From 0308eeee300a6d45586f356caa759b87dda7f285 Mon Sep 17 00:00:00 2001 From: danmaps Date: Sun, 1 Mar 2026 01:34:37 -0800 Subject: [PATCH] Fix: sanitize map names using full Windows invalid set --- cli/arcgispro_cli/paths.py | 62 +++++++++++++++++++++----------------- cli/tests/test_paths.py | 35 +++++++++++++++++++++ 2 files changed, 70 insertions(+), 27 deletions(-) create mode 100644 cli/tests/test_paths.py diff --git a/cli/arcgispro_cli/paths.py b/cli/arcgispro_cli/paths.py index 1750919..fb826b6 100644 --- a/cli/arcgispro_cli/paths.py +++ b/cli/arcgispro_cli/paths.py @@ -1,6 +1,7 @@ """Utility functions for finding and validating .arcgispro folders.""" import json +import re from pathlib import Path from typing import Optional, Dict, Any, List @@ -51,36 +52,43 @@ def get_snapshot_folder(arcgispro_path: Path) -> Path: def sanitize_map_name(name: str) -> str: - """ - Sanitize a map name for use as a filename. - - Replicates the C# SanitizeFileName logic from ImageExporter.cs. - Replaces invalid filename characters with underscores, truncates to 50 chars. - - Args: - name: The map or layer name to sanitize. - - Returns: - Sanitized filename without extension, or "unnamed" if empty. + """Sanitize a map/layer name for use as a filename. + + Intended to match the ProExporter add-in's filename sanitization. + + Rules: + - Replace Windows-invalid filename characters with underscores + - Replace ASCII control chars (0-31) with underscores + - Collapse whitespace to single underscores + - Truncate to 50 characters + + Returns "unnamed" if the input is empty/whitespace, or if sanitization + results in an empty name. """ if not name or not name.strip(): return "unnamed" - - # Invalid filename characters: / \ : * ? " < > | - invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] - - sanitized = name - for char in invalid_chars: - sanitized = sanitized.replace(char, '_') - - # Replace spaces with underscores - sanitized = sanitized.replace(' ', '_') - - # Truncate to 50 characters - if len(sanitized) > 50: - sanitized = sanitized[:50] - - return sanitized + + # Windows-invalid filename characters: <>:"/\\|?* + # Plus ASCII control chars (0-31) which Windows also disallows. + invalid_chars = set('<>:"/\\|?*') + + def repl(match: re.Match) -> str: + ch = match.group(0) + if ch in invalid_chars or ord(ch) < 32: + return "_" + # Any whitespace becomes underscore + return "_" + + # Replace invalid chars and whitespace with underscores. + sanitized = re.sub(r"[\s<>:\"/\\|\?\*\x00-\x1f]", repl, name) + + # Collapse repeated underscores and trim. + sanitized = re.sub(r"_+", "_", sanitized).strip("_") + + if not sanitized: + return "unnamed" + + return sanitized[:50] def load_json_file(path: Path) -> Optional[Dict[str, Any]]: diff --git a/cli/tests/test_paths.py b/cli/tests/test_paths.py new file mode 100644 index 0000000..a625c35 --- /dev/null +++ b/cli/tests/test_paths.py @@ -0,0 +1,35 @@ +import pytest + +from arcgispro_cli.paths import sanitize_map_name + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("", "unnamed"), + (" ", "unnamed"), + ("Parcels", "Parcels"), + ("A/B\\C:D*E?F\"GI|J", "A_B_C_D_E_F_G_H_I_J"), + ("hello world\tagain", "hello_world_again"), + ], +) +def test_sanitize_map_name_basic(raw, expected): + assert sanitize_map_name(raw) == expected + + +def test_sanitize_map_name_control_chars_become_underscores(): + # ASCII 0x01 and 0x1F are invalid on Windows + raw = "A\x01B\x1fC" + assert sanitize_map_name(raw) == "A_B_C" + + +def test_sanitize_map_name_truncates_to_50_chars(): + raw = "x" * 80 + out = sanitize_map_name(raw) + assert len(out) == 50 + assert out == "x" * 50 + + +def test_sanitize_map_name_all_invalid_falls_back_to_unnamed(): + raw = "<>:\"/\\|?*\t\n" + assert sanitize_map_name(raw) == "unnamed"