From 2d67ce9782649186fa681f85e91f8c54f4f465cb Mon Sep 17 00:00:00 2001 From: nlebovits Date: Fri, 10 Jul 2026 15:19:24 +0200 Subject: [PATCH 1/2] refactor: group flat root modules into scan/ sync/ viz/ subpackages (#625) Part of #618. Relocate 20 flat portolan_cli/ root modules into three responsibility-scoped subpackages, shrinking the flat root from 64 to 44 .py files: - sync/ <- push, pull, upload, upload_progress, download, checksums; the sync/clone orchestrator lives in sync/core.py - scan/ <- scan_classify->classify, scan_detect->detect, scan_infer->infer, scan_fix->fix, scan_output->output, scan_progress->progress, check; the scan orchestrator lives in scan/core.py - viz/ <- thumbnail, thumbnail_style, style, pmtiles, pmtiles_links The two orchestrators (formerly scan.py / sync.py) live in /core.py with import-free __init__.py. This is deliberate: making the orchestrator the package __init__ would force every leaf-submodule import to eagerly run it, reintroducing the collection_id<->scan and add<->sync import cycles. Public symbols are now imported from portolan_cli.scan.core / portolan_cli.sync.core. All moves are pure git renames (history preserved); the repo uses absolute imports exclusively, so relocating files left their own imports valid and only references TO the moved modules were rewritten. Import-linter contracts lock the new boundaries: - viz-is-a-leaf: viz imports neither scan nor sync (transitive). - scan-below-sync: scan never directly imports sync/viz (direct-only, since the check command legitimately fans out through shared root modules). The validation-seam ignore_imports comments are updated to the new paths. CLI entry point (portolan_cli:cli) unchanged. Verified locally: lint-imports (7/7), mypy --strict (151 files), ruff, and targeted scan/sync/push/pull/viz/validation suites (336 passed) green. --- portolan_cli/add.py | 6 +- portolan_cli/backends/iceberg/backend.py | 6 +- portolan_cli/backends/json_file.py | 2 +- portolan_cli/cli.py | 40 +- portolan_cli/collection_id.py | 4 +- portolan_cli/convert.py | 2 +- portolan_cli/discovery.py | 2 +- .../extract/common/orchestrator_base.py | 2 +- portolan_cli/metadata/fix.py | 4 +- portolan_cli/metadata/scan.py | 2 +- portolan_cli/preparation.py | 8 +- portolan_cli/query.py | 2 +- portolan_cli/scan/__init__.py | 8 + portolan_cli/{ => scan}/check.py | 2 +- .../{scan_classify.py => scan/classify.py} | 0 portolan_cli/{scan.py => scan/core.py} | 12 +- .../{scan_detect.py => scan/detect.py} | 2 +- portolan_cli/{scan_fix.py => scan/fix.py} | 2 +- portolan_cli/{scan_infer.py => scan/infer.py} | 2 +- .../{scan_output.py => scan/output.py} | 8 +- .../{scan_progress.py => scan/progress.py} | 2 +- portolan_cli/status.py | 4 +- portolan_cli/sync/__init__.py | 8 + portolan_cli/{ => sync}/checksums.py | 0 portolan_cli/{sync.py => sync/core.py} | 10 +- portolan_cli/{ => sync}/download.py | 6 +- portolan_cli/{ => sync}/pull.py | 8 +- portolan_cli/{ => sync}/push.py | 4 +- portolan_cli/{ => sync}/upload.py | 2 +- portolan_cli/{ => sync}/upload_progress.py | 2 +- portolan_cli/validation/rules.py | 4 +- portolan_cli/viz/__init__.py | 6 + portolan_cli/{ => viz}/pmtiles.py | 10 +- portolan_cli/{ => viz}/pmtiles_links.py | 2 +- portolan_cli/{ => viz}/style.py | 0 portolan_cli/{ => viz}/thumbnail.py | 6 +- portolan_cli/{ => viz}/thumbnail_style.py | 0 pyproject.toml | 37 +- tests/benchmark/test_scan_performance.py | 4 +- tests/iceberg/unit/test_on_post_add.py | 10 +- tests/iceberg/unit/test_pull.py | 10 +- .../test_check_conversion_config.py | 2 +- .../test_clone_ergonomics_integration.py | 18 +- tests/integration/test_cog_reoptimize.py | 6 +- .../test_concurrency_enforcement.py | 16 +- .../integration/test_download_integration.py | 38 +- .../integration/test_nested_catalog_clone.py | 20 +- tests/integration/test_partition_paths.py | 2 +- tests/integration/test_pmtiles_integration.py | 28 +- tests/integration/test_pull_integration.py | 64 +-- .../test_pull_parallel_integration.py | 2 +- tests/integration/test_push_integration.py | 146 +++---- .../test_push_parallel_integration.py | 18 +- tests/integration/test_s3_moto.py | 18 +- .../test_scan_unrecognized_files.py | 4 +- tests/integration/test_sync_integration.py | 68 ++-- tests/integration/test_thumbnail_workflow.py | 32 +- tests/integration/test_upload_integration.py | 24 +- .../test_extensions_doc_parity.py | 2 +- .../extract/common/test_orchestrator_base.py | 4 +- tests/unit/test_add.py | 2 +- tests/unit/test_asset_role_media.py | 2 +- tests/unit/test_check_remove_legacy.py | 10 +- tests/unit/test_check_workflow.py | 20 +- tests/unit/test_cli_check.py | 14 +- tests/unit/test_cli_profile_default.py | 26 +- tests/unit/test_cli_push_chunk_concurrency.py | 14 +- tests/unit/test_cli_push_max_connections.py | 8 +- tests/unit/test_cli_push_workers.py | 14 +- tests/unit/test_clone_ergonomics.py | 84 ++-- tests/unit/test_collection_id.py | 10 +- tests/unit/test_concurrency_defaults.py | 12 +- tests/unit/test_concurrency_flag_issue_323.py | 32 +- .../unit/test_discover_nested_collections.py | 2 +- tests/unit/test_download.py | 200 ++++----- tests/unit/test_filegdb_add_support.py | 2 +- .../unit/test_hive_partition_collection_id.py | 4 +- .../test_issue_252_catalog_json_roundtrip.py | 60 +-- tests/unit/test_json_geojson_detection.py | 42 +- tests/unit/test_nested_catalog_inference.py | 2 +- tests/unit/test_partitioning.py | 12 +- tests/unit/test_partitioning_arbitrary.py | 4 +- tests/unit/test_pmtiles.py | 150 +++---- tests/unit/test_pull.py | 212 +++++----- tests/unit/test_pull_async.py | 28 +- tests/unit/test_pull_parallel.py | 22 +- tests/unit/test_pull_restore.py | 52 +-- tests/unit/test_push.py | 382 +++++++++--------- tests/unit/test_push_async.py | 36 +- tests/unit/test_push_catalog_wide.py | 14 +- tests/unit/test_push_default_remote.py | 48 +-- tests/unit/test_push_intermediate_catalogs.py | 12 +- tests/unit/test_push_metadata_sync.py | 52 +-- tests/unit/test_push_root_files.py | 40 +- tests/unit/test_push_verbose.py | 17 +- tests/unit/test_scan.py | 182 ++++----- tests/unit/test_scan_classify.py | 6 +- tests/unit/test_scan_coverage.py | 8 +- tests/unit/test_scan_detect.py | 8 +- tests/unit/test_scan_edge_cases.py | 2 +- tests/unit/test_scan_filegdb_discovery.py | 4 +- tests/unit/test_scan_fix.py | 38 +- tests/unit/test_scan_fix_coverage.py | 4 +- tests/unit/test_scan_fix_normalization.py | 2 +- tests/unit/test_scan_infer.py | 14 +- tests/unit/test_scan_infer_coverage.py | 2 +- tests/unit/test_scan_nested.py | 32 +- tests/unit/test_scan_output.py | 160 ++++---- tests/unit/test_scan_output_batching.py | 4 +- tests/unit/test_scan_progress.py | 4 +- tests/unit/test_scan_structure.py | 42 +- .../test_scan_unrecognized_files_property.py | 4 +- tests/unit/test_style.py | 116 +++--- tests/unit/test_sync.py | 244 +++++------ tests/unit/test_tabular_support.py | 16 +- tests/unit/test_thumbnail.py | 150 +++---- tests/unit/test_thumbnail_style.py | 2 +- tests/unit/test_upload.py | 190 ++++----- tests/unit/test_upload_progress.py | 2 +- tests/unit/test_validation_rules.py | 10 +- 120 files changed, 1873 insertions(+), 1777 deletions(-) create mode 100644 portolan_cli/scan/__init__.py rename portolan_cli/{ => scan}/check.py (99%) rename portolan_cli/{scan_classify.py => scan/classify.py} (100%) rename portolan_cli/{scan.py => scan/core.py} (99%) rename portolan_cli/{scan_detect.py => scan/detect.py} (99%) rename portolan_cli/{scan_fix.py => scan/fix.py} (99%) rename portolan_cli/{scan_infer.py => scan/infer.py} (99%) rename portolan_cli/{scan_output.py => scan/output.py} (99%) rename portolan_cli/{scan_progress.py => scan/progress.py} (99%) create mode 100644 portolan_cli/sync/__init__.py rename portolan_cli/{ => sync}/checksums.py (100%) rename portolan_cli/{sync.py => sync/core.py} (98%) rename portolan_cli/{ => sync}/download.py (99%) rename portolan_cli/{ => sync}/pull.py (99%) rename portolan_cli/{ => sync}/push.py (99%) rename portolan_cli/{ => sync}/upload.py (99%) rename portolan_cli/{ => sync}/upload_progress.py (98%) create mode 100644 portolan_cli/viz/__init__.py rename portolan_cli/{ => viz}/pmtiles.py (99%) rename portolan_cli/{ => viz}/pmtiles_links.py (96%) rename portolan_cli/{ => viz}/style.py (100%) rename portolan_cli/{ => viz}/thumbnail.py (99%) rename portolan_cli/{ => viz}/thumbnail_style.py (100%) diff --git a/portolan_cli/add.py b/portolan_cli/add.py index 844bc0c7..ff0d3e89 100644 --- a/portolan_cli/add.py +++ b/portolan_cli/add.py @@ -25,7 +25,6 @@ import click import pystac -from portolan_cli.checksums import compute_checksum from portolan_cli.collection import ( _compute_union_bbox, _get_metadata_yaml_bbox, @@ -151,7 +150,8 @@ create_collection, update_collection_file_statistics, ) -from portolan_cli.style import enrich_cog_assets +from portolan_cli.sync.checksums import compute_checksum +from portolan_cli.viz.style import enrich_cog_assets logger = logging.getLogger(__name__) @@ -629,7 +629,7 @@ def _update_versions( # ───────────────────────────────────────────────────────────────────────────── # Note: GEOSPATIAL_EXTENSIONS imported from portolan_cli.constants -# Note: is_filegdb is imported from portolan_cli.scan_detect (canonical implementation). +# Note: is_filegdb is imported from portolan_cli.scan.detect (canonical implementation). # scan_detect.is_filegdb accepts either .gdbtable files OR a 'gdb' marker file, which # matches the full FileGDB spec. Do not reimplement here. diff --git a/portolan_cli/backends/iceberg/backend.py b/portolan_cli/backends/iceberg/backend.py index df1faef1..dd375436 100644 --- a/portolan_cli/backends/iceberg/backend.py +++ b/portolan_cli/backends/iceberg/backend.py @@ -411,7 +411,7 @@ def _upload_stac_metadata( Only uploads STAC metadata (item JSON, collection JSON, catalog.json). Data files are NOT uploaded — they live in the Iceberg warehouse. """ - from portolan_cli.upload import upload_file + from portolan_cli.sync.upload import upload_file remote = remote.rstrip("/") @@ -444,9 +444,9 @@ def pull( Queries get_current_version() for asset info, then downloads each asset from {remote_url}/{href} to {local_root}/{href}. """ - from portolan_cli.download import download_file from portolan_cli.output import detail, error, info, success - from portolan_cli.pull import PullResult + from portolan_cli.sync.download import download_file + from portolan_cli.sync.pull import PullResult remote_url = remote_url.rstrip("/") diff --git a/portolan_cli/backends/json_file.py b/portolan_cli/backends/json_file.py index 25e9b82c..d524408e 100644 --- a/portolan_cli/backends/json_file.py +++ b/portolan_cli/backends/json_file.py @@ -145,7 +145,7 @@ def publish( Returns: The newly created Version object. """ - from portolan_cli.checksums import compute_checksum + from portolan_cli.sync.checksums import compute_checksum from portolan_cli.versions import SPEC_VERSION versions_path = self._versions_path(collection) diff --git a/portolan_cli/cli.py b/portolan_cli/cli.py index 6304d2bf..d71a853c 100644 --- a/portolan_cli/cli.py +++ b/portolan_cli/cli.py @@ -19,7 +19,7 @@ from portolan_cli.backends.protocol import VersioningBackend from portolan_cli.extract.arcgis.report import ExtractionReport from portolan_cli.extract.arcgis.url_parser import ParsedArcGISURL - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult import click @@ -31,7 +31,6 @@ CatalogListResult, list_catalog_contents, ) -from portolan_cli.check import check_directory from portolan_cli.collection_id import resolve_collection_id from portolan_cli.constants import PORTOLAN_SPEC_VERSION from portolan_cli.convert import ConversionResult @@ -43,19 +42,20 @@ from portolan_cli.output import info as info_output from portolan_cli.query import ItemInfo from portolan_cli.remove import remove_files -from portolan_cli.scan import ( +from portolan_cli.scan.check import check_directory +from portolan_cli.scan.core import ( IssueType, ScanIssue, ScanOptions, ScanResult, scan_directory, ) -from portolan_cli.scan import ( +from portolan_cli.scan.core import ( Severity as ScanSeverity, ) -from portolan_cli.scan_fix import ProposedFix, apply_safe_fixes -from portolan_cli.scan_infer import infer_collections -from portolan_cli.scan_output import ( +from portolan_cli.scan.fix import ProposedFix, apply_safe_fixes +from portolan_cli.scan.infer import infer_collections +from portolan_cli.scan.output import ( format_collection_suggestion, format_fix_commands_json, generate_next_steps, @@ -64,7 +64,7 @@ group_skipped_files, render_tree_view, ) -from portolan_cli.scan_progress import ScanProgressReporter, count_directories +from portolan_cli.scan.progress import ScanProgressReporter, count_directories from portolan_cli.stac import MergeStrategy from portolan_cli.status import CollectionStatus, get_collection_status from portolan_cli.temporal import FLEXIBLE_DATETIME @@ -789,7 +789,7 @@ def status_cmd( remote_url = resolve_remote(None, catalog_path, collection) # Discover collections - from portolan_cli.push import discover_collections + from portolan_cli.sync.push import discover_collections if collection: collections = [collection] @@ -1400,7 +1400,7 @@ def _execute_check_workflow( - Without --fix: run validation and report issues - With --fix: run validation AND apply fixes for the selected scope """ - from portolan_cli.check import build_check_rules + from portolan_cli.scan.check import build_check_rules # Build rules (respects config severity overrides + strict flag) rules = build_check_rules(path, strict=strict) @@ -1465,7 +1465,7 @@ def _run_fix_workflow( force: Re-optimize already-valid COGs (raster-scoped, issue #530). workers: Parallel worker processes for conversion. """ - from portolan_cli.check import run_fix_workflow + from portolan_cli.scan.check import run_fix_workflow # Progress callback for conversion (skip for JSON mode, per ADR-0040: per-file only in verbose) def show_conversion_progress(result: ConversionResult) -> None: @@ -1962,7 +1962,7 @@ def _output_scan_results( if use_json: _output_scan_json(result, strict=strict, has_strict_failure=has_strict_failure) elif manual_only: - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output output = format_scan_output(result, manual_only=True) click.echo(output) @@ -2272,7 +2272,7 @@ def _print_skipped_by_category(result: ScanResult, *, show_all: bool = False) -> return # Check if any files are truly unknown - from portolan_cli.scan_classify import FileCategory + from portolan_cli.scan.classify import FileCategory unknown_files = grouped.get(FileCategory.UNKNOWN, []) unknown_count = len(unknown_files) @@ -2951,7 +2951,7 @@ def on_file_progress(file_path: Path) -> None: ) # Handle PMTiles generation BEFORE output (so JSON reflects final state) - from portolan_cli.pmtiles import generate_or_suggest_pmtiles + from portolan_cli.viz.pmtiles import generate_or_suggest_pmtiles generate_or_suggest_pmtiles( catalog_root, @@ -3469,7 +3469,7 @@ def push( """ import asyncio - from portolan_cli.push import PushConflictError, push_all_collections, push_async + from portolan_cli.sync.push import PushConflictError, push_all_collections, push_async use_json = should_output_json(ctx, json_output) @@ -3801,8 +3801,8 @@ def pull_command( portolan pull s3://mybucket/catalog portolan pull s3://mybucket/catalog --workers 4 """ - from portolan_cli.pull import pull as pull_fn - from portolan_cli.pull import pull_all_collections + from portolan_cli.sync.pull import pull as pull_fn + from portolan_cli.sync.pull import pull_all_collections use_json = should_output_json(ctx, json_output) @@ -3993,7 +3993,7 @@ def sync( portolan sync s3://mybucket/catalog -c data --profile prod portolan sync --collection demographics # Uses configured remote """ - from portolan_cli.sync import sync as sync_fn + from portolan_cli.sync.core import sync as sync_fn use_json = should_output_json(ctx, json_output) @@ -4142,8 +4142,8 @@ def clone( # Clone specific collection with profile portolan clone s3://mybucket/catalog ./data -c imagery --profile prod """ - from portolan_cli.sync import clone as clone_fn - from portolan_cli.sync import infer_local_path_from_url + from portolan_cli.sync.core import clone as clone_fn + from portolan_cli.sync.core import infer_local_path_from_url use_json = should_output_json(ctx, json_output) diff --git a/portolan_cli/collection_id.py b/portolan_cli/collection_id.py index ca539c09..6b18607b 100644 --- a/portolan_cli/collection_id.py +++ b/portolan_cli/collection_id.py @@ -19,7 +19,7 @@ from pathlib import Path from portolan_cli.formats import FormatType, detect_format -from portolan_cli.scan_detect import is_filegdb +from portolan_cli.scan.detect import is_filegdb # Pattern for valid collection IDs (supports path syntax per ADR-0032): # - Start with lowercase letter or number (year-based organization like 2020/) @@ -253,7 +253,7 @@ def infer_nested_collection_id(path: Path, catalog_root: Path) -> str: ValueError: If path is not inside catalog root, at root level, or if raster data lacks required item subdirectory structure. """ - from portolan_cli.scan_detect import is_hive_partition_dir + from portolan_cli.scan.detect import is_hive_partition_dir # Get path relative to catalog root try: diff --git a/portolan_cli/convert.py b/portolan_cli/convert.py index 809d86b8..979eb471 100644 --- a/portolan_cli/convert.py +++ b/portolan_cli/convert.py @@ -39,7 +39,7 @@ detect_format, get_cloud_native_status, ) -from portolan_cli.thumbnail import ( +from portolan_cli.viz.thumbnail import ( ThumbnailConfig, generate_vector_thumbnail, get_thumbnail_config, diff --git a/portolan_cli/discovery.py b/portolan_cli/discovery.py index 19efad8a..e1707911 100644 --- a/portolan_cli/discovery.py +++ b/portolan_cli/discovery.py @@ -10,7 +10,7 @@ GEOSPATIAL_EXTENSIONS, SIDECAR_PATTERNS, ) -from portolan_cli.scan_detect import is_filegdb +from portolan_cli.scan.detect import is_filegdb logger = logging.getLogger(__name__) diff --git a/portolan_cli/extract/common/orchestrator_base.py b/portolan_cli/extract/common/orchestrator_base.py index f8a748bf..6f7156b8 100644 --- a/portolan_cli/extract/common/orchestrator_base.py +++ b/portolan_cli/extract/common/orchestrator_base.py @@ -120,7 +120,7 @@ def register_collection_styles( report: The extraction report. include_legends: Also discover and register legend sidecars (WFS/WMS). """ - from portolan_cli.style import ( + from portolan_cli.viz.style import ( discover_legends, discover_styles, register_legend_assets, diff --git a/portolan_cli/metadata/fix.py b/portolan_cli/metadata/fix.py index 556f2475..4f703600 100644 --- a/portolan_cli/metadata/fix.py +++ b/portolan_cli/metadata/fix.py @@ -295,11 +295,11 @@ def repair_pmtiles_links(catalog_root: Path, *, dry_run: bool = False) -> list[F def _repair_pmtiles_collection(collection_json: Path, *, dry_run: bool) -> FixResult | None: """Backfill PMTiles links / extension for one collection (see repair_pmtiles_links).""" - from portolan_cli.pmtiles import ( + from portolan_cli.viz.pmtiles import ( add_pmtiles_link_to_collection, ensure_web_map_links_extension, ) - from portolan_cli.pmtiles_links import ( + from portolan_cli.viz.pmtiles_links import ( WEB_MAP_LINKS_EXTENSION, pmtiles_asset_hrefs, pmtiles_link_hrefs, diff --git a/portolan_cli/metadata/scan.py b/portolan_cli/metadata/scan.py index 3e556999..25e6ed4e 100644 --- a/portolan_cli/metadata/scan.py +++ b/portolan_cli/metadata/scan.py @@ -23,7 +23,6 @@ from pathlib import Path from typing import Any -from portolan_cli.checksums import compute_checksum from portolan_cli.metadata.detection import ( check_file_metadata, detect_changes, @@ -36,6 +35,7 @@ MetadataReport, MetadataStatus, ) +from portolan_cli.sync.checksums import compute_checksum _DATA_EXTENSIONS = frozenset( { diff --git a/portolan_cli/preparation.py b/portolan_cli/preparation.py index a80afcd4..fdc1d4db 100644 --- a/portolan_cli/preparation.py +++ b/portolan_cli/preparation.py @@ -25,7 +25,6 @@ import pystac from portolan_cli import extension_registry as _reg -from portolan_cli.checksums import compute_checksum, compute_dir_checksum, compute_dir_size from portolan_cli.collection_id import normalize_collection_id, validate_collection_id from portolan_cli.config import get_setting, load_merged_metadata from portolan_cli.crs import transform_bbox_to_wgs84 @@ -49,14 +48,15 @@ apply_temporal_defaults, validate_metadata, ) -from portolan_cli.scan_detect import is_filegdb +from portolan_cli.scan.detect import is_filegdb from portolan_cli.stac import ( add_projection_extension, add_raster_extension, add_vector_extension, create_item, ) -from portolan_cli.style import enrich_cog_assets +from portolan_cli.sync.checksums import compute_checksum, compute_dir_checksum, compute_dir_size +from portolan_cli.viz.style import enrich_cog_assets logger = logging.getLogger(__name__) @@ -443,7 +443,7 @@ def _derive_item_id_and_asset_level( collection-level assets; other formats derive unique IDs from the partition values. """ - from portolan_cli.scan_detect import is_hive_partition_dir + from portolan_cli.scan.detect import is_hive_partition_dir # If item_id is explicitly provided, treat as item-level (not collection-level) # This ensures --item-id creates a subdirectory structure diff --git a/portolan_cli/query.py b/portolan_cli/query.py index e93621e2..1b623eeb 100644 --- a/portolan_cli/query.py +++ b/portolan_cli/query.py @@ -8,13 +8,13 @@ from datetime import datetime from pathlib import Path -from portolan_cli.checksums import compute_checksum, compute_dir_checksum from portolan_cli.constants import ( MTIME_TOLERANCE_SECONDS, ) from portolan_cli.formats import ( FormatType, ) +from portolan_cli.sync.checksums import compute_checksum, compute_dir_checksum from portolan_cli.versions import ( read_versions, ) diff --git a/portolan_cli/scan/__init__.py b/portolan_cli/scan/__init__.py new file mode 100644 index 00000000..b10a8245 --- /dev/null +++ b/portolan_cli/scan/__init__.py @@ -0,0 +1,8 @@ +"""Directory scanning, classification, inference, and catalog ``check``. + +The orchestrator lives in :mod:`portolan_cli.scan.core`; import its public API +as ``from portolan_cli.scan.core import scan_directory`` (etc.). This ``__init__`` +is intentionally import-free: importing a leaf submodule (e.g. ``scan.detect``) +must not eagerly pull in the heavy orchestrator, which would reintroduce the +``collection_id`` <-> ``scan`` import cycle (#625). +""" diff --git a/portolan_cli/check.py b/portolan_cli/scan/check.py similarity index 99% rename from portolan_cli/check.py rename to portolan_cli/scan/check.py index e888eedd..a9619be6 100644 --- a/portolan_cli/check.py +++ b/portolan_cli/scan/check.py @@ -37,7 +37,7 @@ get_effective_status, is_geoparquet, ) -from portolan_cli.scan_detect import is_filegdb +from portolan_cli.scan.detect import is_filegdb if TYPE_CHECKING: from portolan_cli.metadata.fix import FixReport diff --git a/portolan_cli/scan_classify.py b/portolan_cli/scan/classify.py similarity index 100% rename from portolan_cli/scan_classify.py rename to portolan_cli/scan/classify.py diff --git a/portolan_cli/scan.py b/portolan_cli/scan/core.py similarity index 99% rename from portolan_cli/scan.py rename to portolan_cli/scan/core.py index 6ac2be2c..0a9914ec 100644 --- a/portolan_cli/scan.py +++ b/portolan_cli/scan/core.py @@ -8,7 +8,7 @@ import (like ruff check vs ruff format). See ADR-0016 for rationale. Example: - >>> from portolan_cli.scan import scan_directory, ScanOptions + >>> from portolan_cli.scan.core import scan_directory, ScanOptions >>> result = scan_directory(Path("/data/geospatial")) >>> print(f"Found {len(result.ready)} files ready to import") >>> if result.has_errors: @@ -55,22 +55,22 @@ is_geoparquet, is_valid_parquet, ) -from portolan_cli.scan_classify import ( +from portolan_cli.scan.classify import ( STAC_FILENAMES, FileCategory, SkippedFile, SkipReasonType, classify_file, ) -from portolan_cli.scan_detect import ( +from portolan_cli.scan.detect import ( FILEGDB_LOCK_PATTERNS, DualFormatPair, SpecialFormat, is_filegdb, is_hive_partition_dir, ) -from portolan_cli.scan_fix import ProposedFix -from portolan_cli.scan_infer import CollectionSuggestion +from portolan_cli.scan.fix import ProposedFix +from portolan_cli.scan.infer import CollectionSuggestion # ============================================================================= # Constants @@ -1387,7 +1387,7 @@ def scan_directory( NotADirectoryError: If path is not a directory. Example: - >>> from portolan_cli.scan import scan_directory, ScanOptions + >>> from portolan_cli.scan.core import scan_directory, ScanOptions >>> result = scan_directory(Path("/data/geospatial")) >>> print(f"Found {len(result.ready)} files ready to import") >>> if result.has_errors: diff --git a/portolan_cli/scan_detect.py b/portolan_cli/scan/detect.py similarity index 99% rename from portolan_cli/scan_detect.py rename to portolan_cli/scan/detect.py index 15d92566..504b4c1c 100644 --- a/portolan_cli/scan_detect.py +++ b/portolan_cli/scan/detect.py @@ -26,7 +26,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from portolan_cli.scan import ScannedFile + from portolan_cli.scan.core import ScannedFile # Hive partition pattern: key=value where key starts with letter/underscore HIVE_PARTITION_PATTERN = re.compile(r"^([a-zA-Z_][a-zA-Z0-9_]*)=(.+)$") diff --git a/portolan_cli/scan_fix.py b/portolan_cli/scan/fix.py similarity index 99% rename from portolan_cli/scan_fix.py rename to portolan_cli/scan/fix.py index 2756e801..2b524cf9 100644 --- a/portolan_cli/scan_fix.py +++ b/portolan_cli/scan/fix.py @@ -31,7 +31,7 @@ from portolan_cli.constants import WINDOWS_RESERVED_NAMES if TYPE_CHECKING: - from portolan_cli.scan import ScanIssue + from portolan_cli.scan.core import ScanIssue # Pattern for invalid filename characters # Matches: spaces, parentheses, brackets, braces, control chars (0x00-0x1F, 0x7F), and non-ASCII diff --git a/portolan_cli/scan_infer.py b/portolan_cli/scan/infer.py similarity index 99% rename from portolan_cli/scan_infer.py rename to portolan_cli/scan/infer.py index 1c6b19a4..4bfedc30 100644 --- a/portolan_cli/scan_infer.py +++ b/portolan_cli/scan/infer.py @@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from portolan_cli.scan import ScannedFile + from portolan_cli.scan.core import ScannedFile # Numeric suffix pattern: base_123 or base-123 or baseL123 NUMERIC_SUFFIX_PATTERN = re.compile(r"^(.+?)[-_]?(\d+)$") diff --git a/portolan_cli/scan_output.py b/portolan_cli/scan/output.py similarity index 99% rename from portolan_cli/scan_output.py rename to portolan_cli/scan/output.py index 8bc15f92..abf21c38 100644 --- a/portolan_cli/scan_output.py +++ b/portolan_cli/scan/output.py @@ -10,7 +10,7 @@ 6. Tree View Output - Directory tree with file status markers Example: - >>> from portolan_cli.scan_output import format_scan_output + >>> from portolan_cli.scan.output import format_scan_output >>> output = format_scan_output(scan_result) >>> print(output) """ @@ -24,17 +24,17 @@ from typing import TYPE_CHECKING, Any from portolan_cli.formats import FormatType -from portolan_cli.scan import ( +from portolan_cli.scan.classify import FileCategory, SkippedFile +from portolan_cli.scan.core import ( IssueType, ScanIssue, ScannedFile, ScanResult, Severity, ) -from portolan_cli.scan_classify import FileCategory, SkippedFile if TYPE_CHECKING: - from portolan_cli.scan_infer import CollectionSuggestion + from portolan_cli.scan.infer import CollectionSuggestion # ============================================================================= diff --git a/portolan_cli/scan_progress.py b/portolan_cli/scan/progress.py similarity index 99% rename from portolan_cli/scan_progress.py rename to portolan_cli/scan/progress.py index 9ca9ea9f..00047fe8 100644 --- a/portolan_cli/scan_progress.py +++ b/portolan_cli/scan/progress.py @@ -6,7 +6,7 @@ 3. Suppression of progress in JSON mode (agent/batch usage) Example: - >>> from portolan_cli.scan_progress import count_directories, ScanProgressReporter + >>> from portolan_cli.scan.progress import count_directories, ScanProgressReporter >>> total = count_directories(Path("/data")) >>> with ScanProgressReporter(total, json_mode=False) as reporter: ... for directory in scan_directories(): diff --git a/portolan_cli/status.py b/portolan_cli/status.py index 4268a09d..a2d2034c 100644 --- a/portolan_cli/status.py +++ b/portolan_cli/status.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Any -from portolan_cli.checksums import compute_checksum +from portolan_cli.sync.checksums import compute_checksum from portolan_cli.versions import VersionsFile, read_versions @@ -295,7 +295,7 @@ def _fetch_remote_version(remote_url: str, collection: str) -> str | None: Remote version string, or None if fetch fails. """ # Import here to avoid circular dependency - from portolan_cli.pull import PullError, _fetch_remote_versions + from portolan_cli.sync.pull import PullError, _fetch_remote_versions try: # Reuse existing remote fetch logic diff --git a/portolan_cli/sync/__init__.py b/portolan_cli/sync/__init__.py new file mode 100644 index 00000000..eaee0068 --- /dev/null +++ b/portolan_cli/sync/__init__.py @@ -0,0 +1,8 @@ +"""Remote sync: push, pull, upload, download, checksums, and clone/sync orchestration. + +The orchestrator lives in :mod:`portolan_cli.sync.core`; import its public API +as ``from portolan_cli.sync.core import sync`` (etc.). This ``__init__`` is +intentionally import-free so importing a leaf submodule (e.g. ``sync.checksums``) +does not eagerly run the orchestrator, which would reintroduce the ``add`` <-> +``sync`` import cycle (#625). +""" diff --git a/portolan_cli/checksums.py b/portolan_cli/sync/checksums.py similarity index 100% rename from portolan_cli/checksums.py rename to portolan_cli/sync/checksums.py diff --git a/portolan_cli/sync.py b/portolan_cli/sync/core.py similarity index 98% rename from portolan_cli/sync.py rename to portolan_cli/sync/core.py index 40b60ce7..a9f85086 100644 --- a/portolan_cli/sync.py +++ b/portolan_cli/sync/core.py @@ -24,12 +24,12 @@ from typing import TYPE_CHECKING, Any from portolan_cli.catalog import CatalogState, detect_state, init_catalog -from portolan_cli.check import CheckReport, check_directory -from portolan_cli.download import download_file from portolan_cli.output import detail, error, info, success, warn -from portolan_cli.pull import PullError, PullResult, pull -from portolan_cli.push import PushConflictError, PushResult, push_async -from portolan_cli.scan import ScanResult, scan_directory +from portolan_cli.scan.check import CheckReport, check_directory +from portolan_cli.scan.core import ScanResult, scan_directory +from portolan_cli.sync.download import download_file +from portolan_cli.sync.pull import PullError, PullResult, pull +from portolan_cli.sync.push import PushConflictError, PushResult, push_async if TYPE_CHECKING: pass diff --git a/portolan_cli/download.py b/portolan_cli/sync/download.py similarity index 99% rename from portolan_cli/download.py rename to portolan_cli/sync/download.py index 097c2174..a78e89ae 100644 --- a/portolan_cli/download.py +++ b/portolan_cli/sync/download.py @@ -13,8 +13,8 @@ - Azure: AZURE_STORAGE_ACCOUNT_KEY, SAS token, or Azure CLI Basic Usage: - from portolan_cli.download import download_file, download_directory - from portolan_cli.upload import check_credentials + from portolan_cli.sync.download import download_file, download_directory + from portolan_cli.sync.upload import check_credentials # Check credentials before download valid, hint = check_credentials("s3://mybucket/path") @@ -71,7 +71,7 @@ ) from portolan_cli.output import detail, error, info, success, warn -from portolan_cli.upload import ( +from portolan_cli.sync.upload import ( _setup_store_and_kwargs, parse_object_store_url, ) diff --git a/portolan_cli/pull.py b/portolan_cli/sync/pull.py similarity index 99% rename from portolan_cli/pull.py rename to portolan_cli/sync/pull.py index 4737a4d7..956eb814 100644 --- a/portolan_cli/pull.py +++ b/portolan_cli/sync/pull.py @@ -39,10 +39,10 @@ CircuitBreakerError, get_default_concurrency, ) -from portolan_cli.checksums import compute_checksum -from portolan_cli.download import download_file, get_remote_file_size_async from portolan_cli.output import detail, error, info, output_section, success, warn -from portolan_cli.upload import _setup_store_and_kwargs, parse_object_store_url +from portolan_cli.sync.checksums import compute_checksum +from portolan_cli.sync.download import download_file, get_remote_file_size_async +from portolan_cli.sync.upload import _setup_store_and_kwargs, parse_object_store_url from portolan_cli.versions import ( VersionsFile, _parse_versions_file, @@ -1313,7 +1313,7 @@ async def pull_all_collections_async( ValueError: If local_root is not a valid catalog. """ # Import here to avoid circular dependency - from portolan_cli.push import discover_collections + from portolan_cli.sync.push import discover_collections # Validate URL and create shared store (connection pooling) try: diff --git a/portolan_cli/push.py b/portolan_cli/sync/push.py similarity index 99% rename from portolan_cli/push.py rename to portolan_cli/sync/push.py index c3f4a5b3..ce30dbb7 100644 --- a/portolan_cli/push.py +++ b/portolan_cli/sync/push.py @@ -38,8 +38,8 @@ ) from portolan_cli.catalog import intermediate_catalog_ids from portolan_cli.output import detail, error, info, output_section, success, warn -from portolan_cli.upload import ObjectStore, setup_store -from portolan_cli.upload_progress import UploadProgressReporter +from portolan_cli.sync.upload import ObjectStore, setup_store +from portolan_cli.sync.upload_progress import UploadProgressReporter __all__ = [ "get_default_workers", diff --git a/portolan_cli/upload.py b/portolan_cli/sync/upload.py similarity index 99% rename from portolan_cli/upload.py rename to portolan_cli/sync/upload.py index 4936da26..f38f6c26 100644 --- a/portolan_cli/upload.py +++ b/portolan_cli/sync/upload.py @@ -13,7 +13,7 @@ - Azure: AZURE_STORAGE_ACCOUNT_KEY, SAS token, or Azure CLI Basic Usage: - from portolan_cli.upload import upload_file, upload_directory, check_credentials + from portolan_cli.sync.upload import upload_file, upload_directory, check_credentials # Check credentials before upload valid, hint = check_credentials("s3://mybucket/path") diff --git a/portolan_cli/upload_progress.py b/portolan_cli/sync/upload_progress.py similarity index 98% rename from portolan_cli/upload_progress.py rename to portolan_cli/sync/upload_progress.py index 606bcd8a..62866561 100644 --- a/portolan_cli/upload_progress.py +++ b/portolan_cli/sync/upload_progress.py @@ -6,7 +6,7 @@ 3. Suppression of progress in JSON mode (agent/batch usage) Example: - >>> from portolan_cli.upload_progress import UploadProgressReporter + >>> from portolan_cli.sync.upload_progress import UploadProgressReporter >>> with UploadProgressReporter(total_files=100, total_bytes=1_000_000) as reporter: ... for file in files: ... upload(file) diff --git a/portolan_cli/validation/rules.py b/portolan_cli/validation/rules.py index 331244e6..28e68972 100644 --- a/portolan_cli/validation/rules.py +++ b/portolan_cli/validation/rules.py @@ -14,7 +14,7 @@ from pathlib import Path, PurePath from typing import Any -from portolan_cli.scan_classify import ( +from portolan_cli.scan.classify import ( GEO_ASSET_EXTENSIONS, TABULAR_EXTENSIONS, is_geoparquet, @@ -622,7 +622,7 @@ def _scan_collection( # Import from the framework-free leaf, not pmtiles.py: the latter pulls # in output/thumbnail/style (and click/rich/config), which would break # the reis extraction seam (issue #563). - from portolan_cli.pmtiles_links import ( + from portolan_cli.viz.pmtiles_links import ( WEB_MAP_LINKS_EXTENSION, pmtiles_asset_hrefs, pmtiles_link_hrefs, diff --git a/portolan_cli/viz/__init__.py b/portolan_cli/viz/__init__.py new file mode 100644 index 00000000..4894dd9a --- /dev/null +++ b/portolan_cli/viz/__init__.py @@ -0,0 +1,6 @@ +"""Visualization assets: thumbnails, Mapbox GL styles, and PMTiles helpers. + +Groups the modules that produce or classify visual/tiling artifacts for a +catalog (see ADR-0043/0045 styles, ADR-0050 PMTiles). This package is a leaf: +it must not import the ``scan`` or ``sync`` layers (enforced by import-linter). +""" diff --git a/portolan_cli/pmtiles.py b/portolan_cli/viz/pmtiles.py similarity index 99% rename from portolan_cli/pmtiles.py rename to portolan_cli/viz/pmtiles.py index 627b76d9..0595b9de 100644 --- a/portolan_cli/pmtiles.py +++ b/portolan_cli/viz/pmtiles.py @@ -10,7 +10,7 @@ - tippecanoe binary installed and in PATH Usage: - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection result = generate_pmtiles_for_collection( collection_path=Path("municipalities"), @@ -38,8 +38,8 @@ # importing this module (which pulls in output/thumbnail/style). Re-imported # here — both constants are used below — so pmtiles.py's public API is unchanged # for existing callers/tests (see pmtiles_links.py, issue #563). -from portolan_cli.pmtiles_links import PMTILES_MEDIA_TYPE, WEB_MAP_LINKS_EXTENSION -from portolan_cli.thumbnail import ( +from portolan_cli.viz.pmtiles_links import PMTILES_MEDIA_TYPE, WEB_MAP_LINKS_EXTENSION +from portolan_cli.viz.thumbnail import ( generate_vector_thumbnail, get_thumbnail_config, thumbnail_path_for, @@ -325,7 +325,7 @@ def _write_default_style_for_geoparquet( try: from portolan_cli.metadata.geoparquet import extract_geoparquet_metadata - from portolan_cli.style import ( + from portolan_cli.viz.style import ( VectorStyleConfig, get_vector_style_config, write_default_style, @@ -906,7 +906,7 @@ def generate_pmtiles_for_collection( _track_side_step_assets(collection_path, pmtiles_path, thumb_path, catalog_root) # Discover and register style assets (ADR-0045) - from portolan_cli.style import discover_styles, register_style_assets + from portolan_cli.viz.style import discover_styles, register_style_assets styles = discover_styles(collection_path) register_style_assets(collection_path, styles) diff --git a/portolan_cli/pmtiles_links.py b/portolan_cli/viz/pmtiles_links.py similarity index 96% rename from portolan_cli/pmtiles_links.py rename to portolan_cli/viz/pmtiles_links.py index 1364ae8f..cce3de4e 100644 --- a/portolan_cli/pmtiles_links.py +++ b/portolan_cli/viz/pmtiles_links.py @@ -1,6 +1,6 @@ """Framework-free PMTiles asset/link constants and classifiers. -These helpers are extracted from :mod:`portolan_cli.pmtiles` so the validation +These helpers are extracted from :mod:`portolan_cli.viz.pmtiles` so the validation layer can classify PMTiles assets and links **without** importing ``pmtiles.py``, which pulls in the ``output``/``thumbnail``/``style`` layers (and transitively ``click``/``rich``/``config``). diff --git a/portolan_cli/style.py b/portolan_cli/viz/style.py similarity index 100% rename from portolan_cli/style.py rename to portolan_cli/viz/style.py diff --git a/portolan_cli/thumbnail.py b/portolan_cli/viz/thumbnail.py similarity index 99% rename from portolan_cli/thumbnail.py rename to portolan_cli/viz/thumbnail.py index e11a15f0..163bd8de 100644 --- a/portolan_cli/thumbnail.py +++ b/portolan_cli/viz/thumbnail.py @@ -730,7 +730,7 @@ def _render_geometries( # extracted paint (#518). categorical_style = None if style_path: - from portolan_cli.thumbnail_style import load_thumbnail_style + from portolan_cli.viz.thumbnail_style import load_thumbnail_style loaded = load_thumbnail_style(style_path) if loaded and loaded.color_map and loaded.color_field: @@ -753,7 +753,7 @@ def _render_geometries( # Categorical fill from the style (with a punchy fallback), else preset. if categorical_style is not None: - from portolan_cli.thumbnail_style import resolve_color_for_properties + from portolan_cli.viz.thumbnail_style import resolve_color_for_properties fill_color = resolve_color_for_properties( props, categorical_style, fallback=THUMB_FILL_COLOR @@ -1004,7 +1004,7 @@ def _render_geoparquet( fill_color: str | Any = THUMB_FILL_COLOR # Any allows pd.Series if style_path: - from portolan_cli.thumbnail_style import ( + from portolan_cli.viz.thumbnail_style import ( load_thumbnail_style, resolve_colors_for_gdf, ) diff --git a/portolan_cli/thumbnail_style.py b/portolan_cli/viz/thumbnail_style.py similarity index 100% rename from portolan_cli/thumbnail_style.py rename to portolan_cli/viz/thumbnail_style.py diff --git a/pyproject.toml b/pyproject.toml index fa9c759a..82af5fa8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -335,10 +335,10 @@ forbidden_modules = [ # dependency, so they move with `reis` or vendor trivially at extraction time. # Documented per the second checkbox on #563 (which listed only scan_classify; # metadata.scan and bbox are the same kind of coupling, added since). -# portolan_cli.validation -> portolan_cli.scan_classify +# portolan_cli.validation -> portolan_cli.scan.classify # portolan_cli.validation -> portolan_cli.metadata.scan # portolan_cli.validation -> portolan_cli.bbox -# portolan_cli.validation -> portolan_cli.pmtiles_links +# portolan_cli.validation -> portolan_cli.viz.pmtiles_links # (pmtiles_links is a stdlib-only leaf holding the PMTiles asset/link classifiers; # it exists precisely so validation never imports pmtiles.py, issue #586/#592.) @@ -364,6 +364,39 @@ modules = [ "portolan_cli.json_output", ] +# ---------- feature-package boundaries (#625) ---------- +# The scan/, sync/, and viz/ subpackages group the modules that used to sit +# flat in the root. Their real dependency DAG is: sync (top orchestration) may +# reach down into scan (its `sync` orchestrator drives check/scan); scan and viz +# are independent peers, and neither reaches back up into sync. These two +# contracts lock that shape so the grouping can't silently erode. + +[[tool.importlinter.contracts]] +id = "viz-is-a-leaf" +name = "viz must not import the scan or sync layers" +type = "forbidden" +# viz/ only produces/classifies visual + tiling artifacts (thumbnails, Mapbox GL +# styles, PMTiles). It is a leaf: it depends on neither scanning nor sync. +source_modules = ["portolan_cli.viz"] +forbidden_modules = ["portolan_cli.scan", "portolan_cli.sync"] + +[[tool.importlinter.contracts]] +id = "scan-below-sync" +name = "scan must not directly import the sync or viz layers" +type = "forbidden" +# scan/ (directory scanning, classification, inference, check) sits below sync +# and is independent of viz. sync may import scan, never the reverse. +# +# Direct-only: `scan.check` is a command-level orchestrator that legitimately +# fans out through shared root modules (catalog, metadata, convert), which in +# turn depend on sync.checksums and viz.*. Those transitive chains are the +# existing architecture, not a scan->sync/viz coupling. What we lock here is +# the boundary that actually matters: no scan module may *directly* import the +# sync or viz packages. (viz-is-a-leaf stays transitive because viz truly is.) +source_modules = ["portolan_cli.scan"] +forbidden_modules = ["portolan_cli.sync", "portolan_cli.viz"] +allow_indirect_imports = true + # ---------- dependency checking ---------- # deptry detects unused, missing, and transitive dependencies # Rules: DEP001 (missing), DEP002 (unused), DEP003 (transitive), DEP004 (imported as dev) diff --git a/tests/benchmark/test_scan_performance.py b/tests/benchmark/test_scan_performance.py index b165134b..3e0eeb1f 100644 --- a/tests/benchmark/test_scan_performance.py +++ b/tests/benchmark/test_scan_performance.py @@ -21,7 +21,7 @@ import rasterio from rasterio.transform import from_bounds -from portolan_cli.scan import ScanOptions, scan_directory +from portolan_cli.scan.core import ScanOptions, scan_directory if TYPE_CHECKING: from collections.abc import Callable @@ -465,7 +465,7 @@ def test_many_dirs_no_mixed_structure_issues( benchmark_dir_many_dirs: Path, ) -> None: """EuroSAT structure (files only at leaf) should have no mixed structure issues.""" - from portolan_cli.scan import IssueType + from portolan_cli.scan.core import IssueType result = scan_directory(benchmark_dir_many_dirs) diff --git a/tests/iceberg/unit/test_on_post_add.py b/tests/iceberg/unit/test_on_post_add.py index 61042877..c0036623 100644 --- a/tests/iceberg/unit/test_on_post_add.py +++ b/tests/iceberg/unit/test_on_post_add.py @@ -149,7 +149,7 @@ def test_on_post_add_uploads_stac_metadata_when_remote_set( context = _make_context(catalog_root, item_dir, collection, remote="gs://test-bucket/catalog") - with patch("portolan_cli.upload.upload_file") as mock_upload: + with patch("portolan_cli.sync.upload.upload_file") as mock_upload: mock_upload.return_value = MagicMock(success=True) iceberg_backend.on_post_add(context) @@ -175,7 +175,7 @@ def test_on_post_add_no_upload_when_remote_is_none( context = _make_context(catalog_root, item_dir, collection, remote=None) - with patch("portolan_cli.upload.upload_file") as mock_upload: + with patch("portolan_cli.sync.upload.upload_file") as mock_upload: iceberg_backend.on_post_add(context) mock_upload.assert_not_called() @@ -196,7 +196,7 @@ def test_on_post_add_uploads_correct_remote_paths(iceberg_backend, parquet_file, context = _make_context(catalog_root, item_dir, collection, remote="gs://test-bucket/catalog") - with patch("portolan_cli.upload.upload_file") as mock_upload: + with patch("portolan_cli.sync.upload.upload_file") as mock_upload: mock_upload.return_value = MagicMock(success=True) iceberg_backend.on_post_add(context) @@ -223,7 +223,7 @@ def test_on_post_add_strips_trailing_slash(iceberg_backend, parquet_file, catalo context = _make_context(catalog_root, item_dir, collection, remote="gs://test-bucket/catalog/") - with patch("portolan_cli.upload.upload_file") as mock_upload: + with patch("portolan_cli.sync.upload.upload_file") as mock_upload: mock_upload.return_value = MagicMock(success=True) iceberg_backend.on_post_add(context) @@ -246,7 +246,7 @@ def test_on_post_add_no_data_files_uploaded(iceberg_backend, parquet_file, catal context = _make_context(catalog_root, item_dir, collection, remote="gs://test-bucket/catalog") - with patch("portolan_cli.upload.upload_file") as mock_upload: + with patch("portolan_cli.sync.upload.upload_file") as mock_upload: mock_upload.return_value = MagicMock(success=True) iceberg_backend.on_post_add(context) diff --git a/tests/iceberg/unit/test_pull.py b/tests/iceberg/unit/test_pull.py index 304f4d72..314a1bbc 100644 --- a/tests/iceberg/unit/test_pull.py +++ b/tests/iceberg/unit/test_pull.py @@ -24,7 +24,7 @@ def test_pull_calls_get_current_version(iceberg_backend, parquet_file): message="test", ) - with patch("portolan_cli.download.download_file") as mock_download: + with patch("portolan_cli.sync.download.download_file") as mock_download: mock_download.return_value = MagicMock(success=True, files_downloaded=1) result = iceberg_backend.pull( remote_url="gs://test-bucket/catalog", @@ -47,7 +47,7 @@ def test_pull_downloads_from_remote_plus_href(iceberg_backend, parquet_file): message="test", ) - with patch("portolan_cli.download.download_file") as mock_download: + with patch("portolan_cli.sync.download.download_file") as mock_download: mock_download.return_value = MagicMock(success=True, files_downloaded=1) iceberg_backend.pull( remote_url="gs://test-bucket/catalog", @@ -73,7 +73,7 @@ def test_pull_saves_to_local_path(iceberg_backend, parquet_file, tmp_path): local_root = tmp_path / "local_catalog" local_root.mkdir() - with patch("portolan_cli.download.download_file") as mock_download: + with patch("portolan_cli.sync.download.download_file") as mock_download: mock_download.return_value = MagicMock(success=True, files_downloaded=1) iceberg_backend.pull( remote_url="gs://test-bucket/catalog", @@ -110,7 +110,7 @@ def test_pull_dry_run_no_download(iceberg_backend, parquet_file): message="test", ) - with patch("portolan_cli.download.download_file") as mock_download: + with patch("portolan_cli.sync.download.download_file") as mock_download: result = iceberg_backend.pull( remote_url="gs://test-bucket/catalog", local_root=Path("/tmp/test"), @@ -134,7 +134,7 @@ def test_pull_reports_failed_downloads(iceberg_backend, parquet_file): message="test", ) - with patch("portolan_cli.download.download_file") as mock_download: + with patch("portolan_cli.sync.download.download_file") as mock_download: mock_download.return_value = MagicMock(success=False, files_downloaded=0) result = iceberg_backend.pull( remote_url="gs://test-bucket/catalog", diff --git a/tests/integration/test_check_conversion_config.py b/tests/integration/test_check_conversion_config.py index 5bae42dc..fafcc052 100644 --- a/tests/integration/test_check_conversion_config.py +++ b/tests/integration/test_check_conversion_config.py @@ -14,9 +14,9 @@ import pytest -from portolan_cli.check import check_directory from portolan_cli.config import save_config from portolan_cli.formats import CloudNativeStatus +from portolan_cli.scan.check import check_directory class TestCheckWithConversionConfig: diff --git a/tests/integration/test_clone_ergonomics_integration.py b/tests/integration/test_clone_ergonomics_integration.py index 652f1dcd..ed0190ae 100644 --- a/tests/integration/test_clone_ergonomics_integration.py +++ b/tests/integration/test_clone_ergonomics_integration.py @@ -42,8 +42,8 @@ def test_clone_infers_directory_from_url( try: with ( - patch("portolan_cli.sync.list_remote_collections") as mock_list, - patch("portolan_cli.sync.init_catalog") as mock_init, + patch("portolan_cli.sync.core.list_remote_collections") as mock_list, + patch("portolan_cli.sync.core.init_catalog") as mock_init, patch("portolan_cli.sync.pull") as mock_pull, ): mock_list.return_value = ["collection-a"] @@ -80,8 +80,8 @@ def test_clone_all_collections_when_none_specified( target = tmp_path / "catalog" with ( - patch("portolan_cli.sync.list_remote_collections") as mock_list, - patch("portolan_cli.sync.init_catalog") as mock_init, + patch("portolan_cli.sync.core.list_remote_collections") as mock_list, + patch("portolan_cli.sync.core.init_catalog") as mock_init, patch("portolan_cli.sync.pull") as mock_pull, ): mock_list.return_value = ["demographics", "imagery", "boundaries"] @@ -117,8 +117,8 @@ def test_clone_specific_collection_with_c_flag( target = tmp_path / "catalog" with ( - patch("portolan_cli.sync.list_remote_collections") as mock_list, - patch("portolan_cli.sync.init_catalog") as mock_init, + patch("portolan_cli.sync.core.list_remote_collections") as mock_list, + patch("portolan_cli.sync.core.init_catalog") as mock_init, patch("portolan_cli.sync.pull") as mock_pull, ): mock_init.return_value = None @@ -160,7 +160,7 @@ def test_clone_to_current_directory( try: with ( - patch("portolan_cli.sync.init_catalog") as mock_init, + patch("portolan_cli.sync.core.init_catalog") as mock_init, patch("portolan_cli.sync.pull") as mock_pull, ): mock_init.return_value = None @@ -207,8 +207,8 @@ def test_clone_partial_failure_reports_errors( target = tmp_path / "catalog" with ( - patch("portolan_cli.sync.list_remote_collections") as mock_list, - patch("portolan_cli.sync.init_catalog") as mock_init, + patch("portolan_cli.sync.core.list_remote_collections") as mock_list, + patch("portolan_cli.sync.core.init_catalog") as mock_init, patch("portolan_cli.sync.pull") as mock_pull, ): mock_list.return_value = ["good", "bad", "also-good"] diff --git a/tests/integration/test_cog_reoptimize.py b/tests/integration/test_cog_reoptimize.py index b78a380c..daaffaac 100644 --- a/tests/integration/test_cog_reoptimize.py +++ b/tests/integration/test_cog_reoptimize.py @@ -204,7 +204,7 @@ def test_force_fix_reoptimizes_cog_leaves_geoparquet( self, tmp_path: Path, valid_points_parquet: Path ) -> None: """--fix --force re-encodes valid COGs but leaves valid GeoParquet alone.""" - from portolan_cli.check import check_directory + from portolan_cli.scan.check import check_directory cog = _make_cog_without_overviews(tmp_path / "tile.tif") gpq = tmp_path / "vector.parquet" @@ -224,7 +224,7 @@ def test_force_fix_reoptimizes_cog_leaves_geoparquet( @pytest.mark.integration def test_force_requires_fix(self, tmp_path: Path) -> None: """force without fix is a programmer error (mirrors remove_legacy guard).""" - from portolan_cli.check import check_directory + from portolan_cli.scan.check import check_directory with pytest.raises(ValueError): check_directory(tmp_path, fix=False, force=True) @@ -232,7 +232,7 @@ def test_force_requires_fix(self, tmp_path: Path) -> None: @pytest.mark.integration def test_force_dry_run_previews_cog_reoptimization(self, tmp_path: Path) -> None: """--fix --force --dry-run lists the valid COG as a would-re-optimize COG.""" - from portolan_cli.check import check_directory + from portolan_cli.scan.check import check_directory cog = _make_cog_without_overviews(tmp_path / "tile.tif") diff --git a/tests/integration/test_concurrency_enforcement.py b/tests/integration/test_concurrency_enforcement.py index c1162332..6ea922d6 100644 --- a/tests/integration/test_concurrency_enforcement.py +++ b/tests/integration/test_concurrency_enforcement.py @@ -259,10 +259,10 @@ async def mock_put_async(store: Any, key: str, data: bytes) -> None: put_async_calls.append({"key": key, "size": len(data)}) with ( - patch("portolan_cli.push.obs.put", side_effect=mock_put), - patch("portolan_cli.push.obs.put_async", side_effect=mock_put_async), + patch("portolan_cli.sync.push.obs.put", side_effect=mock_put), + patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_async), ): - from portolan_cli.push import _upload_assets_async + from portolan_cli.sync.push import _upload_assets_async mock_store = MagicMock() await _upload_assets_async( @@ -299,10 +299,10 @@ async def mock_put_async(store: Any, key: str, data: bytes) -> None: put_async_calls.append({"key": key, "size": len(data)}) with ( - patch("portolan_cli.push.obs.put", side_effect=mock_put), - patch("portolan_cli.push.obs.put_async", side_effect=mock_put_async), + patch("portolan_cli.sync.push.obs.put", side_effect=mock_put), + patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_async), ): - from portolan_cli.push import _upload_assets_async + from portolan_cli.sync.push import _upload_assets_async mock_store = MagicMock() await _upload_assets_async( @@ -340,8 +340,8 @@ def test_max_connections_adjusts_effective_concurrency(self) -> None: Path("col1").mkdir() Path("col1/versions.json").write_text('{"versions": []}') - with patch("portolan_cli.push.push_all_collections") as mock_push: - from portolan_cli.push import PushAllResult + with patch("portolan_cli.sync.push.push_all_collections") as mock_push: + from portolan_cli.sync.push import PushAllResult mock_push.return_value = PushAllResult( success=True, diff --git a/tests/integration/test_download_integration.py b/tests/integration/test_download_integration.py index aee0999a..b84598d7 100644 --- a/tests/integration/test_download_integration.py +++ b/tests/integration/test_download_integration.py @@ -38,7 +38,7 @@ class TestDirectoryStructurePreservation: @pytest.mark.integration def test_build_local_path_preserves_nested_structure(self, download_test_dir: Path) -> None: """Local paths should preserve relative directory structure.""" - from portolan_cli.download import _build_local_path + from portolan_cli.sync.download import _build_local_path local_path = _build_local_path( remote_key="data/subdir/file.parquet", @@ -52,7 +52,7 @@ def test_build_local_path_preserves_nested_structure(self, download_test_dir: Pa @pytest.mark.integration def test_build_local_path_with_deep_nesting(self, download_test_dir: Path) -> None: """Local paths should work with deeply nested structures.""" - from portolan_cli.download import _build_local_path + from portolan_cli.sync.download import _build_local_path local_path = _build_local_path( remote_key="prefix/a/b/c/d/file.parquet", @@ -65,7 +65,7 @@ def test_build_local_path_with_deep_nesting(self, download_test_dir: Path) -> No @pytest.mark.integration def test_build_local_path_no_prefix(self, download_test_dir: Path) -> None: """Local paths should work without prefix (filename only).""" - from portolan_cli.download import _build_local_path + from portolan_cli.sync.download import _build_local_path local_path = _build_local_path( remote_key="just-a-file.parquet", @@ -78,7 +78,7 @@ def test_build_local_path_no_prefix(self, download_test_dir: Path) -> None: @pytest.mark.integration def test_build_local_path_prefix_mismatch(self, download_test_dir: Path) -> None: """When prefix doesn't match, should extract filename.""" - from portolan_cli.download import _build_local_path + from portolan_cli.sync.download import _build_local_path local_path = _build_local_path( remote_key="different/path/file.parquet", @@ -101,7 +101,7 @@ class TestSingleFilePath: @pytest.mark.integration def test_get_local_path_to_directory(self, download_test_dir: Path) -> None: """When destination is directory, should append filename.""" - from portolan_cli.download import _get_local_path_for_file + from portolan_cli.sync.download import _get_local_path_for_file local_path = _get_local_path_for_file( source_prefix="data/file.parquet", @@ -114,7 +114,7 @@ def test_get_local_path_to_directory(self, download_test_dir: Path) -> None: @pytest.mark.integration def test_get_local_path_to_file(self, download_test_dir: Path) -> None: """When destination is exact file, should use that path.""" - from portolan_cli.download import _get_local_path_for_file + from portolan_cli.sync.download import _get_local_path_for_file dest_file = download_test_dir / "renamed.parquet" @@ -129,7 +129,7 @@ def test_get_local_path_to_file(self, download_test_dir: Path) -> None: @pytest.mark.integration def test_get_local_path_existing_directory(self, download_test_dir: Path) -> None: """When destination is existing directory, should append filename.""" - from portolan_cli.download import _get_local_path_for_file + from portolan_cli.sync.download import _get_local_path_for_file # download_test_dir exists as a directory local_path = _get_local_path_for_file( @@ -196,7 +196,7 @@ class TestDryRun: @pytest.mark.integration def test_download_file_dry_run_no_file_created(self, download_test_dir: Path) -> None: """Dry-run should not create any files.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = download_test_dir / "should_not_exist.parquet" @@ -215,12 +215,12 @@ def test_download_directory_dry_run_no_files_created(self, download_test_dir: Pa """Dry-run directory download should not create any files.""" from unittest.mock import patch - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory nested_dir = download_test_dir / "nested" - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.upload.S3Store"): + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.upload.S3Store"): # Mock list to return files mock_obs.list.return_value = [ [ @@ -251,7 +251,7 @@ class TestErrorHandling: @pytest.mark.integration def test_download_result_captures_errors(self) -> None: """DownloadResult should properly capture error information.""" - from portolan_cli.download import DownloadResult + from portolan_cli.sync.download import DownloadResult error1 = OSError("Network error") error2 = ValueError("Invalid data") @@ -275,10 +275,10 @@ def test_empty_remote_directory_returns_success(self, download_test_dir: Path) - """Empty remote directory should return success with zero files.""" from unittest.mock import patch - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.upload.S3Store"): + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.upload.S3Store"): # Mock list to return empty mock_obs.list.return_value = [[]] @@ -303,7 +303,7 @@ class TestUrlParsing: @pytest.mark.integration def test_parse_s3_url(self) -> None: """Should correctly parse S3 URLs.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("s3://mybucket/data/file.parquet") @@ -313,7 +313,7 @@ def test_parse_s3_url(self) -> None: @pytest.mark.integration def test_parse_gcs_url(self) -> None: """Should correctly parse GCS URLs.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("gs://mybucket/path/to/data") @@ -323,7 +323,7 @@ def test_parse_gcs_url(self) -> None: @pytest.mark.integration def test_parse_azure_url(self) -> None: """Should correctly parse Azure URLs.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("az://myaccount/mycontainer/data/file.parquet") @@ -333,7 +333,7 @@ def test_parse_azure_url(self) -> None: @pytest.mark.integration def test_parse_url_bucket_only(self) -> None: """Should handle URL with bucket only.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("s3://mybucket") diff --git a/tests/integration/test_nested_catalog_clone.py b/tests/integration/test_nested_catalog_clone.py index 1ef74403..d31b8def 100644 --- a/tests/integration/test_nested_catalog_clone.py +++ b/tests/integration/test_nested_catalog_clone.py @@ -37,7 +37,7 @@ def test_clone_nested_catalog_end_to_end(self, tmp_path: Path) -> None: Expected: clone should find 'climate/hittekaart' and pull it correctly. """ - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone # Root catalog pointing to subcatalog root_catalog = { @@ -87,8 +87,10 @@ def mock_pull( target_dir = tmp_path / "cloned" with ( - patch("portolan_cli.sync._fetch_remote_catalog_json", side_effect=mock_fetch_catalog), - patch("portolan_cli.sync.init_catalog"), + patch( + "portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch_catalog + ), + patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.pull", side_effect=mock_pull), ): result = clone( @@ -118,7 +120,7 @@ def test_clone_deeply_nested_catalog(self, tmp_path: Path) -> None: └── amsterdam/ └── demographics/collection.json """ - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone root_catalog = { "type": "Catalog", @@ -159,8 +161,8 @@ def mock_pull( return MagicMock(success=True, files_downloaded=1, remote_version="1.0.0") with ( - patch("portolan_cli.sync._fetch_remote_catalog_json", side_effect=mock_fetch), - patch("portolan_cli.sync.init_catalog"), + patch("portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch), + patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.pull", side_effect=mock_pull), ): result = clone( @@ -184,7 +186,7 @@ def test_clone_mixed_direct_and_nested_collections(self, tmp_path: Path) -> None └── catalog.json (subcatalog) └── nested-data/collection.json """ - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone root_catalog = { "type": "Catalog", @@ -214,8 +216,8 @@ def mock_pull( return MagicMock(success=True, files_downloaded=1, remote_version="1.0.0") with ( - patch("portolan_cli.sync._fetch_remote_catalog_json", side_effect=mock_fetch), - patch("portolan_cli.sync.init_catalog"), + patch("portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch), + patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.pull", side_effect=mock_pull), ): result = clone( diff --git a/tests/integration/test_partition_paths.py b/tests/integration/test_partition_paths.py index fd7c4c01..bda45568 100644 --- a/tests/integration/test_partition_paths.py +++ b/tests/integration/test_partition_paths.py @@ -281,7 +281,7 @@ def test_push_dryrun_shows_correct_glob_url( assert result.exit_code == 0 # Verify transformation function works correctly - from portolan_cli.push import _transform_collection_glob_assets + from portolan_cli.sync.push import _transform_collection_glob_assets collection_path = large_geoparquet.parent / "collection.json" content = collection_path.read_bytes() diff --git a/tests/integration/test_pmtiles_integration.py b/tests/integration/test_pmtiles_integration.py index 5350b3ff..8cc985e5 100644 --- a/tests/integration/test_pmtiles_integration.py +++ b/tests/integration/test_pmtiles_integration.py @@ -156,7 +156,7 @@ class TestPMTilesGeneration: def test_generate_creates_valid_pmtiles(self, collection_with_geoparquet: Path) -> None: """PMTiles file is created with valid header (magic bytes).""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection catalog_root = collection_with_geoparquet.parent @@ -184,7 +184,7 @@ def test_generate_creates_valid_pmtiles(self, collection_with_geoparquet: Path) def test_skip_when_pmtiles_newer(self, collection_with_geoparquet: Path) -> None: """PMTiles regeneration skipped when output is newer than source.""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection catalog_root = collection_with_geoparquet.parent @@ -208,7 +208,7 @@ def test_skip_when_pmtiles_newer(self, collection_with_geoparquet: Path) -> None def test_force_regenerates(self, collection_with_geoparquet: Path) -> None: """Force flag regenerates PMTiles even when up-to-date.""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection catalog_root = collection_with_geoparquet.parent @@ -238,7 +238,7 @@ def test_force_regenerates(self, collection_with_geoparquet: Path) -> None: def test_asset_registered_in_collection_json(self, collection_with_geoparquet: Path) -> None: """PMTiles asset is registered in collection.json with correct metadata.""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection catalog_root = collection_with_geoparquet.parent @@ -267,7 +267,7 @@ def test_version_tracked(self, collection_with_geoparquet: Path) -> None: thumbnail. Both belong to ONE version bump, not two (Issue #519), and the snapshot carries both with checksum and size. """ - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection catalog_root = collection_with_geoparquet.parent @@ -329,7 +329,7 @@ def test_skip_path_backfills_untracked_artifacts( artifacts must still be backfilled into versions.json — without forcing a regenerate. """ - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection catalog_root = collection_with_geoparquet.parent @@ -378,7 +378,7 @@ def test_backfill_is_idempotent_when_already_tracked( self, collection_with_geoparquet: Path ) -> None: """A skip with everything already tracked creates NO new version (Issue #519).""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection catalog_root = collection_with_geoparquet.parent @@ -408,7 +408,7 @@ def test_each_geoparquet_asset_gets_its_own_version( (one per asset, each bundling that asset's PMTiles + thumbnail), and the final snapshot carries every generated artifact (Issue #519). """ - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection collection = collection_with_two_geoparquet catalog_root = collection.parent @@ -439,7 +439,7 @@ class TestPMTilesZoomLevels: def test_custom_zoom_levels(self, collection_with_geoparquet: Path) -> None: """Custom min/max zoom levels are passed to tippecanoe.""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection catalog_root = collection_with_geoparquet.parent @@ -515,7 +515,7 @@ def test_generates_pmtiles_for_all_geoparquet_assets( portolan_dir.mkdir() (portolan_dir / "config.yaml").write_text("catalog_id: test\n") - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection result = generate_pmtiles_for_collection( collection_path=collection_dir, @@ -540,7 +540,7 @@ class TestPMTilesAdvancedParameters: def test_precision_parameter_accepted(self, collection_with_geoparquet: Path) -> None: """Custom precision parameter is accepted by tippecanoe.""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection catalog_root = collection_with_geoparquet.parent @@ -556,7 +556,7 @@ def test_precision_parameter_accepted(self, collection_with_geoparquet: Path) -> def test_layer_parameter_accepted(self, collection_with_geoparquet: Path) -> None: """Custom layer name is accepted.""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection catalog_root = collection_with_geoparquet.parent @@ -571,7 +571,7 @@ def test_layer_parameter_accepted(self, collection_with_geoparquet: Path) -> Non def test_attribution_parameter_accepted(self, collection_with_geoparquet: Path) -> None: """Custom attribution HTML is accepted.""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection catalog_root = collection_with_geoparquet.parent @@ -586,7 +586,7 @@ def test_attribution_parameter_accepted(self, collection_with_geoparquet: Path) def test_all_parameters_together(self, collection_with_geoparquet: Path) -> None: """All advanced parameters work together.""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection catalog_root = collection_with_geoparquet.parent diff --git a/tests/integration/test_pull_integration.py b/tests/integration/test_pull_integration.py index 977c2c4a..ba73d6a2 100644 --- a/tests/integration/test_pull_integration.py +++ b/tests/integration/test_pull_integration.py @@ -154,8 +154,8 @@ def test_pull_command_basic( """portolan pull should invoke pull function with correct args.""" from portolan_cli.cli import cli - with patch("portolan_cli.pull.pull") as mock_pull: - from portolan_cli.pull import PullResult + with patch("portolan_cli.sync.pull.pull") as mock_pull: + from portolan_cli.sync.pull import PullResult mock_pull.return_value = PullResult( success=True, @@ -185,8 +185,8 @@ def test_pull_command_dry_run(self, cli_runner: CliRunner, local_catalog: Path) """portolan pull --dry-run should pass dry_run=True.""" from portolan_cli.cli import cli - with patch("portolan_cli.pull.pull") as mock_pull: - from portolan_cli.pull import PullResult + with patch("portolan_cli.sync.pull.pull") as mock_pull: + from portolan_cli.sync.pull import PullResult mock_pull.return_value = PullResult( success=True, @@ -219,8 +219,8 @@ def test_pull_command_force(self, cli_runner: CliRunner, local_catalog: Path) -> """portolan pull --force should pass force=True.""" from portolan_cli.cli import cli - with patch("portolan_cli.pull.pull") as mock_pull: - from portolan_cli.pull import PullResult + with patch("portolan_cli.sync.pull.pull") as mock_pull: + from portolan_cli.sync.pull import PullResult mock_pull.return_value = PullResult( success=True, @@ -252,8 +252,8 @@ def test_pull_command_profile(self, cli_runner: CliRunner, local_catalog: Path) """portolan pull --profile should pass profile to pull function.""" from portolan_cli.cli import cli - with patch("portolan_cli.pull.pull") as mock_pull: - from portolan_cli.pull import PullResult + with patch("portolan_cli.sync.pull.pull") as mock_pull: + from portolan_cli.sync.pull import PullResult mock_pull.return_value = PullResult( success=True, @@ -288,8 +288,8 @@ def test_pull_command_uncommitted_changes_fails( """portolan pull should exit 1 when uncommitted changes block pull.""" from portolan_cli.cli import cli - with patch("portolan_cli.pull.pull") as mock_pull: - from portolan_cli.pull import PullResult + with patch("portolan_cli.sync.pull.pull") as mock_pull: + from portolan_cli.sync.pull import PullResult mock_pull.return_value = PullResult( success=False, @@ -319,8 +319,8 @@ def test_pull_command_up_to_date(self, cli_runner: CliRunner, local_catalog: Pat """portolan pull should show message when already up to date.""" from portolan_cli.cli import cli - with patch("portolan_cli.pull.pull") as mock_pull: - from portolan_cli.pull import PullResult + with patch("portolan_cli.sync.pull.pull") as mock_pull: + from portolan_cli.sync.pull import PullResult mock_pull.return_value = PullResult( success=True, @@ -351,8 +351,8 @@ def test_pull_command_json_output(self, cli_runner: CliRunner, local_catalog: Pa """portolan pull --json should output JSON envelope.""" from portolan_cli.cli import cli - with patch("portolan_cli.pull.pull") as mock_pull: - from portolan_cli.pull import PullResult + with patch("portolan_cli.sync.pull.pull") as mock_pull: + from portolan_cli.sync.pull import PullResult mock_pull.return_value = PullResult( success=True, @@ -441,8 +441,8 @@ def test_pull_json_output_on_failure(self, cli_runner: CliRunner, local_catalog: """portolan pull --json should output error envelope on failure.""" from portolan_cli.cli import cli - with patch("portolan_cli.pull.pull") as mock_pull: - from portolan_cli.pull import PullResult + with patch("portolan_cli.sync.pull.pull") as mock_pull: + from portolan_cli.sync.pull import PullResult mock_pull.return_value = PullResult( success=False, @@ -489,9 +489,9 @@ def test_pull_malformed_remote_response( ) -> None: """portolan pull should fail gracefully with malformed remote data.""" from portolan_cli.cli import cli - from portolan_cli.pull import PullError + from portolan_cli.sync.pull import PullError - with patch("portolan_cli.pull.pull") as mock_pull: + with patch("portolan_cli.sync.pull.pull") as mock_pull: # Simulate that pull raises an error due to malformed data mock_pull.side_effect = PullError( "Invalid remote versions.json: missing field 'current_version'" @@ -518,9 +518,9 @@ def test_pull_malformed_local_catalog( ) -> None: """portolan pull should handle malformed local catalog gracefully.""" from portolan_cli.cli import cli - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult - with patch("portolan_cli.pull.pull") as mock_pull: + with patch("portolan_cli.sync.pull.pull") as mock_pull: # The malformed local catalog may cause pull to fail or succeed # depending on how the code handles malformed local data mock_pull.return_value = PullResult( @@ -561,7 +561,7 @@ def test_pull_with_malformed_remote_data_structure( # Verify the fixture has the expected malformed structure assert "current_version" not in malformed_remote_versions_data - with patch("portolan_cli.pull.pull") as mock_pull: + with patch("portolan_cli.sync.pull.pull") as mock_pull: # Simulate parsing error from malformed remote data mock_pull.side_effect = KeyError("current_version") @@ -585,7 +585,7 @@ def test_pull_invalid_url_scheme(self, cli_runner: CliRunner, local_catalog: Pat """portolan pull should reject invalid URL schemes.""" from portolan_cli.cli import cli - with patch("portolan_cli.pull.pull") as mock_pull: + with patch("portolan_cli.sync.pull.pull") as mock_pull: mock_pull.side_effect = ValueError("Unsupported URL scheme: ftp") result = cli_runner.invoke( @@ -618,9 +618,9 @@ def test_pull_cli_json_failure_without_uncommitted( ) -> None: """Pull CLI should output generic error in JSON when no uncommitted changes.""" from portolan_cli.cli import cli - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult - with patch("portolan_cli.pull.pull") as mock_pull: + with patch("portolan_cli.sync.pull.pull") as mock_pull: mock_pull.return_value = PullResult( success=False, files_downloaded=0, @@ -657,9 +657,9 @@ def test_pull_cli_dry_run_human_output( ) -> None: """Pull CLI should show dry-run message in human output.""" from portolan_cli.cli import cli - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult - with patch("portolan_cli.pull.pull") as mock_pull: + with patch("portolan_cli.sync.pull.pull") as mock_pull: mock_pull.return_value = PullResult( success=True, files_downloaded=3, @@ -690,9 +690,9 @@ def test_pull_cli_human_failure_no_uncommitted( ) -> None: """Pull CLI should show generic error message when failure without uncommitted.""" from portolan_cli.cli import cli - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult - with patch("portolan_cli.pull.pull") as mock_pull: + with patch("portolan_cli.sync.pull.pull") as mock_pull: mock_pull.return_value = PullResult( success=False, files_downloaded=0, @@ -723,9 +723,9 @@ def test_pull_cli_human_uncommitted_shows_files( ) -> None: """Pull CLI should list uncommitted files in human output.""" from portolan_cli.cli import cli - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult - with patch("portolan_cli.pull.pull") as mock_pull: + with patch("portolan_cli.sync.pull.pull") as mock_pull: mock_pull.return_value = PullResult( success=False, files_downloaded=0, @@ -757,9 +757,9 @@ def test_pull_cli_success_human_shows_version_transition( ) -> None: """Pull CLI success should show version transition in human output.""" from portolan_cli.cli import cli - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult - with patch("portolan_cli.pull.pull") as mock_pull: + with patch("portolan_cli.sync.pull.pull") as mock_pull: mock_pull.return_value = PullResult( success=True, files_downloaded=2, diff --git a/tests/integration/test_pull_parallel_integration.py b/tests/integration/test_pull_parallel_integration.py index 341710cf..e4ba3a80 100644 --- a/tests/integration/test_pull_parallel_integration.py +++ b/tests/integration/test_pull_parallel_integration.py @@ -10,7 +10,7 @@ import pytest -from portolan_cli.pull import pull_all_collections +from portolan_cli.sync.pull import pull_all_collections # Mark all tests in this module as integration tests pytestmark = pytest.mark.integration diff --git a/tests/integration/test_push_integration.py b/tests/integration/test_push_integration.py index 42c434eb..e55f043c 100644 --- a/tests/integration/test_push_integration.py +++ b/tests/integration/test_push_integration.py @@ -183,7 +183,7 @@ class TestLocalVersionsReading: @pytest.mark.integration def test_read_local_versions_json(self, catalog_with_versions: Path) -> None: """Should correctly read local versions.json.""" - from portolan_cli.push import _read_local_versions + from portolan_cli.sync.push import _read_local_versions versions_data = _read_local_versions( catalog_root=catalog_with_versions, @@ -196,7 +196,7 @@ def test_read_local_versions_json(self, catalog_with_versions: Path) -> None: @pytest.mark.integration def test_read_nonexistent_collection_raises(self, catalog_with_versions: Path) -> None: """Should raise FileNotFoundError for nonexistent collection.""" - from portolan_cli.push import _read_local_versions + from portolan_cli.sync.push import _read_local_versions with pytest.raises(FileNotFoundError, match="versions.json not found"): _read_local_versions( @@ -216,7 +216,7 @@ class TestVersionDiffingIntegration: @pytest.mark.integration def test_diff_with_real_version_lists(self) -> None: """Diff should work with realistic version lists.""" - from portolan_cli.push import diff_version_lists + from portolan_cli.sync.push import diff_version_lists local = ["1.0.0", "1.1.0", "1.2.0", "2.0.0"] remote = ["1.0.0", "1.1.0"] @@ -231,7 +231,7 @@ def test_diff_with_real_version_lists(self) -> None: @pytest.mark.integration def test_diff_preserves_version_order(self) -> None: """Diff should preserve version order in results.""" - from portolan_cli.push import diff_version_lists + from portolan_cli.sync.push import diff_version_lists local = ["1.0.0", "1.1.0", "1.2.0"] remote = ["1.0.0"] @@ -278,12 +278,12 @@ def test_push_dry_run_flag(self, catalog_with_versions: Path) -> None: from unittest.mock import AsyncMock from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() # CLI calls push_async directly (not push) for single-collection push - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -316,12 +316,12 @@ def test_push_force_flag(self, catalog_with_versions: Path) -> None: from unittest.mock import AsyncMock from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() # CLI calls push_async directly (not push) for single-collection push - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=1, @@ -351,11 +351,11 @@ def test_push_force_flag(self, catalog_with_versions: Path) -> None: def test_push_profile_flag(self, catalog_with_versions: Path) -> None: """Push --profile should pass AWS profile.""" from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=1, @@ -392,11 +392,11 @@ def test_push_reads_aws_profile_from_env(self, catalog_with_versions: Path) -> N from unittest.mock import patch as mock_patch from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with mock_patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with mock_patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -439,11 +439,11 @@ def test_push_cli_profile_overrides_env(self, catalog_with_versions: Path) -> No from unittest.mock import patch as mock_patch from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with mock_patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with mock_patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -482,11 +482,11 @@ def test_push_cli_profile_overrides_env(self, catalog_with_versions: Path) -> No def test_push_conflict_shows_error(self, catalog_with_versions: Path) -> None: """Push conflict should show error message to user.""" from portolan_cli.cli import cli - from portolan_cli.push import PushConflictError + from portolan_cli.sync.push import PushConflictError runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.side_effect = PushConflictError( "Remote has changes not present locally. Use --force to overwrite." ) @@ -519,11 +519,11 @@ class TestPushJSONOutput: def test_push_json_success(self, catalog_with_versions: Path) -> None: """Push with --format=json should output JSON envelope.""" from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=1, @@ -556,11 +556,11 @@ def test_push_json_success(self, catalog_with_versions: Path) -> None: def test_push_json_error(self, catalog_with_versions: Path) -> None: """Push errors with --format=json should output JSON error envelope.""" from portolan_cli.cli import cli - from portolan_cli.push import PushConflictError + from portolan_cli.sync.push import PushConflictError runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.side_effect = PushConflictError("Remote diverged") result = runner.invoke( @@ -591,11 +591,11 @@ def test_push_json_returned_failure_is_not_reported_as_success( failed push was reported as ``success: true`` with exit 0 in JSON mode. """ from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=False, files_uploaded=0, @@ -636,10 +636,10 @@ class TestDryRunBehavior: @pytest.mark.integration def test_dry_run_shows_files_to_upload(self, catalog_with_versions: Path) -> None: """Dry-run should show which files would be uploaded.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (None, None) # First push @@ -662,10 +662,10 @@ def test_dry_run_does_not_detect_conflicts(self, catalog_with_versions: Path) -> before any network I/O, which means remote conflicts are not checked. Users who want conflict detection must run without --dry-run. """ - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = ( { @@ -701,11 +701,11 @@ def test_cli_dry_run_no_contradictory_messages(self, catalog_with_versions: Path - "Nothing to push - local and remote are in sync" """ from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: # Simulate dry-run where there IS work to do # (versions_pushed=0 because dry-run doesn't actually push) mock_push.return_value = PushResult( @@ -739,11 +739,11 @@ def test_cli_dry_run_no_contradictory_messages(self, catalog_with_versions: Path def test_cli_dry_run_shows_completion_message(self, catalog_with_versions: Path) -> None: """CLI should show dry-run completion message when dry-run succeeds.""" from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -775,11 +775,11 @@ def test_cli_dry_run_shows_completion_message(self, catalog_with_versions: Path) def test_cli_normal_push_still_shows_nothing_to_push(self, catalog_with_versions: Path) -> None: """Non-dry-run with nothing to push should still show 'Nothing to push'.""" from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -814,14 +814,14 @@ def test_dry_run_nothing_to_push_real_codepath(self, catalog_with_versions: Path This test does NOT mock push() - it mocks only _fetch_remote_versions so we exercise the actual dry-run logic in push.py. """ - from portolan_cli.push import push + from portolan_cli.sync.push import push # Mock _fetch_remote_versions to simulate remote is identical to local versions_path = catalog_with_versions / "demographics" / "versions.json" local_versions = json.loads(versions_path.read_text()) with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: # Remote has same versions as local = nothing to push mock_fetch.return_value = (local_versions, "etag-123") @@ -844,10 +844,10 @@ def test_dry_run_with_work_real_codepath(self, catalog_with_versions: Path) -> N This test mocks only _fetch_remote_versions, not push(), so we test the actual dry-run message generation in push.py. """ - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: # Remote is empty = local has versions to push mock_fetch.return_value = (None, None) @@ -881,7 +881,7 @@ def test_cli_dry_run_nothing_to_push_real_codepath( runner = CliRunner() with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (local_versions, "etag-123") @@ -920,7 +920,7 @@ def test_cli_dry_run_with_work_real_codepath(self, catalog_with_versions: Path) runner = CliRunner() with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: # Remote is empty = local has versions to push mock_fetch.return_value = (None, None) @@ -960,7 +960,7 @@ class TestAssetPathResolution: @pytest.mark.integration def test_resolve_asset_paths(self, catalog_with_versions: Path) -> None: """Should resolve asset paths relative to catalog root.""" - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload # Read local versions versions_path = catalog_with_versions / "demographics" / "versions.json" @@ -990,7 +990,7 @@ class TestErrorHandling: @pytest.mark.integration def test_invalid_destination_url(self, catalog_with_versions: Path) -> None: """Push with invalid URL should fail gracefully.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push with pytest.raises(ValueError, match="Unsupported URL scheme"): push( @@ -1002,7 +1002,7 @@ def test_invalid_destination_url(self, catalog_with_versions: Path) -> None: @pytest.mark.integration def test_missing_catalog_root(self, tmp_path: Path) -> None: """Push with nonexistent catalog should fail.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push nonexistent = tmp_path / "nonexistent" @@ -1016,7 +1016,7 @@ def test_missing_catalog_root(self, tmp_path: Path) -> None: @pytest.mark.integration def test_push_with_invalid_json(self, catalog_with_versions_malformed: Path) -> None: """Push should fail with clear error on invalid JSON in versions.json.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push with pytest.raises(ValueError, match="Invalid JSON"): push( @@ -1028,7 +1028,7 @@ def test_push_with_invalid_json(self, catalog_with_versions_malformed: Path) -> @pytest.mark.integration def test_push_with_missing_versions_file(self, catalog_missing_versions_file: Path) -> None: """Push should fail with clear error when versions.json doesn't exist.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push with pytest.raises(FileNotFoundError, match="versions.json"): push( @@ -1101,7 +1101,7 @@ def test_push_cli_valueerror_json_output(self, catalog_with_versions: Path) -> N runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.side_effect = ValueError("Invalid destination URL format") result = runner.invoke( @@ -1130,7 +1130,7 @@ def test_push_cli_valueerror_human_output(self, catalog_with_versions: Path) -> runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.side_effect = ValueError("Unsupported URL scheme: invalid") result = runner.invoke( @@ -1152,11 +1152,11 @@ def test_push_cli_valueerror_human_output(self, catalog_with_versions: Path) -> def test_push_cli_result_errors_human_output(self, catalog_with_versions: Path) -> None: """Push CLI should show errors from PushResult and exit 1.""" from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=False, files_uploaded=0, @@ -1183,11 +1183,11 @@ def test_push_cli_result_errors_human_output(self, catalog_with_versions: Path) def test_push_cli_conflict_human_advice(self, catalog_with_versions: Path) -> None: """Push CLI should show advice about --force on conflict.""" from portolan_cli.cli import cli - from portolan_cli.push import PushConflictError + from portolan_cli.sync.push import PushConflictError runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.side_effect = PushConflictError("Remote has diverged") result = runner.invoke( @@ -1242,11 +1242,11 @@ def test_dry_run_never_shows_nothing_to_push( dry-run indicates work to do but then says nothing to push. """ from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=files_uploaded, @@ -1294,11 +1294,11 @@ def test_non_dry_run_zero_versions_shows_nothing_to_push( This invariant ensures the normal case still works correctly. """ from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=files_uploaded, @@ -1348,11 +1348,11 @@ def test_successful_push_with_versions_shows_pushed_message( This invariant ensures success messages appear when work is done. """ from portolan_cli.cli import cli - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=files_uploaded, @@ -1407,10 +1407,10 @@ def test_dry_run_nothing_to_push_shows_dry_run_prefix( to verify the dry-run message is prefixed correctly when local and remote are in sync. """ - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: # Simulate remote having same versions as local (nothing to push) mock_fetch.return_value = ( @@ -1444,10 +1444,10 @@ def test_non_dry_run_nothing_to_push_no_prefix(self, catalog_with_versions: Path This verifies the normal case (non-dry-run) still works correctly. """ - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: # Simulate remote having same versions as local (nothing to push) mock_fetch.return_value = ( @@ -1482,10 +1482,10 @@ def test_dry_run_with_work_to_do_shows_would_push(self, catalog_with_versions: P This tests the case where there ARE versions to push, verifying that dry-run mode shows "[DRY RUN] Would push" messages. """ - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: # Simulate remote having fewer versions than local (work to do) mock_fetch.return_value = ( @@ -1532,10 +1532,10 @@ def test_push_with_trailing_slash_url(self, catalog_with_versions: Path) -> None Before fix: s3://bucket/prefix/ would cause: "Could not parse path: Path 'prefix//collection/versions.json' contained empty path segment" """ - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (None, None) # First push @@ -1554,10 +1554,10 @@ def test_push_with_trailing_slash_url(self, catalog_with_versions: Path) -> None @pytest.mark.integration def test_push_with_multiple_trailing_slashes(self, catalog_with_versions: Path) -> None: """Push should handle multiple trailing slashes gracefully.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (None, None) @@ -1573,10 +1573,10 @@ def test_push_with_multiple_trailing_slashes(self, catalog_with_versions: Path) @pytest.mark.integration def test_push_url_bucket_only_with_trailing_slash(self, catalog_with_versions: Path) -> None: """Push to bucket root with trailing slash should work.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (None, None) @@ -1599,7 +1599,7 @@ def test_push_constructed_paths_have_no_double_slashes( This test verifies the fix by checking the actual paths that would be used for upload/fetch operations. """ - from portolan_cli.upload import setup_store + from portolan_cli.sync.upload import setup_store # Test various trailing slash scenarios test_cases = [ @@ -1610,7 +1610,7 @@ def test_push_constructed_paths_have_no_double_slashes( ] for destination, expected_prefix in test_cases: - with patch("portolan_cli.upload.S3Store"): + with patch("portolan_cli.sync.upload.S3Store"): _, prefix = setup_store(destination) assert prefix == expected_prefix, ( f"For {destination!r}, expected prefix {expected_prefix!r}, got {prefix!r}" @@ -1643,7 +1643,7 @@ async def test_push_only_uploads_new_assets(self, tmp_path: Path) -> None: has 2 existing files on remote (version 1.0.0). Only the new file should be uploaded. """ - from portolan_cli.push import push_async + from portolan_cli.sync.push import push_async # Setup catalog catalog_dir = tmp_path / "catalog" @@ -1765,11 +1765,13 @@ async def mock_put(store, path, data, **kwargs): return None with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (remote_versions, "etag123") - with patch("portolan_cli.push.obs.put_async", new_callable=AsyncMock) as mock_put_call: + with patch( + "portolan_cli.sync.push.obs.put_async", new_callable=AsyncMock + ) as mock_put_call: mock_put_call.side_effect = mock_put result = await push_async( diff --git a/tests/integration/test_push_parallel_integration.py b/tests/integration/test_push_parallel_integration.py index 0534b842..f6f854a5 100644 --- a/tests/integration/test_push_parallel_integration.py +++ b/tests/integration/test_push_parallel_integration.py @@ -23,7 +23,7 @@ from click.testing import CliRunner from portolan_cli.cli import cli -from portolan_cli.push import ( +from portolan_cli.sync.push import ( PushAllResult, PushResult, get_default_workers, @@ -102,8 +102,8 @@ class TestParallelPushIntegration: """Integration tests for parallel push_all_collections.""" @pytest.mark.integration - @patch("portolan_cli.push.obs.put") # For catalog.json upload - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.obs.put") # For catalog.json upload + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_parallel_execution_observes_worker_count( self, mock_push_async: AsyncMock, @@ -138,8 +138,8 @@ async def track_concurrent(**kwargs): # type: ignore[no-untyped-def] assert mock_push_async.call_count == 4 @pytest.mark.integration - @patch("portolan_cli.push.obs.put") # For catalog.json upload - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.obs.put") # For catalog.json upload + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_sequential_execution_with_workers_1( self, mock_push_async: AsyncMock, @@ -168,8 +168,8 @@ async def track_order(**kwargs): # type: ignore[no-untyped-def] assert call_order == ["collection_a", "collection_b", "collection_c", "collection_d"] @pytest.mark.integration - @patch("portolan_cli.push.obs.put") # For catalog.json upload (skipped on failure) - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.obs.put") # For catalog.json upload (skipped on failure) + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_parallel_continues_on_individual_failure( self, mock_push_async: AsyncMock, _mock_obs_put: MagicMock, multi_collection_catalog: Path ) -> None: @@ -219,7 +219,7 @@ def runner(self) -> CliRunner: return CliRunner() @pytest.mark.integration - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_cli_workers_flag_integration( self, mock_push_all: MagicMock, runner: CliRunner, multi_collection_catalog: Path ) -> None: @@ -244,7 +244,7 @@ def test_cli_workers_flag_integration( assert mock_push_all.call_args.kwargs["workers"] == 2 @pytest.mark.integration - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_cli_default_workers_integration( self, mock_push_all: MagicMock, runner: CliRunner, multi_collection_catalog: Path ) -> None: diff --git a/tests/integration/test_s3_moto.py b/tests/integration/test_s3_moto.py index 2f04a8ae..6cae72f7 100644 --- a/tests/integration/test_s3_moto.py +++ b/tests/integration/test_s3_moto.py @@ -284,7 +284,7 @@ def test_push_uploads_files_to_s3( """Push should upload files to the S3 bucket.""" bucket_name, endpoint_url = s3_bucket - from portolan_cli.push import push + from portolan_cli.sync.push import push result = push( catalog_root=catalog_with_data, @@ -320,7 +320,7 @@ def test_push_incremental_only_uploads_new_files( """Issue #329: Push should only upload new/changed files.""" bucket_name, endpoint_url = s3_bucket - from portolan_cli.push import push + from portolan_cli.sync.push import push # First push - should upload everything (fresh bucket) result1 = push( @@ -396,7 +396,7 @@ def test_push_uploads_intermediate_catalogs_to_s3( """ bucket_name, endpoint_url = s3_bucket - from portolan_cli.push import push_all_collections + from portolan_cli.sync.push import push_all_collections result = push_all_collections( catalog_root=nested_catalog_with_data, @@ -442,7 +442,7 @@ def test_single_collection_push_uploads_intermediate_catalogs_to_s3( """ bucket_name, endpoint_url = s3_bucket - from portolan_cli.push import push + from portolan_cli.sync.push import push result = push( catalog_root=nested_catalog_with_data, @@ -485,7 +485,7 @@ def test_force_refreshes_regenerated_collection_json_on_s3( """ bucket_name, endpoint_url = s3_bucket - from portolan_cli.push import push + from portolan_cli.sync.push import push collection = "tst/latest/adm0" destination = f"s3://{bucket_name}/catalog" @@ -564,8 +564,8 @@ def test_pull_downloads_files_from_s3( """Pull should download files from S3.""" bucket_name, endpoint_url = s3_bucket - from portolan_cli.pull import pull - from portolan_cli.push import push + from portolan_cli.sync.pull import pull + from portolan_cli.sync.push import push # First push to populate S3 push_result = push( @@ -619,8 +619,8 @@ def test_restore_redownloads_missing_files( """Issue #325: --restore should re-download missing local files.""" bucket_name, endpoint_url = s3_bucket - from portolan_cli.pull import pull - from portolan_cli.push import push + from portolan_cli.sync.pull import pull + from portolan_cli.sync.push import push # Push to S3 push_result = push( diff --git a/tests/integration/test_scan_unrecognized_files.py b/tests/integration/test_scan_unrecognized_files.py index 586da2bf..2a4850b4 100644 --- a/tests/integration/test_scan_unrecognized_files.py +++ b/tests/integration/test_scan_unrecognized_files.py @@ -256,7 +256,7 @@ def test_scan_json_output_includes_unrecognized_files( assert len(skipped_files) > 0 # Find unknown files using explicit enum value check - from portolan_cli.scan_classify import SkipReasonType + from portolan_cli.scan.classify import SkipReasonType unknown_files = [ f for f in skipped_files if f.get("reason_type") == SkipReasonType.UNKNOWN_FORMAT.value @@ -438,7 +438,7 @@ def test_scan_json_explicit_enum_values(self, runner: CliRunner, tmp_path: Path) import json - from portolan_cli.scan_classify import FileCategory, SkipReasonType + from portolan_cli.scan.classify import FileCategory, SkipReasonType output_json = json.loads(result.output) skipped_files = output_json.get("data", {}).get("skipped", []) diff --git a/tests/integration/test_sync_integration.py b/tests/integration/test_sync_integration.py index 375dd542..6fdc2669 100644 --- a/tests/integration/test_sync_integration.py +++ b/tests/integration/test_sync_integration.py @@ -160,13 +160,13 @@ class TestSyncFlagPassthrough: def test_sync_dry_run_flag(self, managed_catalog: Path) -> None: """Sync --dry-run should pass dry_run=True to sync function.""" from portolan_cli.cli import cli - from portolan_cli.pull import PullResult - from portolan_cli.push import PushResult - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult + from portolan_cli.sync.pull import PullResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = SyncResult( success=True, pull_result=PullResult( @@ -211,13 +211,13 @@ def test_sync_dry_run_flag(self, managed_catalog: Path) -> None: def test_sync_force_flag(self, managed_catalog: Path) -> None: """Sync --force should pass force=True to sync function.""" from portolan_cli.cli import cli - from portolan_cli.pull import PullResult - from portolan_cli.push import PushResult - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult + from portolan_cli.sync.pull import PullResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = SyncResult( success=True, pull_result=PullResult( @@ -259,13 +259,13 @@ def test_sync_force_flag(self, managed_catalog: Path) -> None: def test_sync_fix_flag(self, managed_catalog: Path) -> None: """Sync --fix should pass fix=True to sync function.""" from portolan_cli.cli import cli - from portolan_cli.pull import PullResult - from portolan_cli.push import PushResult - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult + from portolan_cli.sync.pull import PullResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = SyncResult( success=True, pull_result=PullResult( @@ -307,13 +307,13 @@ def test_sync_fix_flag(self, managed_catalog: Path) -> None: def test_sync_profile_flag(self, managed_catalog: Path) -> None: """Sync --profile should pass AWS profile to sync function.""" from portolan_cli.cli import cli - from portolan_cli.pull import PullResult - from portolan_cli.push import PushResult - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult + from portolan_cli.sync.pull import PullResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = SyncResult( success=True, pull_result=PullResult( @@ -362,13 +362,13 @@ def test_sync_reads_aws_profile_from_env(self, managed_catalog: Path) -> None: from unittest.mock import patch as mock_patch from portolan_cli.cli import cli - from portolan_cli.pull import PullResult - from portolan_cli.push import PushResult - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult + from portolan_cli.sync.pull import PullResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with mock_patch("portolan_cli.sync.sync") as mock_sync: + with mock_patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = SyncResult( success=True, pull_result=PullResult( @@ -426,11 +426,11 @@ class TestSyncErrorHandling: def test_sync_failure_returns_nonzero(self, managed_catalog: Path) -> None: """Sync failure should return non-zero exit code.""" from portolan_cli.cli import cli - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult runner = CliRunner() - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = SyncResult( success=False, pull_result=None, @@ -459,12 +459,12 @@ def test_sync_failure_returns_nonzero(self, managed_catalog: Path) -> None: def test_sync_missing_catalog_returns_error(self, tmp_path: Path) -> None: """Sync with non-existent catalog should fail.""" from portolan_cli.cli import cli - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult runner = CliRunner() non_existent = tmp_path / "does_not_exist" - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = SyncResult( success=False, pull_result=None, @@ -502,13 +502,13 @@ class TestSyncJSONOutput: def test_sync_json_output_success(self, managed_catalog: Path) -> None: """Sync with --format=json should output valid JSON envelope.""" from portolan_cli.cli import cli - from portolan_cli.pull import PullResult - from portolan_cli.push import PushResult - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult + from portolan_cli.sync.pull import PullResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = SyncResult( success=True, pull_result=PullResult( @@ -560,11 +560,11 @@ def test_sync_json_output_success(self, managed_catalog: Path) -> None: def test_sync_json_output_failure(self, managed_catalog: Path) -> None: """Sync failure with --format=json should include errors array.""" from portolan_cli.cli import cli - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult runner = CliRunner() - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = SyncResult( success=False, pull_result=None, @@ -612,13 +612,13 @@ class TestSyncCombinedFlags: def test_sync_all_flags_together(self, managed_catalog: Path) -> None: """Sync should handle all flags simultaneously.""" from portolan_cli.cli import cli - from portolan_cli.pull import PullResult - from portolan_cli.push import PushResult - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult + from portolan_cli.sync.pull import PullResult + from portolan_cli.sync.push import PushResult runner = CliRunner() - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = SyncResult( success=True, pull_result=PullResult( diff --git a/tests/integration/test_thumbnail_workflow.py b/tests/integration/test_thumbnail_workflow.py index 2149e74e..22bdfd9b 100644 --- a/tests/integration/test_thumbnail_workflow.py +++ b/tests/integration/test_thumbnail_workflow.py @@ -37,7 +37,7 @@ def test_thumbnail_generated_after_conversion( self, sample_geoparquet: Path, tmp_path: Path ) -> None: """Thumbnail is generated after vector conversion.""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_vector_thumbnail + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_vector_thumbnail config = ThumbnailConfig(basemap_provider="none") # No network calls @@ -57,7 +57,7 @@ def test_thumbnail_generated_after_conversion( @pytest.mark.integration def test_thumbnail_disabled_via_config(self, sample_geoparquet: Path, tmp_path: Path) -> None: """Thumbnail not generated when disabled in config.""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_vector_thumbnail + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_vector_thumbnail config = ThumbnailConfig(enabled=False) @@ -72,7 +72,7 @@ def test_thumbnail_disabled_via_config(self, sample_geoparquet: Path, tmp_path: @pytest.mark.integration def test_thumbnail_config_from_yaml(self, tmp_path: Path) -> None: """Thumbnail config loads from catalog config.yaml.""" - from portolan_cli.thumbnail import get_thumbnail_config + from portolan_cli.viz.thumbnail import get_thumbnail_config # Create catalog structure with config portolan_dir = tmp_path / ".portolan" @@ -107,7 +107,7 @@ class TestStyleInStacAssets: @pytest.mark.integration def test_build_full_style_creates_valid_mapbox_gl_spec(self) -> None: """build_full_style produces a complete Mapbox GL v8 style spec.""" - from portolan_cli.style import VectorStyleConfig, build_full_style + from portolan_cli.viz.style import VectorStyleConfig, build_full_style config = VectorStyleConfig() style = build_full_style( @@ -146,7 +146,7 @@ def test_pmtiles_metadata_includes_layer_name(self) -> None: @pytest.mark.integration def test_style_config_from_yaml(self, tmp_path: Path) -> None: """Style config loads from catalog config.yaml.""" - from portolan_cli.style import get_vector_style_config + from portolan_cli.viz.style import get_vector_style_config portolan_dir = tmp_path / ".portolan" portolan_dir.mkdir() @@ -166,7 +166,7 @@ def test_style_config_from_yaml(self, tmp_path: Path) -> None: @pytest.mark.integration def test_style_registered_as_stac_asset(self, tmp_path: Path) -> None: """Style files are registered as STAC assets with portolan:styles manifest.""" - from portolan_cli.style import ( + from portolan_cli.viz.style import ( VectorStyleConfig, discover_styles, register_style_assets, @@ -228,7 +228,7 @@ class TestRasterStyleWorkflow: @pytest.mark.integration def test_raster_style_config_from_yaml(self, tmp_path: Path) -> None: """Raster style config loads from catalog config.yaml.""" - from portolan_cli.style import get_raster_style_config + from portolan_cli.viz.style import get_raster_style_config portolan_dir = tmp_path / ".portolan" portolan_dir.mkdir() @@ -248,7 +248,7 @@ def test_raster_style_config_from_yaml(self, tmp_path: Path) -> None: @pytest.mark.integration def test_raster_style_generates_render_props(self) -> None: """Raster style generates render extension properties.""" - from portolan_cli.style import RasterStyleConfig, build_raster_style + from portolan_cli.viz.style import RasterStyleConfig, build_raster_style config = RasterStyleConfig( colormap="terrain", @@ -273,8 +273,8 @@ class TestConfigHierarchy: @pytest.mark.integration def test_defaults_when_no_config(self, tmp_path: Path) -> None: """Returns defaults when no config.yaml exists.""" - from portolan_cli.style import VectorStyleConfig, get_vector_style_config - from portolan_cli.thumbnail import ThumbnailConfig, get_thumbnail_config + from portolan_cli.viz.style import VectorStyleConfig, get_vector_style_config + from portolan_cli.viz.thumbnail import ThumbnailConfig, get_thumbnail_config # No .portolan directory vector_config = get_vector_style_config(tmp_path) @@ -286,7 +286,7 @@ def test_defaults_when_no_config(self, tmp_path: Path) -> None: @pytest.mark.integration def test_partial_config_uses_defaults(self, tmp_path: Path) -> None: """Partial config fills missing values with defaults.""" - from portolan_cli.style import get_vector_style_config + from portolan_cli.viz.style import get_vector_style_config portolan_dir = tmp_path / ".portolan" portolan_dir.mkdir() @@ -317,7 +317,7 @@ class TestPmtilesCoordinateTransformation: @pytest.mark.integration def test_tile_bounds_calculation(self) -> None: """Tile bounds are calculated correctly from z/x/y.""" - from portolan_cli.thumbnail import _tile_bounds + from portolan_cli.viz.thumbnail import _tile_bounds # z=0 x=0 y=0 should cover the whole world bounds = _tile_bounds(0, 0, 0) @@ -330,7 +330,7 @@ def test_tile_bounds_calculation(self) -> None: @pytest.mark.integration def test_tile_bounds_at_zoom_1(self) -> None: """Tile bounds at zoom 1 divide world into quadrants.""" - from portolan_cli.thumbnail import _tile_bounds + from portolan_cli.viz.thumbnail import _tile_bounds # z=1 x=0 y=0 is NW quadrant nw = _tile_bounds(1, 0, 0) @@ -347,7 +347,7 @@ def test_tile_bounds_at_zoom_1(self) -> None: @pytest.mark.integration def test_coord_transformation(self) -> None: """MVT coordinates transform to geographic correctly.""" - from portolan_cli.thumbnail import _tile_bounds, _transform_coord + from portolan_cli.viz.thumbnail import _tile_bounds, _transform_coord # Tile z=0 x=0 y=0 covers whole world bounds = _tile_bounds(0, 0, 0) @@ -370,7 +370,7 @@ def test_coord_transformation(self) -> None: @pytest.mark.integration def test_transform_coords_recursive(self) -> None: """Coordinate arrays transform recursively for all geometry types.""" - from portolan_cli.thumbnail import _tile_bounds, _transform_coords + from portolan_cli.viz.thumbnail import _tile_bounds, _transform_coords bounds = _tile_bounds(0, 0, 0) @@ -400,7 +400,7 @@ def test_real_pmtiles_produces_geographic_bounds(self, fixtures_dir: Path) -> No pytest.importorskip("pmtiles") pytest.importorskip("mapbox_vector_tile") - from portolan_cli.thumbnail import _read_pmtiles_geometries + from portolan_cli.viz.thumbnail import _read_pmtiles_geometries pmtiles_path = fixtures_dir / "cloud_native" / "sample.pmtiles" if not pmtiles_path.exists(): diff --git a/tests/integration/test_upload_integration.py b/tests/integration/test_upload_integration.py index 32f99c0f..dc312a3b 100644 --- a/tests/integration/test_upload_integration.py +++ b/tests/integration/test_upload_integration.py @@ -43,7 +43,7 @@ class TestDirectoryStructurePreservation: @pytest.mark.integration def test_build_target_key_preserves_nested_structure(self, upload_test_dir: Path) -> None: """Target keys should preserve relative directory structure.""" - from portolan_cli.upload import _build_target_key + from portolan_cli.sync.upload import _build_target_key nested_file = upload_test_dir / "subdir" / "file3.parquet" @@ -55,7 +55,7 @@ def test_build_target_key_preserves_nested_structure(self, upload_test_dir: Path @pytest.mark.integration def test_build_target_key_with_deep_nesting(self, tmp_path: Path) -> None: """Target keys should work with deeply nested structures.""" - from portolan_cli.upload import _build_target_key + from portolan_cli.sync.upload import _build_target_key # Create deep nesting deep_path = tmp_path / "a" / "b" / "c" / "d" @@ -104,7 +104,7 @@ class TestEmptyDirectoryHandling: @pytest.mark.integration def test_empty_directory_returns_success(self, tmp_path: Path) -> None: """Empty directory should return success with zero files.""" - from portolan_cli.upload import upload_directory + from portolan_cli.sync.upload import upload_directory empty_dir = tmp_path / "empty" empty_dir.mkdir() @@ -122,7 +122,7 @@ def test_empty_directory_returns_success(self, tmp_path: Path) -> None: @pytest.mark.integration def test_directory_with_no_matching_pattern(self, upload_test_dir: Path) -> None: """Directory with no matching files should return success.""" - from portolan_cli.upload import upload_directory + from portolan_cli.sync.upload import upload_directory result = upload_directory( source=upload_test_dir, @@ -141,7 +141,7 @@ class TestFileValidation: @pytest.mark.integration def test_upload_file_nonexistent_raises(self, tmp_path: Path) -> None: """Uploading a nonexistent file should raise FileNotFoundError.""" - from portolan_cli.upload import upload_file + from portolan_cli.sync.upload import upload_file nonexistent = tmp_path / "does_not_exist.parquet" @@ -151,7 +151,7 @@ def test_upload_file_nonexistent_raises(self, tmp_path: Path) -> None: @pytest.mark.integration def test_upload_file_directory_raises(self, upload_test_dir: Path) -> None: """Uploading a directory as a file should raise ValueError.""" - from portolan_cli.upload import upload_file + from portolan_cli.sync.upload import upload_file with pytest.raises(ValueError, match="Source is not a file"): upload_file(source=upload_test_dir, destination="s3://bucket/path") @@ -159,7 +159,7 @@ def test_upload_file_directory_raises(self, upload_test_dir: Path) -> None: @pytest.mark.integration def test_upload_directory_file_raises(self, tmp_path: Path) -> None: """Uploading a file as directory should raise ValueError.""" - from portolan_cli.upload import upload_directory + from portolan_cli.sync.upload import upload_directory file_path = tmp_path / "file.txt" file_path.write_text("content") @@ -178,7 +178,7 @@ def test_s3_url_with_complex_prefix(self) -> None: Note: Trailing slashes are stripped per issue #144 fix to prevent double-slash issues in path construction. """ - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("s3://mybucket/path/to/deeply/nested/data/") @@ -188,7 +188,7 @@ def test_s3_url_with_complex_prefix(self) -> None: @pytest.mark.integration def test_azure_url_complex(self) -> None: """Azure URL with path should parse correctly.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("az://storageaccount/container/data/path") @@ -202,7 +202,7 @@ class TestDryRunMode: @pytest.mark.integration def test_dry_run_file_does_not_require_credentials(self, tmp_path: Path) -> None: """Dry-run should work without valid credentials.""" - from portolan_cli.upload import upload_file + from portolan_cli.sync.upload import upload_file test_file = tmp_path / "test.parquet" test_file.write_bytes(b"test content") @@ -220,7 +220,7 @@ def test_dry_run_file_does_not_require_credentials(self, tmp_path: Path) -> None @pytest.mark.integration def test_dry_run_directory_does_not_require_credentials(self, upload_test_dir: Path) -> None: """Dry-run directory upload should work without credentials.""" - from portolan_cli.upload import upload_directory + from portolan_cli.sync.upload import upload_directory result = upload_directory( source=upload_test_dir, @@ -240,7 +240,7 @@ def test_check_credentials_with_real_env(self) -> None: """Credential checking should work with real environment.""" import os - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials # Save original env vars original_access = os.environ.get("AWS_ACCESS_KEY_ID") diff --git a/tests/spec_compliance/test_extensions_doc_parity.py b/tests/spec_compliance/test_extensions_doc_parity.py index 8da9083a..35e26927 100644 --- a/tests/spec_compliance/test_extensions_doc_parity.py +++ b/tests/spec_compliance/test_extensions_doc_parity.py @@ -20,7 +20,7 @@ from portolan_cli.constants import GEOSPATIAL_EXTENSIONS, TABULAR_EXTENSIONS from portolan_cli.extension_registry import EXTENSION_REGISTRY, all_known_sidecar_extensions from portolan_cli.formats import UNSUPPORTED_EXTENSIONS -from portolan_cli.scan_classify import ( +from portolan_cli.scan.classify import ( DOC_EXTENSIONS, IMAGE_EXTENSIONS, JUNK_DIRS, diff --git a/tests/unit/extract/common/test_orchestrator_base.py b/tests/unit/extract/common/test_orchestrator_base.py index bc8e5e87..7ccb31d7 100644 --- a/tests/unit/extract/common/test_orchestrator_base.py +++ b/tests/unit/extract/common/test_orchestrator_base.py @@ -197,7 +197,7 @@ def test_registers_styles_and_optional_legends( ) -> None: report = _report([_layer(0, "a", output_path="a/a.parquet")]) - import portolan_cli.style as style_mod + import portolan_cli.viz.style as style_mod style_calls: list[Path] = [] legend_calls: list[Path] = [] @@ -222,7 +222,7 @@ def test_skips_unsuccessful_layers( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: report = _report([_layer(0, "a", status="failed")]) - import portolan_cli.style as style_mod + import portolan_cli.viz.style as style_mod called = {"discover": False} diff --git a/tests/unit/test_add.py b/tests/unit/test_add.py index d504c2dd..475badcf 100644 --- a/tests/unit/test_add.py +++ b/tests/unit/test_add.py @@ -24,8 +24,8 @@ get_item_info, list_items, ) -from portolan_cli.checksums import compute_dir_size from portolan_cli.formats import FormatType +from portolan_cli.sync.checksums import compute_dir_size from portolan_cli.versions import read_versions if TYPE_CHECKING: diff --git a/tests/unit/test_asset_role_media.py b/tests/unit/test_asset_role_media.py index 7e45652c..161b6f25 100644 --- a/tests/unit/test_asset_role_media.py +++ b/tests/unit/test_asset_role_media.py @@ -13,7 +13,7 @@ import pytest from portolan_cli.add import _get_asset_role, _get_media_type -from portolan_cli.scan_classify import IMAGE_EXTENSIONS +from portolan_cli.scan.classify import IMAGE_EXTENSIONS pytestmark = pytest.mark.unit diff --git a/tests/unit/test_check_remove_legacy.py b/tests/unit/test_check_remove_legacy.py index e9261b26..12dc89e9 100644 --- a/tests/unit/test_check_remove_legacy.py +++ b/tests/unit/test_check_remove_legacy.py @@ -16,12 +16,12 @@ import pytest -from portolan_cli.check import ( +from portolan_cli.convert import ConversionReport, ConversionResult, ConversionStatus +from portolan_cli.scan.check import ( check_directory, get_legacy_files_to_remove, remove_legacy_files, ) -from portolan_cli.convert import ConversionReport, ConversionResult, ConversionStatus if TYPE_CHECKING: pass @@ -457,7 +457,7 @@ def test_remove_legacy_ignored_in_dry_run( geojson.write_text('{"type": "FeatureCollection", "features": []}') # Mock conversion to avoid actual file operations - with patch("portolan_cli.check.convert_directory") as mock_convert: + with patch("portolan_cli.scan.check.convert_directory") as mock_convert: mock_convert.return_value = ConversionReport(results=[]) check_directory(data_dir, fix=True, dry_run=True, remove_legacy=True) @@ -465,8 +465,8 @@ def test_remove_legacy_ignored_in_dry_run( # File should still exist (dry run) assert geojson.exists() - @patch("portolan_cli.check.remove_legacy_files") - @patch("portolan_cli.check.convert_directory") + @patch("portolan_cli.scan.check.remove_legacy_files") + @patch("portolan_cli.scan.check.convert_directory") def test_remove_legacy_called_after_conversion( self, mock_convert: MagicMock, diff --git a/tests/unit/test_check_workflow.py b/tests/unit/test_check_workflow.py index 96e49e3b..fb46c7cb 100644 --- a/tests/unit/test_check_workflow.py +++ b/tests/unit/test_check_workflow.py @@ -38,7 +38,7 @@ class TestResolveCatalogRootForCheck: @pytest.mark.unit def test_finds_catalog_json_in_parent(self, tmp_path: Path) -> None: - from portolan_cli.check import resolve_catalog_root_for_check + from portolan_cli.scan.check import resolve_catalog_root_for_check _init_catalog(tmp_path) subdir = tmp_path / "collection" / "nested" @@ -48,7 +48,7 @@ def test_finds_catalog_json_in_parent(self, tmp_path: Path) -> None: @pytest.mark.unit def test_returns_none_without_catalog_json(self, tmp_path: Path) -> None: - from portolan_cli.check import resolve_catalog_root_for_check + from portolan_cli.scan.check import resolve_catalog_root_for_check # No catalog.json anywhere under a private temp dir. loose = tmp_path / "loose" @@ -63,7 +63,7 @@ class TestBuildCheckRules: @pytest.mark.unit def test_matches_underlying_build_rules(self, tmp_path: Path) -> None: """Without config, build_check_rules matches validation.runner._build_rules.""" - from portolan_cli.check import build_check_rules + from portolan_cli.scan.check import build_check_rules from portolan_cli.validation.runner import _build_rules _init_catalog(tmp_path) @@ -75,7 +75,7 @@ def test_matches_underlying_build_rules(self, tmp_path: Path) -> None: @pytest.mark.unit def test_returns_rules_without_portolan_dir(self, tmp_path: Path) -> None: """A path with no .portolan/config.yaml still yields the default rules.""" - from portolan_cli.check import build_check_rules + from portolan_cli.scan.check import build_check_rules from portolan_cli.validation.runner import _build_rules got = build_check_rules(tmp_path, strict=True) @@ -90,7 +90,7 @@ class TestRunFixWorkflow: @pytest.mark.unit def test_metadata_only_without_catalog_sets_fatal_error(self, tmp_path: Path) -> None: """Metadata-only fix with no catalog.json returns a fatal_error message.""" - from portolan_cli.check import run_fix_workflow + from portolan_cli.scan.check import run_fix_workflow loose = tmp_path / "loose" loose.mkdir() @@ -110,13 +110,13 @@ def test_metadata_only_without_catalog_sets_fatal_error(self, tmp_path: Path) -> @pytest.mark.unit def test_mixed_mode_without_catalog_skips_metadata(self, tmp_path: Path) -> None: """Mixed mode with no catalog skips metadata (no fatal) and still runs geo fix.""" - from portolan_cli.check import CheckReport, run_fix_workflow + from portolan_cli.scan.check import CheckReport, run_fix_workflow loose = tmp_path / "loose" loose.mkdir() sentinel = CheckReport(root=loose, files=[], conversion_report=None) - with patch("portolan_cli.check.check_directory", return_value=sentinel) as mock_check: + with patch("portolan_cli.scan.check.check_directory", return_value=sentinel) as mock_check: outcome = run_fix_workflow( path=loose, run_metadata=True, @@ -133,11 +133,11 @@ def test_mixed_mode_without_catalog_skips_metadata(self, tmp_path: Path) -> None @pytest.mark.unit def test_geo_only_threads_force_and_workers(self, tmp_path: Path) -> None: """Geo-only fix forwards force/workers to check_directory.""" - from portolan_cli.check import CheckReport, run_fix_workflow + from portolan_cli.scan.check import CheckReport, run_fix_workflow _init_catalog(tmp_path) sentinel = CheckReport(root=tmp_path, files=[], conversion_report=None) - with patch("portolan_cli.check.check_directory", return_value=sentinel) as mock_check: + with patch("portolan_cli.scan.check.check_directory", return_value=sentinel) as mock_check: outcome = run_fix_workflow( path=tmp_path, run_metadata=False, @@ -156,13 +156,13 @@ def test_geo_only_threads_force_and_workers(self, tmp_path: Path) -> None: @pytest.mark.unit def test_metadata_fix_reports_failures(self, tmp_path: Path) -> None: """A metadata fix with failures sets has_failures on the outcome.""" - from portolan_cli.check import run_fix_workflow from portolan_cli.metadata.fix import FixAction, FixReport, FixResult from portolan_cli.metadata.models import ( MetadataCheckResult, MetadataReport, MetadataStatus, ) + from portolan_cli.scan.check import run_fix_workflow _init_catalog(tmp_path) diff --git a/tests/unit/test_cli_check.py b/tests/unit/test_cli_check.py index 32f49008..cf574b4b 100644 --- a/tests/unit/test_cli_check.py +++ b/tests/unit/test_cli_check.py @@ -64,7 +64,7 @@ def mock_check_report(tmp_path: Path): Returns: CheckReport with empty files list. """ - from portolan_cli.check import CheckReport + from portolan_cli.scan.check import CheckReport return CheckReport(root=tmp_path, files=[], conversion_report=None) @@ -845,14 +845,14 @@ def test_fix_with_both_scopes( # --fix without scope flags should run both metadata and geo-asset fixes with ( patch("portolan_cli.cli.validate_catalog") as mock_validate, - patch("portolan_cli.check.check_directory") as mock_check, + patch("portolan_cli.scan.check.check_directory") as mock_check, patch("portolan_cli.metadata.scan.scan_catalog_metadata") as mock_md_check, patch("portolan_cli.metadata.fix_metadata") as mock_fix, ): from portolan_cli.validation.results import ValidationReport mock_validate.return_value = ValidationReport(results=[]) - from portolan_cli.check import CheckReport + from portolan_cli.scan.check import CheckReport mock_check.return_value = CheckReport( root=valid_catalog_with_parquet, files=[], conversion_report=None @@ -919,9 +919,9 @@ def test_force_and_workers_threaded_to_check_directory( self, runner: CliRunner, catalog: Path ) -> None: """--fix --force --workers N reaches check_directory with those values.""" - from portolan_cli.check import CheckReport + from portolan_cli.scan.check import CheckReport - with patch("portolan_cli.check.check_directory") as mock_check: + with patch("portolan_cli.scan.check.check_directory") as mock_check: mock_check.return_value = CheckReport(root=catalog, files=[], conversion_report=None) result = runner.invoke( cli, @@ -949,9 +949,9 @@ def test_workers_defaults_to_cpu_count_when_unset( """Without --workers, the CLI resolves a concrete worker count (parallel by default).""" import os - from portolan_cli.check import CheckReport + from portolan_cli.scan.check import CheckReport - with patch("portolan_cli.check.check_directory") as mock_check: + with patch("portolan_cli.scan.check.check_directory") as mock_check: mock_check.return_value = CheckReport(root=catalog, files=[], conversion_report=None) result = runner.invoke( cli, diff --git a/tests/unit/test_cli_profile_default.py b/tests/unit/test_cli_profile_default.py index d9014b5f..4531173a 100644 --- a/tests/unit/test_cli_profile_default.py +++ b/tests/unit/test_cli_profile_default.py @@ -53,7 +53,7 @@ def test_push_defaults_to_default_profile(self, mock_catalog: Path) -> None: """portolan push should use 'default' profile when --profile not specified.""" runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = MagicMock( success=True, files_uploaded=0, @@ -87,7 +87,7 @@ def test_push_respects_explicit_profile(self, mock_catalog: Path) -> None: """portolan push should use explicit --profile when provided.""" runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = MagicMock( success=True, files_uploaded=0, @@ -118,11 +118,11 @@ def test_push_respects_explicit_profile(self, mock_catalog: Path) -> None: @pytest.mark.unit def test_pull_defaults_to_default_profile(self, mock_catalog: Path) -> None: """portolan pull should use 'default' profile when --profile not specified.""" - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult runner = CliRunner() - with patch("portolan_cli.pull.pull") as mock_pull: + with patch("portolan_cli.sync.pull.pull") as mock_pull: mock_pull.return_value = PullResult( success=True, files_downloaded=0, @@ -153,7 +153,7 @@ def test_sync_defaults_to_default_profile(self, mock_catalog: Path) -> None: """portolan sync should use 'default' profile when --profile not specified.""" runner = CliRunner() - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = MagicMock( success=True, files_pulled=0, @@ -178,7 +178,7 @@ def test_clone_defaults_to_default_profile(self, tmp_path: Path) -> None: runner = CliRunner() clone_target = tmp_path / "cloned-catalog" - with patch("portolan_cli.sync.clone") as mock_clone: + with patch("portolan_cli.sync.core.clone") as mock_clone: mock_clone.return_value = MagicMock( success=True, local_path=clone_target, @@ -238,7 +238,7 @@ def test_push_uses_aws_profile_from_env(self, basic_catalog: Path) -> None: """portolan push should use PORTOLAN_AWS_PROFILE env var when --profile not specified.""" runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = MagicMock( success=True, files_uploaded=0, @@ -266,7 +266,7 @@ def test_push_cli_profile_overrides_env(self, basic_catalog: Path) -> None: """CLI --profile should override PORTOLAN_AWS_PROFILE env var.""" runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = MagicMock( success=True, files_uploaded=0, @@ -300,11 +300,11 @@ def test_push_cli_profile_overrides_env(self, basic_catalog: Path) -> None: @pytest.mark.unit def test_pull_uses_aws_profile_from_env(self, basic_catalog: Path) -> None: """portolan pull should use PORTOLAN_AWS_PROFILE env var when --profile not specified.""" - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult runner = CliRunner() - with patch("portolan_cli.pull.pull") as mock_pull: + with patch("portolan_cli.sync.pull.pull") as mock_pull: mock_pull.return_value = PullResult( success=True, files_downloaded=0, @@ -336,7 +336,7 @@ def test_sync_uses_aws_profile_from_env(self, basic_catalog: Path) -> None: """portolan sync should use PORTOLAN_AWS_PROFILE env var when --profile not specified.""" runner = CliRunner() - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = MagicMock( success=True, files_pulled=0, @@ -364,7 +364,7 @@ def test_clone_uses_aws_profile_from_env(self, tmp_path: Path) -> None: runner = CliRunner() clone_target = tmp_path / "cloned-catalog" - with patch("portolan_cli.sync.clone") as mock_clone: + with patch("portolan_cli.sync.core.clone") as mock_clone: mock_clone.return_value = MagicMock( success=True, local_path=clone_target, @@ -410,7 +410,7 @@ def test_push_uses_env_var_when_no_cli(self, tmp_path: Path) -> None: runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = MagicMock( success=True, files_uploaded=0, diff --git a/tests/unit/test_cli_push_chunk_concurrency.py b/tests/unit/test_cli_push_chunk_concurrency.py index 439983a0..6bcb3bfd 100644 --- a/tests/unit/test_cli_push_chunk_concurrency.py +++ b/tests/unit/test_cli_push_chunk_concurrency.py @@ -17,7 +17,7 @@ from click.testing import CliRunner from portolan_cli.cli import cli -from portolan_cli.push import PushAllResult, PushResult +from portolan_cli.sync.push import PushAllResult, PushResult # Remote URL for tests - set via env var (Issue #356: sensitive settings) TEST_REMOTE = "s3://test/catalog" @@ -49,7 +49,7 @@ def runner(self) -> CliRunner: return CliRunner() @pytest.mark.unit - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_chunk_concurrency_flag_exists( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -73,7 +73,7 @@ def test_chunk_concurrency_flag_exists( assert result.exit_code == 0, f"Failed: {result.output}" @pytest.mark.unit - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_chunk_concurrency_value_passed_to_push_all( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -99,7 +99,7 @@ def test_chunk_concurrency_value_passed_to_push_all( assert call_kwargs.get("chunk_concurrency") == 6 @pytest.mark.unit - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_chunk_concurrency_default_is_4( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -146,7 +146,7 @@ def test_chunk_concurrency_rejects_negative(self, runner: CliRunner, tmp_path: P assert result.exit_code != 0 @pytest.mark.unit - @patch("portolan_cli.push.push_async") + @patch("portolan_cli.sync.push.push_async") def test_chunk_concurrency_passed_to_single_collection_push( self, mock_push_async: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -200,7 +200,7 @@ def runner(self) -> CliRunner: return CliRunner() @pytest.mark.unit - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_both_concurrency_flags_can_be_set( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -238,7 +238,7 @@ def test_both_concurrency_flags_can_be_set( assert call_kwargs.get("chunk_concurrency") == 6 @pytest.mark.unit - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_high_concurrency_shows_warning( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: diff --git a/tests/unit/test_cli_push_max_connections.py b/tests/unit/test_cli_push_max_connections.py index 49294c0c..0f05ca29 100644 --- a/tests/unit/test_cli_push_max_connections.py +++ b/tests/unit/test_cli_push_max_connections.py @@ -17,7 +17,7 @@ from click.testing import CliRunner from portolan_cli.cli import cli -from portolan_cli.push import PushAllResult +from portolan_cli.sync.push import PushAllResult # Remote URL for tests - set via env var (Issue #356: sensitive settings) TEST_REMOTE = "s3://test/catalog" @@ -47,7 +47,7 @@ def runner(self) -> CliRunner: return CliRunner() @pytest.mark.unit - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_max_connections_flag_exists( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -70,7 +70,7 @@ def test_max_connections_flag_exists( assert result.exit_code == 0, f"Failed: {result.output}" @pytest.mark.unit - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_max_connections_value_passed_to_push_all( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -186,7 +186,7 @@ def runner(self) -> CliRunner: return CliRunner() @pytest.mark.unit - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_max_connections_considers_workers( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: diff --git a/tests/unit/test_cli_push_workers.py b/tests/unit/test_cli_push_workers.py index 09ab5a22..e33574cd 100644 --- a/tests/unit/test_cli_push_workers.py +++ b/tests/unit/test_cli_push_workers.py @@ -14,7 +14,7 @@ from click.testing import CliRunner from portolan_cli.cli import cli -from portolan_cli.push import PushAllResult, PushResult +from portolan_cli.sync.push import PushAllResult, PushResult # Remote URL for tests - set via env var (Issue #356: sensitive settings) TEST_REMOTE = "s3://test/catalog" @@ -46,7 +46,7 @@ def runner(self) -> CliRunner: return CliRunner() @pytest.mark.unit - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_workers_flag_exists( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -70,7 +70,7 @@ def test_workers_flag_exists( assert result.exit_code == 0, f"Failed: {result.output}" @pytest.mark.unit - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_workers_flag_shorthand( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -93,7 +93,7 @@ def test_workers_flag_shorthand( assert result.exit_code == 0, f"Failed: {result.output}" @pytest.mark.unit - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_workers_value_passed_to_push_all( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -120,7 +120,7 @@ def test_workers_value_passed_to_push_all( assert call_kwargs["workers"] == 8 @pytest.mark.unit - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_workers_default_is_none( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -147,7 +147,7 @@ def test_workers_default_is_none( assert call_kwargs["workers"] is None @pytest.mark.unit - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_concurrency_passed_for_single_collection( self, mock_push: AsyncMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -196,7 +196,7 @@ def test_workers_requires_positive_integer(self, runner: CliRunner, tmp_path: Pa assert result.exit_code != 0 @pytest.mark.unit - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_workers_1_for_sequential( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: diff --git a/tests/unit/test_clone_ergonomics.py b/tests/unit/test_clone_ergonomics.py index 79b76b58..19744346 100644 --- a/tests/unit/test_clone_ergonomics.py +++ b/tests/unit/test_clone_ergonomics.py @@ -31,7 +31,7 @@ class TestInferLocalPathFromUrl: @pytest.mark.unit def test_infer_from_s3_url(self) -> None: """Should extract catalog name from S3 URL.""" - from portolan_cli.sync import infer_local_path_from_url + from portolan_cli.sync.core import infer_local_path_from_url result = infer_local_path_from_url("s3://mybucket/my-catalog") assert result == Path("my-catalog") @@ -39,7 +39,7 @@ def test_infer_from_s3_url(self) -> None: @pytest.mark.unit def test_infer_from_s3_url_trailing_slash(self) -> None: """Should handle trailing slashes.""" - from portolan_cli.sync import infer_local_path_from_url + from portolan_cli.sync.core import infer_local_path_from_url result = infer_local_path_from_url("s3://mybucket/my-catalog/") assert result == Path("my-catalog") @@ -47,7 +47,7 @@ def test_infer_from_s3_url_trailing_slash(self) -> None: @pytest.mark.unit def test_infer_from_nested_path(self) -> None: """Should extract last component from nested path.""" - from portolan_cli.sync import infer_local_path_from_url + from portolan_cli.sync.core import infer_local_path_from_url result = infer_local_path_from_url("s3://mybucket/path/to/my-catalog") assert result == Path("my-catalog") @@ -55,7 +55,7 @@ def test_infer_from_nested_path(self) -> None: @pytest.mark.unit def test_infer_from_gcs_url(self) -> None: """Should work with GCS URLs.""" - from portolan_cli.sync import infer_local_path_from_url + from portolan_cli.sync.core import infer_local_path_from_url result = infer_local_path_from_url("gs://mybucket/catalog-name") assert result == Path("catalog-name") @@ -63,7 +63,7 @@ def test_infer_from_gcs_url(self) -> None: @pytest.mark.unit def test_infer_from_azure_url(self) -> None: """Should work with Azure URLs.""" - from portolan_cli.sync import infer_local_path_from_url + from portolan_cli.sync.core import infer_local_path_from_url result = infer_local_path_from_url("az://container/my-data") assert result == Path("my-data") @@ -71,7 +71,7 @@ def test_infer_from_azure_url(self) -> None: @pytest.mark.unit def test_infer_handles_multiple_trailing_slashes(self) -> None: """Should handle multiple trailing slashes.""" - from portolan_cli.sync import infer_local_path_from_url + from portolan_cli.sync.core import infer_local_path_from_url result = infer_local_path_from_url("s3://bucket/catalog///") assert result == Path("catalog") @@ -79,7 +79,7 @@ def test_infer_handles_multiple_trailing_slashes(self) -> None: @pytest.mark.unit def test_infer_raises_on_bucket_only_url(self) -> None: """Should raise error for bucket-only URLs (no catalog name).""" - from portolan_cli.sync import infer_local_path_from_url + from portolan_cli.sync.core import infer_local_path_from_url with pytest.raises(ValueError, match="Cannot infer"): infer_local_path_from_url("s3://mybucket") @@ -87,7 +87,7 @@ def test_infer_raises_on_bucket_only_url(self) -> None: @pytest.mark.unit def test_infer_raises_on_bucket_only_url_with_slash(self) -> None: """Should raise error for bucket-only URLs with trailing slash.""" - from portolan_cli.sync import infer_local_path_from_url + from portolan_cli.sync.core import infer_local_path_from_url with pytest.raises(ValueError, match="Cannot infer"): infer_local_path_from_url("s3://mybucket/") @@ -109,7 +109,7 @@ class TestInferLocalPathFromUrlHypothesis: @settings(max_examples=50) def test_infer_extracts_catalog_name(self, catalog_name: str) -> None: """Property: inferred path should match catalog name from URL.""" - from portolan_cli.sync import infer_local_path_from_url + from portolan_cli.sync.core import infer_local_path_from_url url = f"s3://somebucket/{catalog_name}" result = infer_local_path_from_url(url) @@ -135,7 +135,7 @@ def test_infer_extracts_catalog_name(self, catalog_name: str) -> None: @settings(max_examples=30) def test_infer_extracts_last_component(self, prefix: str, catalog_name: str) -> None: """Property: should extract last path component regardless of prefix.""" - from portolan_cli.sync import infer_local_path_from_url + from portolan_cli.sync.core import infer_local_path_from_url # Clean up prefix - remove leading/trailing slashes prefix_clean = prefix.strip("/") @@ -159,7 +159,7 @@ class TestListRemoteCollections: @pytest.mark.unit def test_list_collections_parses_stac_links(self, tmp_path: Path) -> None: """Should parse child links from STAC catalog.json.""" - from portolan_cli.sync import list_remote_collections + from portolan_cli.sync.core import list_remote_collections # Mock catalog.json content catalog_json = { @@ -174,7 +174,7 @@ def test_list_collections_parses_stac_links(self, tmp_path: Path) -> None: ], } - with patch("portolan_cli.sync._fetch_remote_catalog_json") as mock_fetch: + with patch("portolan_cli.sync.core._fetch_remote_catalog_json") as mock_fetch: mock_fetch.return_value = catalog_json result = list_remote_collections("s3://bucket/catalog") @@ -184,7 +184,7 @@ def test_list_collections_parses_stac_links(self, tmp_path: Path) -> None: @pytest.mark.unit def test_list_collections_returns_empty_for_no_children(self) -> None: """Should return empty list if catalog has no child collections.""" - from portolan_cli.sync import list_remote_collections + from portolan_cli.sync.core import list_remote_collections catalog_json = { "type": "Catalog", @@ -196,7 +196,7 @@ def test_list_collections_returns_empty_for_no_children(self) -> None: ], } - with patch("portolan_cli.sync._fetch_remote_catalog_json") as mock_fetch: + with patch("portolan_cli.sync.core._fetch_remote_catalog_json") as mock_fetch: mock_fetch.return_value = catalog_json result = list_remote_collections("s3://bucket/catalog") @@ -206,7 +206,7 @@ def test_list_collections_returns_empty_for_no_children(self) -> None: @pytest.mark.unit def test_list_collections_handles_absolute_hrefs(self) -> None: """Should handle absolute href paths in child links.""" - from portolan_cli.sync import list_remote_collections + from portolan_cli.sync.core import list_remote_collections catalog_json = { "type": "Catalog", @@ -217,7 +217,7 @@ def test_list_collections_handles_absolute_hrefs(self) -> None: ], } - with patch("portolan_cli.sync._fetch_remote_catalog_json") as mock_fetch: + with patch("portolan_cli.sync.core._fetch_remote_catalog_json") as mock_fetch: mock_fetch.return_value = catalog_json result = list_remote_collections("s3://bucket/catalog") @@ -228,9 +228,9 @@ def test_list_collections_handles_absolute_hrefs(self) -> None: @pytest.mark.unit def test_list_collections_propagates_profile(self) -> None: """Should pass AWS profile to fetch function.""" - from portolan_cli.sync import list_remote_collections + from portolan_cli.sync.core import list_remote_collections - with patch("portolan_cli.sync._fetch_remote_catalog_json") as mock_fetch: + with patch("portolan_cli.sync.core._fetch_remote_catalog_json") as mock_fetch: mock_fetch.return_value = {"type": "Catalog", "links": []} list_remote_collections("s3://bucket/catalog", profile="my-profile") @@ -244,14 +244,14 @@ class TestFetchRemoteCatalogJson: @pytest.mark.unit def test_fetch_downloads_and_parses_json(self, tmp_path: Path) -> None: """Should download catalog.json and parse it.""" - from portolan_cli.sync import _fetch_remote_catalog_json + from portolan_cli.sync.core import _fetch_remote_catalog_json catalog_content = {"type": "Catalog", "id": "test"} with ( - patch("portolan_cli.sync.download_file") as mock_download, + patch("portolan_cli.sync.core.download_file") as mock_download, patch("builtins.open", MagicMock()), - patch("portolan_cli.sync.json.load") as mock_json_load, + patch("portolan_cli.sync.core.json.load") as mock_json_load, patch("pathlib.Path.unlink"), patch("pathlib.Path.exists", return_value=True), ): @@ -269,9 +269,9 @@ def test_fetch_downloads_and_parses_json(self, tmp_path: Path) -> None: @pytest.mark.unit def test_fetch_raises_on_download_failure(self) -> None: """Should raise error if download fails.""" - from portolan_cli.sync import CloneError, _fetch_remote_catalog_json + from portolan_cli.sync.core import CloneError, _fetch_remote_catalog_json - with patch("portolan_cli.sync.download_file") as mock_download: + with patch("portolan_cli.sync.core.download_file") as mock_download: # errors is list[tuple[Path, Exception]] per DownloadResult mock_download.return_value = MagicMock( success=False, @@ -293,13 +293,13 @@ class TestCloneAllCollections: @pytest.mark.unit def test_clone_all_collections_when_none_specified(self, tmp_path: Path) -> None: """Should clone all collections when collection=None.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone target = tmp_path / "new_catalog" with ( - patch("portolan_cli.sync.list_remote_collections") as mock_list, - patch("portolan_cli.sync.init_catalog"), + patch("portolan_cli.sync.core.list_remote_collections") as mock_list, + patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.pull") as mock_pull, ): mock_list.return_value = ["collection-a", "collection-b"] @@ -325,13 +325,13 @@ def test_clone_all_collections_when_none_specified(self, tmp_path: Path) -> None @pytest.mark.unit def test_clone_single_collection_still_works(self, tmp_path: Path) -> None: """Should still support explicit collection specification.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone target = tmp_path / "new_catalog" with ( - patch("portolan_cli.sync.list_remote_collections") as mock_list, - patch("portolan_cli.sync.init_catalog"), + patch("portolan_cli.sync.core.list_remote_collections") as mock_list, + patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.pull") as mock_pull, ): mock_pull.return_value = MagicMock( @@ -356,13 +356,13 @@ def test_clone_single_collection_still_works(self, tmp_path: Path) -> None: @pytest.mark.unit def test_clone_fails_gracefully_when_no_collections(self, tmp_path: Path) -> None: """Should fail with helpful message when remote has no collections.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone target = tmp_path / "new_catalog" with ( - patch("portolan_cli.sync.list_remote_collections") as mock_list, - patch("portolan_cli.sync.init_catalog"), + patch("portolan_cli.sync.core.list_remote_collections") as mock_list, + patch("portolan_cli.sync.core.init_catalog"), ): mock_list.return_value = [] # No collections @@ -378,13 +378,13 @@ def test_clone_fails_gracefully_when_no_collections(self, tmp_path: Path) -> Non @pytest.mark.unit def test_clone_partial_failure_reports_all_errors(self, tmp_path: Path) -> None: """Should report errors for each failed collection in multi-clone.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone target = tmp_path / "new_catalog" with ( - patch("portolan_cli.sync.list_remote_collections") as mock_list, - patch("portolan_cli.sync.init_catalog"), + patch("portolan_cli.sync.core.list_remote_collections") as mock_list, + patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.pull") as mock_pull, ): mock_list.return_value = ["good-collection", "bad-collection", "another-good"] @@ -413,12 +413,12 @@ class TestCloneToCurrentDirectory: @pytest.mark.unit def test_clone_to_dot_with_empty_directory(self, tmp_path: Path) -> None: """Should allow cloning to '.' if directory is empty.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone # tmp_path is empty with ( - patch("portolan_cli.sync.init_catalog"), + patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.pull") as mock_pull, ): mock_pull.return_value = MagicMock( @@ -439,7 +439,7 @@ def test_clone_to_dot_with_empty_directory(self, tmp_path: Path) -> None: @pytest.mark.unit def test_clone_to_dot_fails_if_not_empty(self, tmp_path: Path) -> None: """Should fail if '.' is not empty.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone # Create a file to make directory non-empty (tmp_path / "existing.txt").write_text("content") @@ -465,7 +465,7 @@ class TestCloneResultMultiCollection: @pytest.mark.unit def test_clone_result_stores_multiple_pull_results(self, tmp_path: Path) -> None: """CloneResult should store results for multiple collections.""" - from portolan_cli.sync import CloneResult + from portolan_cli.sync.core import CloneResult # When cloning multiple collections, pull_results should be a list # or we store aggregate stats @@ -503,7 +503,7 @@ def test_cli_local_path_is_optional(self, cli_runner: CliRunner) -> None: from portolan_cli.cli import clone with ( - patch("portolan_cli.sync.clone") as mock_clone, + patch("portolan_cli.sync.core.clone") as mock_clone, ): mock_clone.return_value = MagicMock( success=True, @@ -532,7 +532,7 @@ def test_cli_collection_is_optional(self, cli_runner: CliRunner) -> None: from portolan_cli.cli import clone with ( - patch("portolan_cli.sync.clone") as mock_clone, + patch("portolan_cli.sync.core.clone") as mock_clone, ): mock_clone.return_value = MagicMock( success=True, @@ -561,7 +561,7 @@ def test_cli_explicit_local_path_honored(self, cli_runner: CliRunner) -> None: from portolan_cli.cli import clone with ( - patch("portolan_cli.sync.clone") as mock_clone, + patch("portolan_cli.sync.core.clone") as mock_clone, ): mock_clone.return_value = MagicMock( success=True, diff --git a/tests/unit/test_collection_id.py b/tests/unit/test_collection_id.py index 2a56c467..e17a0c9f 100644 --- a/tests/unit/test_collection_id.py +++ b/tests/unit/test_collection_id.py @@ -265,7 +265,7 @@ def test_compute_collection_id_fix_uppercase(self) -> None: """Should compute fix for uppercase collection ID.""" from pathlib import Path - from portolan_cli.scan_fix import _compute_collection_id_fix + from portolan_cli.scan.fix import _compute_collection_id_fix # Mock directory path dir_path = Path("/data/MyCollection") @@ -282,7 +282,7 @@ def test_compute_collection_id_fix_spaces(self) -> None: """Should compute fix for collection ID with spaces.""" from pathlib import Path - from portolan_cli.scan_fix import _compute_collection_id_fix + from portolan_cli.scan.fix import _compute_collection_id_fix dir_path = Path("/data/My Data") @@ -296,7 +296,7 @@ def test_compute_collection_id_fix_starts_with_number(self) -> None: """Collection IDs starting with numbers are now valid (ADR-0032).""" from pathlib import Path - from portolan_cli.scan_fix import _compute_collection_id_fix + from portolan_cli.scan.fix import _compute_collection_id_fix dir_path = Path("/data/2020-census") @@ -309,7 +309,7 @@ def test_compute_collection_id_fix_already_valid(self) -> None: """Should return None for already valid collection ID.""" from pathlib import Path - from portolan_cli.scan_fix import _compute_collection_id_fix + from portolan_cli.scan.fix import _compute_collection_id_fix dir_path = Path("/data/census-2020") @@ -320,7 +320,7 @@ def test_compute_collection_id_fix_already_valid(self) -> None: def test_is_fix_flag_issue_includes_collection_id(self) -> None: """INVALID_COLLECTION_ID should be in FIX_FLAG_ISSUE_TYPES.""" - from portolan_cli.scan_fix import FIX_FLAG_ISSUE_TYPES + from portolan_cli.scan.fix import FIX_FLAG_ISSUE_TYPES assert "invalid_collection_id" in FIX_FLAG_ISSUE_TYPES diff --git a/tests/unit/test_concurrency_defaults.py b/tests/unit/test_concurrency_defaults.py index ea4c702a..d57d2652 100644 --- a/tests/unit/test_concurrency_defaults.py +++ b/tests/unit/test_concurrency_defaults.py @@ -109,7 +109,7 @@ def test_upload_file_default_chunk_concurrency(self) -> None: """upload_file should default to 4 chunk concurrency.""" import inspect - from portolan_cli.upload import upload_file + from portolan_cli.sync.upload import upload_file sig = inspect.signature(upload_file) chunk_param = sig.parameters.get("chunk_concurrency") @@ -122,7 +122,7 @@ def test_upload_directory_default_chunk_concurrency(self) -> None: """upload_directory should default to 4 chunk concurrency.""" import inspect - from portolan_cli.upload import upload_directory + from portolan_cli.sync.upload import upload_directory sig = inspect.signature(upload_directory) chunk_param = sig.parameters.get("chunk_concurrency") @@ -137,7 +137,7 @@ def test_setup_store_default_chunk_concurrency(self) -> None: # to _setup_store_and_kwargs. The default should be 4. import inspect - from portolan_cli.upload import _setup_store_and_kwargs + from portolan_cli.sync.upload import _setup_store_and_kwargs sig = inspect.signature(_setup_store_and_kwargs) chunk_param = sig.parameters.get("chunk_concurrency") @@ -160,7 +160,7 @@ def test_push_async_default_concurrency(self) -> None: """push_async should default to 8 file concurrency.""" import inspect - from portolan_cli.push import push_async + from portolan_cli.sync.push import push_async sig = inspect.signature(push_async) conc_param = sig.parameters.get("concurrency") @@ -174,7 +174,7 @@ def test_push_async_has_chunk_concurrency_param(self) -> None: """push_async should accept chunk_concurrency parameter.""" import inspect - from portolan_cli.push import push_async + from portolan_cli.sync.push import push_async sig = inspect.signature(push_async) chunk_param = sig.parameters.get("chunk_concurrency") @@ -186,7 +186,7 @@ def test_push_all_collections_has_chunk_concurrency_param(self) -> None: """push_all_collections should accept chunk_concurrency parameter.""" import inspect - from portolan_cli.push import push_all_collections + from portolan_cli.sync.push import push_all_collections sig = inspect.signature(push_all_collections) chunk_param = sig.parameters.get("chunk_concurrency") diff --git a/tests/unit/test_concurrency_flag_issue_323.py b/tests/unit/test_concurrency_flag_issue_323.py index 4d61eb28..4ea702a7 100644 --- a/tests/unit/test_concurrency_flag_issue_323.py +++ b/tests/unit/test_concurrency_flag_issue_323.py @@ -19,8 +19,8 @@ from click.testing import CliRunner from portolan_cli.cli import cli -from portolan_cli.pull import PullAllResult, PullResult, pull_all_collections -from portolan_cli.push import PushAllResult, PushResult, push_all_collections +from portolan_cli.sync.pull import PullAllResult, PullResult, pull_all_collections +from portolan_cli.sync.push import PushAllResult, PushResult, push_all_collections # Mark all tests in this module as unit tests pytestmark = pytest.mark.unit @@ -54,7 +54,7 @@ def _create_collection(catalog_root: Path, name: str) -> None: class TestPushAllCollectionsFileConcurrency: """Tests for file_concurrency parameter in push_all_collections().""" - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_accepts_file_concurrency_parameter(self, mock_push: AsyncMock, tmp_path: Path) -> None: """push_all_collections accepts file_concurrency parameter.""" _setup_valid_catalog(tmp_path) @@ -77,7 +77,7 @@ def test_accepts_file_concurrency_parameter(self, mock_push: AsyncMock, tmp_path assert result.success is True - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_file_concurrency_passed_to_push_async( self, mock_push: AsyncMock, tmp_path: Path ) -> None: @@ -107,7 +107,7 @@ def test_file_concurrency_passed_to_push_async( for call in mock_push.call_args_list: assert call.kwargs.get("concurrency") == 5 - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_file_concurrency_none_uses_default(self, mock_push: AsyncMock, tmp_path: Path) -> None: """file_concurrency=None uses the default concurrency (8 per Issue #344).""" _setup_valid_catalog(tmp_path) @@ -142,7 +142,7 @@ def test_file_concurrency_none_uses_default(self, mock_push: AsyncMock, tmp_path class TestPullAllCollectionsFileConcurrency: """Tests for file_concurrency parameter in pull_all_collections().""" - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_accepts_file_concurrency_parameter(self, mock_pull: AsyncMock, tmp_path: Path) -> None: """pull_all_collections accepts file_concurrency parameter.""" _setup_valid_catalog(tmp_path) @@ -165,7 +165,7 @@ def test_accepts_file_concurrency_parameter(self, mock_pull: AsyncMock, tmp_path assert result.success is True - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_file_concurrency_passed_to_pull_async( self, mock_pull: AsyncMock, tmp_path: Path ) -> None: @@ -195,7 +195,7 @@ def test_file_concurrency_passed_to_pull_async( for call in mock_pull.call_args_list: assert call.kwargs.get("concurrency") == 5 - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_file_concurrency_none_uses_default(self, mock_pull: AsyncMock, tmp_path: Path) -> None: """file_concurrency=None allows pull_async to use its default.""" _setup_valid_catalog(tmp_path) @@ -234,7 +234,7 @@ def runner(self) -> CliRunner: """Create a Click test runner.""" return CliRunner() - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_push_concurrency_passed_to_push_all_collections( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -260,7 +260,7 @@ def test_push_concurrency_passed_to_push_all_collections( call_kwargs = mock_push_all.call_args.kwargs assert call_kwargs["file_concurrency"] == 10 - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_push_concurrency_default_passed_to_push_all_collections( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -287,7 +287,7 @@ def test_push_concurrency_default_passed_to_push_all_collections( call_kwargs = mock_push_all.call_args.kwargs assert call_kwargs["file_concurrency"] == 8 - @patch("portolan_cli.pull.pull_all_collections") + @patch("portolan_cli.sync.pull.pull_all_collections") def test_pull_concurrency_passed_to_pull_all_collections( self, mock_pull_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -313,7 +313,7 @@ def test_pull_concurrency_passed_to_pull_all_collections( call_kwargs = mock_pull_all.call_args.kwargs assert call_kwargs["file_concurrency"] == 10 - @patch("portolan_cli.pull.pull_all_collections") + @patch("portolan_cli.sync.pull.pull_all_collections") def test_pull_concurrency_default_passed_to_pull_all_collections( self, mock_pull_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -347,7 +347,7 @@ def test_pull_concurrency_default_passed_to_pull_all_collections( class TestCombinedConcurrencyParameters: """Tests for using both --workers and --concurrency together.""" - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_push_both_workers_and_file_concurrency( self, mock_push: AsyncMock, tmp_path: Path ) -> None: @@ -378,7 +378,7 @@ def test_push_both_workers_and_file_concurrency( for call in mock_push.call_args_list: assert call.kwargs.get("concurrency") == 5 - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_pull_both_workers_and_file_concurrency( self, mock_pull: AsyncMock, tmp_path: Path ) -> None: @@ -414,7 +414,7 @@ def runner(self) -> CliRunner: """Create a Click test runner.""" return CliRunner() - @patch("portolan_cli.push.push_all_collections") + @patch("portolan_cli.sync.push.push_all_collections") def test_cli_push_both_workers_and_concurrency( self, mock_push_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: @@ -444,7 +444,7 @@ def test_cli_push_both_workers_and_concurrency( assert call_kwargs["workers"] == 2 assert call_kwargs["file_concurrency"] == 10 - @patch("portolan_cli.pull.pull_all_collections") + @patch("portolan_cli.sync.pull.pull_all_collections") def test_cli_pull_both_workers_and_concurrency( self, mock_pull_all: MagicMock, runner: CliRunner, tmp_path: Path ) -> None: diff --git a/tests/unit/test_discover_nested_collections.py b/tests/unit/test_discover_nested_collections.py index 80c99b58..86f320bb 100644 --- a/tests/unit/test_discover_nested_collections.py +++ b/tests/unit/test_discover_nested_collections.py @@ -8,7 +8,7 @@ import pytest -from portolan_cli.push import discover_collections +from portolan_cli.sync.push import discover_collections @pytest.fixture diff --git a/tests/unit/test_download.py b/tests/unit/test_download.py index 7e8a66a4..0381ed5c 100644 --- a/tests/unit/test_download.py +++ b/tests/unit/test_download.py @@ -89,7 +89,7 @@ class TestDownloadResult: @pytest.mark.unit def test_download_result_success(self) -> None: """DownloadResult should track successful downloads.""" - from portolan_cli.download import DownloadResult + from portolan_cli.sync.download import DownloadResult result = DownloadResult( success=True, @@ -108,7 +108,7 @@ def test_download_result_success(self) -> None: @pytest.mark.unit def test_download_result_partial_failure(self) -> None: """DownloadResult should track partial failures.""" - from portolan_cli.download import DownloadResult + from portolan_cli.sync.download import DownloadResult error = OSError("Network error") result = DownloadResult( @@ -130,7 +130,7 @@ def test_download_result_partial_failure(self) -> None: @pytest.mark.unit def test_download_result_default_errors(self) -> None: """DownloadResult should default to empty errors list.""" - from portolan_cli.download import DownloadResult + from portolan_cli.sync.download import DownloadResult result = DownloadResult( success=True, @@ -153,13 +153,13 @@ class TestDownloadFile: @pytest.mark.unit def test_download_file_success(self, temp_download_dir: Path) -> None: """download_file should download a single file successfully.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "data.parquet" test_data = b"test data content" - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -188,11 +188,11 @@ def test_download_file_success(self, temp_download_dir: Path) -> None: @pytest.mark.unit def test_download_file_dry_run(self, temp_download_dir: Path) -> None: """Dry-run should not perform actual download.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "data.parquet" - with patch("portolan_cli.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.obs") as mock_obs: result = download_file( source="s3://mybucket/data.parquet", destination=dest_file, @@ -206,14 +206,14 @@ def test_download_file_dry_run(self, temp_download_dir: Path) -> None: @pytest.mark.unit def test_download_file_creates_parent_dir(self, tmp_path: Path) -> None: """download_file should create parent directories if needed.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file # Destination in non-existent subdirectory dest_file = tmp_path / "nested" / "deep" / "data.parquet" test_data = b"test data" - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -237,12 +237,12 @@ def test_download_file_creates_parent_dir(self, tmp_path: Path) -> None: @pytest.mark.unit def test_download_file_failure(self, temp_download_dir: Path) -> None: """download_file should handle download failures gracefully.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "data.parquet" - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store mock_obs.get.side_effect = OSError("Network error") @@ -264,13 +264,13 @@ def test_download_file_failure(self, temp_download_dir: Path) -> None: @pytest.mark.unit def test_download_file_gcs(self, temp_download_dir: Path) -> None: """download_file should work with GCS URLs.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "data.parquet" test_data = b"gcs data content" - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.upload.obs") as mock_upload_obs: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.upload.obs") as mock_upload_obs: mock_response = MagicMock() mock_response.meta = {"size": len(test_data)} mock_response.__iter__ = lambda self: iter([test_data]) @@ -289,14 +289,14 @@ def test_download_file_gcs(self, temp_download_dir: Path) -> None: @pytest.mark.unit def test_download_file_azure(self, temp_download_dir: Path) -> None: """download_file should work with Azure URLs.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "data.parquet" test_data = b"azure data content" # Need to patch both download.obs AND upload.obs since _setup_store_and_kwargs is in upload - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.upload.obs") as mock_upload_obs: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.upload.obs") as mock_upload_obs: mock_response = MagicMock() mock_response.meta = {"size": len(test_data)} mock_response.__iter__ = lambda self: iter([test_data]) @@ -326,14 +326,14 @@ class TestDownloadDirectory: @pytest.mark.unit def test_download_directory_all_files(self, temp_download_dir: Path) -> None: """Directory download should download all files.""" - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory # Use consistent size for test data test_data = b"file content" file_size = len(test_data) - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -368,13 +368,13 @@ def test_download_directory_all_files(self, temp_download_dir: Path) -> None: @pytest.mark.unit def test_download_directory_with_pattern(self, temp_download_dir: Path) -> None: """Directory download with pattern should filter files.""" - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory test_data = b"parquet content" file_size = len(test_data) - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -410,9 +410,9 @@ def test_download_directory_with_pattern(self, temp_download_dir: Path) -> None: @pytest.mark.unit def test_download_directory_dry_run(self, temp_download_dir: Path) -> None: """Dry-run should not perform actual downloads.""" - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory - with patch("portolan_cli.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.obs") as mock_obs: # Mock list to return files mock_obs.list.return_value = [ [ @@ -434,10 +434,10 @@ def test_download_directory_dry_run(self, temp_download_dir: Path) -> None: @pytest.mark.unit def test_download_directory_empty(self, temp_download_dir: Path) -> None: """Empty remote directory should return appropriate result.""" - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -460,10 +460,10 @@ def test_download_directory_empty(self, temp_download_dir: Path) -> None: @pytest.mark.unit def test_download_directory_fail_fast_true(self, temp_download_dir: Path) -> None: """fail_fast=True should stop on first error and report failure.""" - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -496,10 +496,10 @@ def test_download_directory_fail_fast_true(self, temp_download_dir: Path) -> Non @pytest.mark.unit def test_download_directory_fail_fast_false(self, temp_download_dir: Path) -> None: """fail_fast=False should continue and collect all errors.""" - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -532,7 +532,7 @@ def test_download_directory_fail_fast_false(self, temp_download_dir: Path) -> No @pytest.mark.unit def test_download_directory_preserves_structure(self, temp_download_dir: Path) -> None: """Directory download should preserve relative path structure.""" - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory test_data = b"file content data" file_size = len(test_data) @@ -545,8 +545,8 @@ def capture_download(store: object, key: str) -> MagicMock: mock_response.__iter__ = lambda self: iter([test_data]) return mock_response - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -585,14 +585,14 @@ class TestCustomS3Endpoint: @pytest.mark.unit def test_download_file_custom_endpoint(self, temp_download_dir: Path) -> None: """download_file should support custom S3 endpoints.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "data.parquet" test_data = b"endpoint data" # Patch S3Store in upload module where _setup_store_and_kwargs lives - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -622,14 +622,14 @@ def test_download_file_custom_endpoint(self, temp_download_dir: Path) -> None: @pytest.mark.unit def test_download_file_custom_endpoint_no_ssl(self, temp_download_dir: Path) -> None: """download_file should support HTTP for custom endpoints.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "data.parquet" test_data = b"http data" # Patch S3Store in upload module where _setup_store_and_kwargs lives - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -665,12 +665,12 @@ class TestStreamingDownload: @pytest.mark.unit def test_download_writes_in_chunks(self, temp_download_dir: Path) -> None: """download_file should write data in chunks (streaming).""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "large.parquet" - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -710,7 +710,7 @@ class TestUploadModuleReuse: def test_uses_parse_object_store_url(self) -> None: """download module should use parse_object_store_url from upload.""" # This test verifies the import works - actual parsing tested in upload tests - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("s3://bucket/path/to/file") assert bucket_url == "s3://bucket" @@ -719,7 +719,7 @@ def test_uses_parse_object_store_url(self) -> None: @pytest.mark.unit def test_uses_check_credentials(self) -> None: """download module should use check_credentials from upload.""" - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials # Test with env vars set with patch.dict( @@ -733,9 +733,9 @@ def test_uses_check_credentials(self) -> None: @pytest.mark.unit def test_uses_setup_store_and_kwargs(self) -> None: """download module should use _setup_store_and_kwargs from upload.""" - from portolan_cli.upload import _setup_store_and_kwargs + from portolan_cli.sync.upload import _setup_store_and_kwargs - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -760,13 +760,13 @@ class TestFileIntegrity: @pytest.mark.unit def test_download_verifies_file_size_matches_metadata(self, temp_download_dir: Path) -> None: """Downloaded file size should match expected size from metadata.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "data.parquet" expected_content = b"test data content" - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -793,13 +793,13 @@ def test_download_verifies_file_size_matches_metadata(self, temp_download_dir: P @pytest.mark.unit def test_download_detects_size_mismatch(self, temp_download_dir: Path) -> None: """Download should fail when actual size doesn't match expected size.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "data.parquet" actual_content = b"short" # 5 bytes - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -828,7 +828,7 @@ def test_download_detects_size_mismatch(self, temp_download_dir: Path) -> None: @pytest.mark.unit def test_download_cleans_up_partial_file_on_failure(self, temp_download_dir: Path) -> None: """Partial files should be deleted when download fails mid-stream.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "data.parquet" @@ -837,8 +837,8 @@ def fail_mid_stream() -> Generator[bytes, None, None]: yield b"first chunk" raise OSError("Connection lost") - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -863,7 +863,7 @@ def fail_mid_stream() -> Generator[bytes, None, None]: @pytest.mark.unit def test_download_one_file_cleans_up_on_failure(self, temp_download_dir: Path) -> None: """_download_one_file should clean up partial file on failure.""" - from portolan_cli.download import _download_one_file + from portolan_cli.sync.download import _download_one_file local_path = temp_download_dir / "data.parquet" mock_store = MagicMock() @@ -872,7 +872,7 @@ def fail_mid_stream() -> Generator[bytes, None, None]: yield b"partial data" raise OSError("Network error") - with patch("portolan_cli.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.obs") as mock_obs: mock_response = MagicMock() mock_response.__iter__ = lambda self: fail_mid_stream() mock_obs.get.return_value = mock_response @@ -898,12 +898,12 @@ class TestOverwriteProtection: @pytest.mark.unit def test_download_file_skips_existing_by_default(self, temp_download_dir: Path) -> None: """download_file should skip existing files when overwrite=False.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "existing.parquet" dest_file.write_bytes(b"original content") - with patch("portolan_cli.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.obs") as mock_obs: result = download_file( source="s3://mybucket/existing.parquet", destination=dest_file, @@ -920,14 +920,14 @@ def test_download_file_skips_existing_by_default(self, temp_download_dir: Path) @pytest.mark.unit def test_download_file_overwrites_when_specified(self, temp_download_dir: Path) -> None: """download_file should overwrite existing files when overwrite=True.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "existing.parquet" dest_file.write_bytes(b"original content") new_content = b"new content" - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -954,14 +954,14 @@ def test_download_file_overwrites_when_specified(self, temp_download_dir: Path) @pytest.mark.unit def test_download_directory_skips_existing_files(self, temp_download_dir: Path) -> None: """download_directory should skip existing files when overwrite=False.""" - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory # Create an existing file existing_file = temp_download_dir / "file1.parquet" existing_file.write_bytes(b"original") - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -1005,7 +1005,7 @@ class TestPathTraversalProtection: @pytest.mark.unit def test_rejects_path_traversal_with_dotdot(self, temp_download_dir: Path) -> None: """Should reject remote keys containing '..' that escape destination.""" - from portolan_cli.download import ( + from portolan_cli.sync.download import ( PathTraversalError, _build_local_path, _validate_local_path, @@ -1029,7 +1029,7 @@ def test_rejects_path_traversal_with_dotdot(self, temp_download_dir: Path) -> No @pytest.mark.unit def test_allows_safe_paths(self, temp_download_dir: Path) -> None: """Should allow safe paths that stay within destination.""" - from portolan_cli.download import _build_local_path, _validate_local_path + from portolan_cli.sync.download import _build_local_path, _validate_local_path destination = temp_download_dir / "safe_dir" destination.mkdir() @@ -1050,13 +1050,13 @@ def test_allows_safe_paths(self, temp_download_dir: Path) -> None: @pytest.mark.unit def test_download_directory_rejects_traversal(self, temp_download_dir: Path) -> None: """download_directory should skip files with path traversal attempts.""" - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory test_data = b"safe file content" file_size = len(test_data) - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -1100,7 +1100,7 @@ class TestInputValidation: @pytest.mark.unit def test_download_file_rejects_empty_destination(self, tmp_path: Path) -> None: """download_file should reject empty source URL.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file # Note: Path("") is valid (it represents "."), so we test source validation # instead, which explicitly checks for empty strings @@ -1113,7 +1113,7 @@ def test_download_file_rejects_empty_destination(self, tmp_path: Path) -> None: @pytest.mark.unit def test_download_file_rejects_empty_source(self, temp_download_dir: Path) -> None: """download_file should reject empty source URL.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file with pytest.raises(ValueError, match="source"): download_file( @@ -1124,7 +1124,7 @@ def test_download_file_rejects_empty_source(self, temp_download_dir: Path) -> No @pytest.mark.unit def test_download_directory_rejects_file_destination(self, temp_download_dir: Path) -> None: """download_directory should reject file as destination.""" - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory # Create a file where directory is expected file_dest = temp_download_dir / "not_a_dir.txt" @@ -1148,7 +1148,7 @@ class TestErrorTypeConsistency: @pytest.mark.unit def test_download_result_uses_path_for_errors(self) -> None: """DownloadResult.errors should use Path type like UploadResult.""" - from portolan_cli.download import DownloadResult + from portolan_cli.sync.download import DownloadResult error = OSError("Network error") result = DownloadResult( @@ -1165,12 +1165,12 @@ def test_download_result_uses_path_for_errors(self) -> None: @pytest.mark.unit def test_download_file_returns_path_in_errors(self, temp_download_dir: Path) -> None: """download_file should return Path in error tuples.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "data.parquet" - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store mock_obs.get.side_effect = OSError("Network error") @@ -1200,13 +1200,13 @@ class TestDryRunCoverage: @pytest.mark.unit def test_download_directory_dry_run_many_files(self, temp_download_dir: Path) -> None: """Dry run with > 10 files should show '... and N more' message.""" - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory # Create mock for listing 15+ files - must be list of list (generator behavior) mock_files = [[{"path": f"data/file{i}.parquet", "size": 100} for i in range(15)]] - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store mock_obs.list.return_value = mock_files @@ -1231,7 +1231,7 @@ class TestFailFastSubmitNextFile: @pytest.mark.unit def test_download_directory_fail_fast_submits_next(self, temp_download_dir: Path) -> None: """fail_fast mode should submit next file after one completes.""" - from portolan_cli.download import download_directory + from portolan_cli.sync.download import download_directory # Create 5 files, max_files=2 to force incremental submission # Must be list of list (generator behavior) @@ -1243,8 +1243,8 @@ def mock_get(store: object, key: str) -> MagicMock: response.__iter__ = lambda self: iter([b"x" * 100]) return response - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store mock_obs.list.return_value = mock_files @@ -1271,7 +1271,7 @@ class TestExceptionCleanupInDownloadFile: @pytest.mark.unit def test_download_file_cleans_up_on_unexpected_error(self, temp_download_dir: Path) -> None: """download_file should clean up partial file on unexpected exception.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "data.parquet" @@ -1281,8 +1281,8 @@ def mock_get(store: object, key: str) -> MagicMock: dest_file.write_bytes(b"partial") raise RuntimeError("Unexpected error during download") - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store mock_obs.get.side_effect = mock_get @@ -1303,15 +1303,15 @@ def mock_get(store: object, key: str) -> MagicMock: @pytest.mark.unit def test_download_file_handles_cleanup_oserror(self, temp_download_dir: Path) -> None: """download_file should handle OSError during cleanup gracefully.""" - from portolan_cli.download import download_file + from portolan_cli.sync.download import download_file dest_file = temp_download_dir / "data.parquet" def mock_get(store: object, key: str) -> MagicMock: raise RuntimeError("Download error") - with patch("portolan_cli.download.obs") as mock_obs: - with patch("portolan_cli.download.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.download.obs") as mock_obs: + with patch("portolan_cli.sync.download.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store mock_obs.get.side_effect = mock_get @@ -1338,7 +1338,7 @@ class TestGetRemoteFileSize: @pytest.mark.unit def test_get_remote_file_size_returns_content_length(self) -> None: """get_remote_file_size returns Content-Length header value.""" - from portolan_cli.download import get_remote_file_size + from portolan_cli.sync.download import get_remote_file_size with patch("httpx.Client") as mock_client_class: mock_client = MagicMock() @@ -1357,7 +1357,7 @@ def test_get_remote_file_size_returns_content_length(self) -> None: @pytest.mark.unit def test_get_remote_file_size_returns_none_on_missing_header(self) -> None: """get_remote_file_size returns None when Content-Length is missing.""" - from portolan_cli.download import get_remote_file_size + from portolan_cli.sync.download import get_remote_file_size with patch("httpx.Client") as mock_client_class: mock_client = MagicMock() @@ -1375,7 +1375,7 @@ def test_get_remote_file_size_returns_none_on_http_error(self) -> None: """get_remote_file_size returns None on HTTP errors.""" import httpx - from portolan_cli.download import get_remote_file_size + from portolan_cli.sync.download import get_remote_file_size with patch("httpx.Client") as mock_client_class: mock_client = MagicMock() @@ -1389,7 +1389,7 @@ def test_get_remote_file_size_returns_none_on_http_error(self) -> None: @pytest.mark.unit def test_get_remote_file_size_uses_custom_timeout(self) -> None: """get_remote_file_size respects custom timeout.""" - from portolan_cli.download import get_remote_file_size + from portolan_cli.sync.download import get_remote_file_size with patch("httpx.Client") as mock_client_class: mock_client = MagicMock() diff --git a/tests/unit/test_filegdb_add_support.py b/tests/unit/test_filegdb_add_support.py index a7de0113..8d290daa 100644 --- a/tests/unit/test_filegdb_add_support.py +++ b/tests/unit/test_filegdb_add_support.py @@ -12,7 +12,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from portolan_cli.scan import FormatType, ScanOptions, scan_directory +from portolan_cli.scan.core import FormatType, ScanOptions, scan_directory @pytest.mark.unit diff --git a/tests/unit/test_hive_partition_collection_id.py b/tests/unit/test_hive_partition_collection_id.py index 265859b9..e51684b3 100644 --- a/tests/unit/test_hive_partition_collection_id.py +++ b/tests/unit/test_hive_partition_collection_id.py @@ -11,8 +11,8 @@ from pathlib import Path -from portolan_cli.scan import _infer_collection_id_from_relative_path -from portolan_cli.scan_detect import is_hive_partition_dir +from portolan_cli.scan.core import _infer_collection_id_from_relative_path +from portolan_cli.scan.detect import is_hive_partition_dir class TestIsHivePartitionDir: diff --git a/tests/unit/test_issue_252_catalog_json_roundtrip.py b/tests/unit/test_issue_252_catalog_json_roundtrip.py index 357c03d4..505d02d6 100644 --- a/tests/unit/test_issue_252_catalog_json_roundtrip.py +++ b/tests/unit/test_issue_252_catalog_json_roundtrip.py @@ -189,7 +189,7 @@ class TestPushUploadsCatalogJson: def test_per_collection_push_uploads_catalog_json(self, full_catalog: Path) -> None: """Per-collection push SHOULD upload catalog.json for standalone use.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push uploaded_keys: list[str] = [] @@ -199,8 +199,8 @@ async def mock_put_async(store: Any, key: str, data: Any, **kwargs: Any) -> None async def mock_get_async(store: Any, key: str) -> bytes: raise FileNotFoundError("Not found") - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put_async): - with patch("portolan_cli.push.obs.get_async", side_effect=mock_get_async): + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_async): + with patch("portolan_cli.sync.push.obs.get_async", side_effect=mock_get_async): result = push( catalog_root=full_catalog, collection="test-collection", @@ -221,7 +221,7 @@ def test_push_all_collections_uploads_catalog_json(self, full_catalog: Path) -> Each push() call uploads catalog.json, and push_all_collections also uploads it at the end when all collections succeed. This ensures a consistent final state. """ - from portolan_cli.push import push_all_collections + from portolan_cli.sync.push import push_all_collections uploaded_keys: list[str] = [] @@ -232,9 +232,11 @@ def mock_put_sync(store: Any, key: str, data: Any, **kwargs: Any) -> None: # Sync version for _upload_catalog_json helper uploaded_keys.append(key) - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put_async): - with patch("portolan_cli.push.obs.put", side_effect=mock_put_sync): - with patch("portolan_cli.push.obs.get_async", new_callable=AsyncMock) as mock_get: + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_async): + with patch("portolan_cli.sync.push.obs.put", side_effect=mock_put_sync): + with patch( + "portolan_cli.sync.push.obs.get_async", new_callable=AsyncMock + ) as mock_get: mock_get.side_effect = FileNotFoundError("Not found") result = push_all_collections( @@ -252,7 +254,7 @@ def mock_put_sync(store: Any, key: str, data: Any, **kwargs: Any) -> None: def test_push_all_catalog_json_content_matches_local(self, full_catalog: Path) -> None: """Uploaded catalog.json content should match local file.""" - from portolan_cli.push import push_all_collections + from portolan_cli.sync.push import push_all_collections uploaded_content: dict[str, bytes] = {} @@ -269,9 +271,11 @@ def mock_put_sync(store: Any, key: str, data: Any, **kwargs: Any) -> None: else: uploaded_content[key] = bytes(data) if not isinstance(data, bytes) else data - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put_async): - with patch("portolan_cli.push.obs.put", side_effect=mock_put_sync): - with patch("portolan_cli.push.obs.get_async", new_callable=AsyncMock) as mock_get: + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_async): + with patch("portolan_cli.sync.push.obs.put", side_effect=mock_put_sync): + with patch( + "portolan_cli.sync.push.obs.get_async", new_callable=AsyncMock + ) as mock_get: mock_get.side_effect = FileNotFoundError("Not found") push_all_collections( @@ -296,15 +300,15 @@ class TestPushUploadsCollectionJson: def test_push_uploads_collection_json(self, full_catalog: Path) -> None: """Push should upload collection.json for the pushed collection.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push uploaded_keys: list[str] = [] async def mock_put_async(store: Any, key: str, data: Any, **kwargs: Any) -> None: uploaded_keys.append(key) - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put_async): - with patch("portolan_cli.push.obs.get_async", new_callable=AsyncMock) as mock_get: + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_async): + with patch("portolan_cli.sync.push.obs.get_async", new_callable=AsyncMock) as mock_get: mock_get.side_effect = FileNotFoundError("Not found") result = push( @@ -337,15 +341,15 @@ class TestPushUploadsItemStacFiles: def test_push_uploads_item_stac_file(self, full_catalog: Path) -> None: """Push should upload {item_id}.json for all items in the collection.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push uploaded_keys: list[str] = [] async def mock_put_async(store: Any, key: str, data: Any, **kwargs: Any) -> None: uploaded_keys.append(key) - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put_async): - with patch("portolan_cli.push.obs.get_async", new_callable=AsyncMock) as mock_get: + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_async): + with patch("portolan_cli.sync.push.obs.get_async", new_callable=AsyncMock) as mock_get: mock_get.side_effect = FileNotFoundError("Not found") result = push( @@ -380,15 +384,15 @@ def test_push_uploads_assets_before_manifests(self, full_catalog: Path) -> None: 4. catalog.json (root manifest) 5. versions.json (last - makes the push "visible") """ - from portolan_cli.push import push + from portolan_cli.sync.push import push upload_order: list[str] = [] async def mock_put_async(store: Any, key: str, data: Any, **kwargs: Any) -> None: upload_order.append(key) - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put_async): - with patch("portolan_cli.push.obs.get_async", new_callable=AsyncMock) as mock_get: + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_async): + with patch("portolan_cli.sync.push.obs.get_async", new_callable=AsyncMock) as mock_get: mock_get.side_effect = FileNotFoundError("Not found") push( @@ -441,15 +445,15 @@ class TestPushUploadsAllFiles: def test_per_collection_push_uploads_complete_structure(self, full_catalog: Path) -> None: """Per-collection push should upload complete STAC structure including catalog.json.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push uploaded_keys: set[str] = set() async def mock_put_async(store: Any, key: str, data: Any, **kwargs: Any) -> None: uploaded_keys.add(key) - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put_async): - with patch("portolan_cli.push.obs.get_async", new_callable=AsyncMock) as mock_get: + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_async): + with patch("portolan_cli.sync.push.obs.get_async", new_callable=AsyncMock) as mock_get: mock_get.side_effect = FileNotFoundError("Not found") push( @@ -473,7 +477,7 @@ async def mock_put_async(store: Any, key: str, data: Any, **kwargs: Any) -> None def test_push_all_uploads_complete_stac_structure(self, full_catalog: Path) -> None: """push_all_collections should upload complete STAC structure including catalog.json.""" - from portolan_cli.push import push_all_collections + from portolan_cli.sync.push import push_all_collections uploaded_keys: set[str] = set() @@ -484,9 +488,11 @@ def mock_put_sync(store: Any, key: str, data: Any, **kwargs: Any) -> None: # Sync version for _push_all_upload_catalog helper uploaded_keys.add(key) - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put_async): - with patch("portolan_cli.push.obs.put", side_effect=mock_put_sync): - with patch("portolan_cli.push.obs.get_async", new_callable=AsyncMock) as mock_get: + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_async): + with patch("portolan_cli.sync.push.obs.put", side_effect=mock_put_sync): + with patch( + "portolan_cli.sync.push.obs.get_async", new_callable=AsyncMock + ) as mock_get: mock_get.side_effect = FileNotFoundError("Not found") push_all_collections( diff --git a/tests/unit/test_json_geojson_detection.py b/tests/unit/test_json_geojson_detection.py index 732ad739..976d02e8 100644 --- a/tests/unit/test_json_geojson_detection.py +++ b/tests/unit/test_json_geojson_detection.py @@ -24,7 +24,7 @@ def test_json_with_geojson_content_is_detected_as_ready(self, fixtures_dir: Path This is the core fix for Issue #256: rec_centers.json contains a valid FeatureCollection but has .json extension instead of .geojson. """ - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory scan_path = fixtures_dir / "scan" / "json_geojson" result = scan_directory(scan_path) @@ -38,7 +38,7 @@ def test_json_with_geojson_content_is_detected_as_ready(self, fixtures_dir: Path def test_json_with_geojson_content_has_vector_format_type(self, fixtures_dir: Path) -> None: """A .json file with GeoJSON content should have VECTOR format type.""" - from portolan_cli.scan import FormatType, scan_directory + from portolan_cli.scan.core import FormatType, scan_directory scan_path = fixtures_dir / "scan" / "json_geojson" result = scan_directory(scan_path) @@ -50,7 +50,7 @@ def test_json_with_geojson_content_has_vector_format_type(self, fixtures_dir: Pa def test_json_with_geojson_content_has_json_extension(self, fixtures_dir: Path) -> None: """A .json file with GeoJSON content should keep .json extension.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory scan_path = fixtures_dir / "scan" / "json_geojson" result = scan_directory(scan_path) @@ -65,7 +65,7 @@ def test_plain_json_is_skipped(self, fixtures_dir: Path) -> None: config.json is a plain settings file, not GeoJSON. It should not be added to the ready list. """ - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory scan_path = fixtures_dir / "scan" / "json_geojson" result = scan_directory(scan_path) @@ -88,7 +88,7 @@ class TestJsonGeoJsonDetectionEdgeCases: def test_json_with_feature_type_is_detected(self, tmp_path: Path) -> None: """A .json file with Feature type (not FeatureCollection) is detected.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # Create a single Feature (not FeatureCollection) json_file = tmp_path / "single_feature.json" @@ -102,7 +102,7 @@ def test_json_with_feature_type_is_detected(self, tmp_path: Path) -> None: def test_json_with_geometry_type_is_detected(self, tmp_path: Path) -> None: """A .json file with bare geometry (Point, Polygon, etc.) is detected.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # Create a bare geometry (no Feature wrapper) json_file = tmp_path / "bare_point.json" @@ -114,7 +114,7 @@ def test_json_with_geometry_type_is_detected(self, tmp_path: Path) -> None: def test_json_with_multipolygon_is_detected(self, tmp_path: Path) -> None: """A .json file with MultiPolygon geometry is detected.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory json_file = tmp_path / "multipolygon.json" json_file.write_text( @@ -127,7 +127,7 @@ def test_json_with_multipolygon_is_detected(self, tmp_path: Path) -> None: def test_json_array_is_skipped(self, tmp_path: Path) -> None: """A .json file containing an array (not object) is skipped.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory json_file = tmp_path / "array.json" json_file.write_text("[1, 2, 3, 4]") @@ -138,7 +138,7 @@ def test_json_array_is_skipped(self, tmp_path: Path) -> None: def test_json_with_type_key_but_not_geojson_is_skipped(self, tmp_path: Path) -> None: """A .json with 'type' key but non-GeoJSON value is skipped.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory json_file = tmp_path / "not_geo.json" json_file.write_text('{"type": "something_else", "data": [1, 2, 3]}') @@ -152,7 +152,7 @@ def test_large_json_geojson_is_detected(self, tmp_path: Path) -> None: Detection reads only first 8KB, so GeoJSON type token must appear early. """ - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # Create a file with FeatureCollection type early, then padding json_file = tmp_path / "large.json" @@ -175,7 +175,7 @@ class TestJsonGeoJsonMixedDirectory: def test_mixed_json_and_geojson_both_detected(self, tmp_path: Path) -> None: """Both .json and .geojson files with GeoJSON content are detected.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # Create .geojson file geojson_file = tmp_path / "standard.geojson" @@ -194,7 +194,7 @@ def test_mixed_json_and_geojson_both_detected(self, tmp_path: Path) -> None: def test_json_geojson_and_plain_json_mixed(self, tmp_path: Path) -> None: """Directory with GeoJSON .json and plain .json handles both correctly.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # GeoJSON in .json geo_json = tmp_path / "places.json" @@ -226,7 +226,7 @@ def test_binary_file_with_json_extension_is_skipped(self, tmp_path: Path) -> Non This tests the UnicodeDecodeError handling path. Binary files cannot be decoded as UTF-8 and should be gracefully skipped. """ - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # Create a binary file with .json extension binary_json = tmp_path / "binary.json" @@ -249,7 +249,7 @@ def test_geojson_token_after_8kb_is_skipped(self, tmp_path: Path) -> None: If the type token appears later, the file is not detected as GeoJSON. This is a known limitation documented in the code. """ - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # Create a file with GeoJSON type token appearing after 8KB json_file = tmp_path / "late_token.json" @@ -266,7 +266,7 @@ def test_geojson_token_after_8kb_is_skipped(self, tmp_path: Path) -> None: def test_empty_json_file_is_flagged_as_issue(self, tmp_path: Path) -> None: """An empty .json file should be flagged as a zero-byte issue.""" - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory # Create an empty file empty_json = tmp_path / "empty.json" @@ -288,8 +288,8 @@ def test_stac_catalog_json_is_skipped_as_metadata(self, tmp_path: Path) -> None: This verifies that STAC filenames are handled before GeoJSON content inspection. """ - from portolan_cli.scan import scan_directory - from portolan_cli.scan_classify import FileCategory + from portolan_cli.scan.classify import FileCategory + from portolan_cli.scan.core import scan_directory # Create a STAC catalog file catalog_json = tmp_path / "catalog.json" @@ -303,8 +303,8 @@ def test_stac_catalog_json_is_skipped_as_metadata(self, tmp_path: Path) -> None: def test_stac_collection_json_is_skipped_as_metadata(self, tmp_path: Path) -> None: """STAC collection.json should be skipped as metadata.""" - from portolan_cli.scan import scan_directory - from portolan_cli.scan_classify import FileCategory + from portolan_cli.scan.classify import FileCategory + from portolan_cli.scan.core import scan_directory # Create a STAC collection file collection_json = tmp_path / "collection.json" @@ -318,7 +318,7 @@ def test_stac_collection_json_is_skipped_as_metadata(self, tmp_path: Path) -> No def test_json_with_null_bytes_is_skipped(self, tmp_path: Path) -> None: """A .json file with embedded null bytes should be skipped.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # Create a file with null bytes (corrupted JSON) corrupt_json = tmp_path / "corrupt.json" @@ -336,7 +336,7 @@ def test_json_with_bom_is_detected(self, tmp_path: Path) -> None: Some editors add a BOM (Byte Order Mark) to UTF-8 files. The detection should still work. """ - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # Create a GeoJSON file with UTF-8 BOM bom_json = tmp_path / "with_bom.json" diff --git a/tests/unit/test_nested_catalog_inference.py b/tests/unit/test_nested_catalog_inference.py index 98b5240b..2a912dc7 100644 --- a/tests/unit/test_nested_catalog_inference.py +++ b/tests/unit/test_nested_catalog_inference.py @@ -150,7 +150,7 @@ def test_empty_gdb_directory_uses_suffix_fallback(self, tmp_path: Path) -> None: - Manually created .gdb folders """ from portolan_cli.add import infer_nested_collection_id - from portolan_cli.scan_detect import is_filegdb + from portolan_cli.scan.detect import is_filegdb catalog_root = tmp_path gdb_path = tmp_path / "ocha" / "incomplete.gdb" diff --git a/tests/unit/test_partitioning.py b/tests/unit/test_partitioning.py index a6e01349..4432f613 100644 --- a/tests/unit/test_partitioning.py +++ b/tests/unit/test_partitioning.py @@ -243,7 +243,7 @@ def test_transform_adds_glob_field_to_pattern_assets(self) -> None: """_transform_collection_glob_assets adds portolan:glob to glob-pattern assets.""" import json - from portolan_cli.push import _transform_collection_glob_assets + from portolan_cli.sync.push import _transform_collection_glob_assets collection_json = { "type": "Collection", @@ -282,7 +282,7 @@ def test_transform_overwrites_existing_glob_for_sync(self) -> None: """_transform_collection_glob_assets overwrites existing globs to keep in sync.""" import json - from portolan_cli.push import _transform_collection_glob_assets + from portolan_cli.sync.push import _transform_collection_glob_assets collection_json = { "type": "Collection", @@ -311,7 +311,7 @@ def test_transform_returns_unchanged_for_no_globs(self) -> None: """_transform_collection_glob_assets returns unchanged content when no globs.""" import json - from portolan_cli.push import _transform_collection_glob_assets + from portolan_cli.sync.push import _transform_collection_glob_assets collection_json = { "type": "Collection", @@ -334,7 +334,7 @@ def test_transform_returns_unchanged_for_no_globs(self) -> None: @pytest.mark.unit def test_transform_handles_invalid_json(self) -> None: """_transform_collection_glob_assets returns unchanged for invalid JSON.""" - from portolan_cli.push import _transform_collection_glob_assets + from portolan_cli.sync.push import _transform_collection_glob_assets content = b"not valid json {" result = _transform_collection_glob_assets(content, "s3://bucket/catalog", "buildings") @@ -674,7 +674,7 @@ def test_transform_adds_both_glob_fields(self) -> None: """_transform_collection_glob_assets adds both partition:glob and portolan:glob.""" import json - from portolan_cli.push import _transform_collection_glob_assets + from portolan_cli.sync.push import _transform_collection_glob_assets collection_json = { "type": "Collection", @@ -703,7 +703,7 @@ def test_transform_syncs_both_glob_fields(self) -> None: """_transform_collection_glob_assets syncs both partition:glob and portolan:glob.""" import json - from portolan_cli.push import _transform_collection_glob_assets + from portolan_cli.sync.push import _transform_collection_glob_assets collection_json = { "type": "Collection", diff --git a/tests/unit/test_partitioning_arbitrary.py b/tests/unit/test_partitioning_arbitrary.py index 84f3222d..46394a68 100644 --- a/tests/unit/test_partitioning_arbitrary.py +++ b/tests/unit/test_partitioning_arbitrary.py @@ -366,7 +366,7 @@ def test_transform_glob_assets_arbitrary_column(self) -> None: """_transform_collection_glob_assets handles arbitrary column names in href.""" import json - from portolan_cli.push import _transform_collection_glob_assets + from portolan_cli.sync.push import _transform_collection_glob_assets collection_json = { "type": "Collection", @@ -394,7 +394,7 @@ def test_transform_glob_assets_multilevel(self) -> None: """_transform_collection_glob_assets handles multi-level partition paths.""" import json - from portolan_cli.push import _transform_collection_glob_assets + from portolan_cli.sync.push import _transform_collection_glob_assets collection_json = { "type": "Collection", diff --git a/tests/unit/test_pmtiles.py b/tests/unit/test_pmtiles.py index cf38bbba..a82431c0 100644 --- a/tests/unit/test_pmtiles.py +++ b/tests/unit/test_pmtiles.py @@ -21,7 +21,7 @@ class TestPMTilesErrors: @pytest.mark.unit def test_pmtiles_not_available_error_message(self) -> None: """Error message includes installation instructions.""" - from portolan_cli.pmtiles import PMTilesNotAvailableError + from portolan_cli.viz.pmtiles import PMTilesNotAvailableError error = PMTilesNotAvailableError() assert "gpio-pmtiles" in str(error) @@ -30,7 +30,7 @@ def test_pmtiles_not_available_error_message(self) -> None: @pytest.mark.unit def test_tippecanoe_not_found_error_message(self) -> None: """Error message includes installation instructions.""" - from portolan_cli.pmtiles import TippecanoeNotFoundError + from portolan_cli.viz.pmtiles import TippecanoeNotFoundError error = TippecanoeNotFoundError() assert "tippecanoe" in str(error) @@ -39,7 +39,7 @@ def test_tippecanoe_not_found_error_message(self) -> None: @pytest.mark.unit def test_pmtiles_generation_error_includes_source(self) -> None: """Error includes source path and original error.""" - from portolan_cli.pmtiles import PMTilesGenerationError + from portolan_cli.viz.pmtiles import PMTilesGenerationError original = ValueError("test error") error = PMTilesGenerationError("/path/to/file.parquet", original) @@ -54,7 +54,7 @@ class TestPMTilesResult: @pytest.mark.unit def test_total_counts_all_results(self) -> None: """Total property counts generated, skipped, and failed.""" - from portolan_cli.pmtiles import PMTilesResult + from portolan_cli.viz.pmtiles import PMTilesResult result = PMTilesResult( generated=[Path("a.pmtiles"), Path("b.pmtiles")], @@ -67,7 +67,7 @@ def test_total_counts_all_results(self) -> None: @pytest.mark.unit def test_success_true_when_no_failures(self) -> None: """Success is True when failed list is empty.""" - from portolan_cli.pmtiles import PMTilesResult + from portolan_cli.viz.pmtiles import PMTilesResult result = PMTilesResult( generated=[Path("a.pmtiles")], @@ -80,7 +80,7 @@ def test_success_true_when_no_failures(self) -> None: @pytest.mark.unit def test_success_false_when_failures_exist(self) -> None: """Success is False when failed list has items.""" - from portolan_cli.pmtiles import PMTilesResult + from portolan_cli.viz.pmtiles import PMTilesResult result = PMTilesResult( generated=[], @@ -97,13 +97,13 @@ class TestCheckPMTilesAvailable: @pytest.mark.unit def test_raises_when_gpio_pmtiles_not_installed(self) -> None: """Raises PMTilesNotAvailableError when gpio-pmtiles missing.""" - from portolan_cli.pmtiles import ( + from portolan_cli.viz.pmtiles import ( PMTilesNotAvailableError, check_pmtiles_available, ) with patch.dict("sys.modules", {"gpio_pmtiles": None}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): # Mock the import to raise ImportError with patch( "builtins.__import__", @@ -115,7 +115,7 @@ def test_raises_when_gpio_pmtiles_not_installed(self) -> None: @pytest.mark.unit def test_raises_when_tippecanoe_not_in_path(self) -> None: """Raises TippecanoeNotFoundError when tippecanoe not in PATH.""" - from portolan_cli.pmtiles import ( + from portolan_cli.viz.pmtiles import ( TippecanoeNotFoundError, check_pmtiles_available, ) @@ -123,7 +123,7 @@ def test_raises_when_tippecanoe_not_in_path(self) -> None: # Mock gpio_pmtiles as available but tippecanoe missing mock_module = MagicMock() with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value=None): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value=None): with pytest.raises(TippecanoeNotFoundError): check_pmtiles_available() @@ -134,7 +134,7 @@ class TestFindGeoparquetAssets: @pytest.mark.unit def test_finds_parquet_assets_by_media_type(self, tmp_path: Path) -> None: """Finds assets with application/vnd.apache.parquet type.""" - from portolan_cli.pmtiles import _find_geoparquet_assets + from portolan_cli.viz.pmtiles import _find_geoparquet_assets collection_dir = tmp_path / "collection" collection_dir.mkdir() @@ -161,7 +161,7 @@ def test_finds_parquet_assets_by_media_type(self, tmp_path: Path) -> None: @pytest.mark.unit def test_ignores_stac_items_parquet(self, tmp_path: Path) -> None: """Ignores parquet files with stac-items role.""" - from portolan_cli.pmtiles import _find_geoparquet_assets + from portolan_cli.viz.pmtiles import _find_geoparquet_assets collection_dir = tmp_path / "collection" collection_dir.mkdir() @@ -186,7 +186,7 @@ def test_ignores_stac_items_parquet(self, tmp_path: Path) -> None: @pytest.mark.unit def test_returns_empty_for_missing_collection_json(self, tmp_path: Path) -> None: """Returns empty list when collection.json doesn't exist.""" - from portolan_cli.pmtiles import _find_geoparquet_assets + from portolan_cli.viz.pmtiles import _find_geoparquet_assets assets = _find_geoparquet_assets(tmp_path) @@ -199,7 +199,7 @@ class TestShouldGenerate: @pytest.mark.unit def test_returns_true_when_force(self, tmp_path: Path) -> None: """Returns True when force=True regardless of file state.""" - from portolan_cli.pmtiles import _should_generate + from portolan_cli.viz.pmtiles import _should_generate parquet = tmp_path / "data.parquet" pmtiles = tmp_path / "data.pmtiles" @@ -211,7 +211,7 @@ def test_returns_true_when_force(self, tmp_path: Path) -> None: @pytest.mark.unit def test_returns_true_when_pmtiles_missing(self, tmp_path: Path) -> None: """Returns True when PMTiles file doesn't exist.""" - from portolan_cli.pmtiles import _should_generate + from portolan_cli.viz.pmtiles import _should_generate parquet = tmp_path / "data.parquet" pmtiles = tmp_path / "data.pmtiles" @@ -224,7 +224,7 @@ def test_returns_false_when_pmtiles_newer(self, tmp_path: Path) -> None: """Returns False when PMTiles is newer than parquet.""" import time - from portolan_cli.pmtiles import _should_generate + from portolan_cli.viz.pmtiles import _should_generate parquet = tmp_path / "data.parquet" pmtiles = tmp_path / "data.pmtiles" @@ -242,7 +242,7 @@ class TestAddPMTilesAssetToCollection: @pytest.mark.unit def test_adds_pmtiles_asset_with_correct_role(self, tmp_path: Path) -> None: """Adds PMTiles asset with role=['overview'].""" - from portolan_cli.pmtiles import add_pmtiles_asset_to_collection + from portolan_cli.viz.pmtiles import add_pmtiles_asset_to_collection collection_dir = tmp_path / "collection" collection_dir.mkdir() @@ -270,7 +270,7 @@ def test_adds_pmtiles_asset_with_correct_role(self, tmp_path: Path) -> None: @pytest.mark.unit def test_idempotent_does_not_duplicate(self, tmp_path: Path) -> None: """Calling twice doesn't create duplicate assets.""" - from portolan_cli.pmtiles import add_pmtiles_asset_to_collection + from portolan_cli.viz.pmtiles import add_pmtiles_asset_to_collection collection_dir = tmp_path / "collection" collection_dir.mkdir() @@ -310,7 +310,7 @@ def _collection(tmp_path: Path) -> Path: @pytest.mark.unit def test_adds_web_map_links_link_and_extension(self, tmp_path: Path) -> None: """Adds rel='pmtiles' link with media type, layers, and extension.""" - from portolan_cli.pmtiles import ( + from portolan_cli.viz.pmtiles import ( WEB_MAP_LINKS_EXTENSION, add_pmtiles_link_to_collection, ) @@ -330,7 +330,7 @@ def test_adds_web_map_links_link_and_extension(self, tmp_path: Path) -> None: @pytest.mark.unit def test_idempotent_does_not_duplicate_link_or_extension(self, tmp_path: Path) -> None: """Calling twice keeps exactly one link and one extension entry.""" - from portolan_cli.pmtiles import ( + from portolan_cli.viz.pmtiles import ( WEB_MAP_LINKS_EXTENSION, add_pmtiles_link_to_collection, ) @@ -348,7 +348,7 @@ def test_idempotent_does_not_duplicate_link_or_extension(self, tmp_path: Path) - @pytest.mark.unit def test_realigns_stale_layers(self, tmp_path: Path) -> None: """A pre-existing link with stale pmtiles:layers is updated in place.""" - from portolan_cli.pmtiles import add_pmtiles_link_to_collection + from portolan_cli.viz.pmtiles import add_pmtiles_link_to_collection collection_dir = self._collection(tmp_path) add_pmtiles_link_to_collection(collection_dir, "./data.pmtiles", layers=["old"]) @@ -396,8 +396,8 @@ def _write_versions(collection_dir: Path) -> None: @pytest.mark.unit def test_asset_tracked_with_checksum_size_mtime(self, tmp_path: Path) -> None: """A generated thumbnail is added to versions.json as a full asset.""" - from portolan_cli.pmtiles import _track_generated_assets_in_versions from portolan_cli.versions import read_versions + from portolan_cli.viz.pmtiles import _track_generated_assets_in_versions collection_dir = tmp_path / "demographics" collection_dir.mkdir() @@ -430,8 +430,8 @@ def test_asset_tracked_with_checksum_size_mtime(self, tmp_path: Path) -> None: @pytest.mark.unit def test_pmtiles_and_thumbnail_share_one_version(self, tmp_path: Path) -> None: """PMTiles + thumbnail tracked together land in a SINGLE version (Issue #519).""" - from portolan_cli.pmtiles import _track_generated_assets_in_versions from portolan_cli.versions import read_versions + from portolan_cli.viz.pmtiles import _track_generated_assets_in_versions collection_dir = tmp_path / "demographics" collection_dir.mkdir() @@ -457,8 +457,8 @@ def test_pmtiles_and_thumbnail_share_one_version(self, tmp_path: Path) -> None: @pytest.mark.unit def test_only_if_missing_skips_already_tracked(self, tmp_path: Path) -> None: """only_if_missing creates no version when every asset is already tracked.""" - from portolan_cli.pmtiles import _track_generated_assets_in_versions from portolan_cli.versions import read_versions + from portolan_cli.viz.pmtiles import _track_generated_assets_in_versions collection_dir = tmp_path / "demographics" collection_dir.mkdir() @@ -479,8 +479,8 @@ def test_only_if_missing_skips_already_tracked(self, tmp_path: Path) -> None: @pytest.mark.unit def test_creates_versions_file_when_missing(self, tmp_path: Path) -> None: """First-ever version is created at 1.0.0 if versions.json is absent.""" - from portolan_cli.pmtiles import _track_generated_assets_in_versions from portolan_cli.versions import read_versions + from portolan_cli.viz.pmtiles import _track_generated_assets_in_versions collection_dir = tmp_path / "demographics" collection_dir.mkdir() @@ -499,7 +499,7 @@ def test_creates_versions_file_when_missing(self, tmp_path: Path) -> None: @pytest.mark.unit def test_raises_when_asset_missing(self, tmp_path: Path) -> None: """A missing asset file is a hard error (no phantom asset).""" - from portolan_cli.pmtiles import _track_generated_assets_in_versions + from portolan_cli.viz.pmtiles import _track_generated_assets_in_versions collection_dir = tmp_path / "demographics" collection_dir.mkdir() @@ -517,7 +517,7 @@ class TestGeneratePMTiles: @pytest.mark.unit def test_passes_all_parameters_to_gpio_pmtiles(self, tmp_path: Path) -> None: """All parameters are passed through to create_pmtiles_from_geoparquet.""" - from portolan_cli.pmtiles import generate_pmtiles + from portolan_cli.viz.pmtiles import generate_pmtiles parquet = tmp_path / "data.parquet" pmtiles = tmp_path / "data.pmtiles" @@ -528,7 +528,7 @@ def test_passes_all_parameters_to_gpio_pmtiles(self, tmp_path: Path) -> None: mock_module.create_pmtiles_from_geoparquet = mock_create with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): generate_pmtiles( parquet, pmtiles, @@ -560,7 +560,7 @@ def test_passes_all_parameters_to_gpio_pmtiles(self, tmp_path: Path) -> None: @pytest.mark.unit def test_default_precision_is_six(self, tmp_path: Path) -> None: """Default precision value is 6.""" - from portolan_cli.pmtiles import generate_pmtiles + from portolan_cli.viz.pmtiles import generate_pmtiles parquet = tmp_path / "data.parquet" pmtiles = tmp_path / "data.pmtiles" @@ -571,7 +571,7 @@ def test_default_precision_is_six(self, tmp_path: Path) -> None: mock_module.create_pmtiles_from_geoparquet = mock_create with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): generate_pmtiles(parquet, pmtiles) call_kwargs = mock_create.call_args[1] @@ -584,7 +584,7 @@ class TestGeneratePMTilesForCollection: @pytest.mark.unit def test_returns_empty_result_for_no_geoparquet(self, tmp_path: Path) -> None: """Returns empty result when collection has no GeoParquet assets.""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection collection_dir = tmp_path / "collection" collection_dir.mkdir() @@ -595,7 +595,7 @@ def test_returns_empty_result_for_no_geoparquet(self, tmp_path: Path) -> None: # Mock dependencies as available mock_module = MagicMock() with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): result = generate_pmtiles_for_collection(collection_dir, tmp_path) assert result.total == 0 @@ -604,7 +604,7 @@ def test_returns_empty_result_for_no_geoparquet(self, tmp_path: Path) -> None: @pytest.mark.unit def test_passes_all_parameters_to_generate_pmtiles(self, tmp_path: Path) -> None: """All parameters are forwarded to generate_pmtiles.""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection collection_dir = tmp_path / "collection" collection_dir.mkdir() @@ -642,8 +642,8 @@ def test_passes_all_parameters_to_generate_pmtiles(self, tmp_path: Path) -> None mock_module = MagicMock() with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): - with patch("portolan_cli.pmtiles.generate_pmtiles", mock_generate): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.generate_pmtiles", mock_generate): generate_pmtiles_for_collection( collection_dir, tmp_path, @@ -679,7 +679,7 @@ def test_cleans_up_partial_file_on_failure(self, tmp_path: Path) -> None: partial output file. This test verifies that such files are cleaned up to prevent phantom assets in versions.json on subsequent add runs. """ - from portolan_cli.pmtiles import ( + from portolan_cli.viz.pmtiles import ( PMTilesGenerationError, generate_pmtiles_for_collection, ) @@ -710,8 +710,8 @@ def mock_generate_raises(*args: object, **kwargs: object) -> None: mock_module = MagicMock() with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): - with patch("portolan_cli.pmtiles.generate_pmtiles", mock_generate_raises): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.generate_pmtiles", mock_generate_raises): result = generate_pmtiles_for_collection(collection_dir, tmp_path) # Verify failure was recorded @@ -727,7 +727,7 @@ def mock_generate_raises(*args: object, **kwargs: object) -> None: @pytest.mark.unit def test_cleans_up_partial_file_on_unexpected_error(self, tmp_path: Path) -> None: """Partial PMTiles file is deleted on unexpected errors too (Issue #385).""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection collection_dir = tmp_path / "collection" collection_dir.mkdir() @@ -753,8 +753,8 @@ def mock_generate_unexpected(*args: object, **kwargs: object) -> None: mock_module = MagicMock() with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): - with patch("portolan_cli.pmtiles.generate_pmtiles", mock_generate_unexpected): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.generate_pmtiles", mock_generate_unexpected): result = generate_pmtiles_for_collection(collection_dir, tmp_path) assert len(result.failed) == 1 @@ -767,7 +767,7 @@ def test_cleans_up_partial_file_on_keyboard_interrupt(self, tmp_path: Path) -> N KeyboardInterrupt inherits from BaseException, not Exception. The finally block ensures cleanup even when user hits Ctrl+C. """ - from portolan_cli.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection collection_dir = tmp_path / "collection" collection_dir.mkdir() @@ -792,8 +792,8 @@ def mock_generate_interrupted(*args: object, **kwargs: object) -> None: mock_module = MagicMock() with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): - with patch("portolan_cli.pmtiles.generate_pmtiles", mock_generate_interrupted): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.generate_pmtiles", mock_generate_interrupted): with pytest.raises(KeyboardInterrupt): generate_pmtiles_for_collection(collection_dir, tmp_path) @@ -813,8 +813,8 @@ def test_generated_thumbnail_tracked_in_versions(self, tmp_path: Path) -> None: PMTiles and thumbnail each bump their own version (two snapshots for one side-step); they must now share a single version snapshot. """ - from portolan_cli.pmtiles import generate_pmtiles_for_collection from portolan_cli.versions import read_versions + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection collection_dir = tmp_path / "demographics" collection_dir.mkdir() @@ -858,10 +858,10 @@ def mock_thumbnail(*args: object, **kwargs: object) -> Path: mock_module = MagicMock() with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): - with patch("portolan_cli.pmtiles.generate_pmtiles", mock_generate): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.generate_pmtiles", mock_generate): with patch( - "portolan_cli.pmtiles.generate_vector_thumbnail", + "portolan_cli.viz.pmtiles.generate_vector_thumbnail", mock_thumbnail, ): result = generate_pmtiles_for_collection(collection_dir, tmp_path) @@ -887,7 +887,7 @@ def mock_thumbnail(*args: object, **kwargs: object) -> Path: @pytest.mark.unit def test_generate_emits_pmtiles_link(self, tmp_path: Path) -> None: """The generate path emits the rel='pmtiles' web-map-links link (#569).""" - from portolan_cli.pmtiles import ( + from portolan_cli.viz.pmtiles import ( WEB_MAP_LINKS_EXTENSION, generate_pmtiles_for_collection, ) @@ -915,8 +915,8 @@ def mock_generate(*args: object, **kwargs: object) -> None: mock_module = MagicMock() with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): - with patch("portolan_cli.pmtiles.generate_pmtiles", mock_generate): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.generate_pmtiles", mock_generate): generate_pmtiles_for_collection(collection_dir, tmp_path) updated = json.loads((collection_dir / "collection.json").read_text()) @@ -936,8 +936,8 @@ def test_skip_path_backfills_untracked_thumbnail(self, tmp_path: Path) -> None: from versions.json) must be backfilled — without forcing regeneration — and in exactly one version bump. """ - from portolan_cli.pmtiles import generate_pmtiles_for_collection from portolan_cli.versions import read_versions + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection collection_dir = tmp_path / "demographics" collection_dir.mkdir() @@ -983,7 +983,7 @@ def test_skip_path_backfills_untracked_thumbnail(self, tmp_path: Path) -> None: mock_module = MagicMock() with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): result = generate_pmtiles_for_collection(collection_dir, tmp_path) assert pmtiles in result.skipped, "Up-to-date PMTiles should be skipped, not regenerated" @@ -1005,7 +1005,7 @@ def test_skip_path_backfills_untracked_thumbnail(self, tmp_path: Path) -> None: # Idempotent: a second run with everything tracked creates NO new version. with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): generate_pmtiles_for_collection(collection_dir, tmp_path) versions_after = read_versions(collection_dir / "versions.json") assert len(versions_after.versions) == 2, ( @@ -1054,9 +1054,9 @@ def test_thumbnail_disabled_tracks_only_pmtiles(self, tmp_path: Path) -> None: """When thumbnails are disabled, only the PMTiles is tracked (one version).""" from unittest.mock import patch as _patch - from portolan_cli.pmtiles import generate_pmtiles_for_collection - from portolan_cli.thumbnail import ThumbnailConfig from portolan_cli.versions import read_versions + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection + from portolan_cli.viz.thumbnail import ThumbnailConfig collection_dir = self._fresh_collection(tmp_path / "demographics") pmtiles_path = collection_dir / "data.pmtiles" @@ -1066,10 +1066,10 @@ def mock_generate(*args: object, **kwargs: object) -> None: mock_module = MagicMock() with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): - with patch("portolan_cli.pmtiles.generate_pmtiles", mock_generate): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.generate_pmtiles", mock_generate): with _patch( - "portolan_cli.pmtiles.get_thumbnail_config", + "portolan_cli.viz.pmtiles.get_thumbnail_config", return_value=ThumbnailConfig(enabled=False), ): result = generate_pmtiles_for_collection(collection_dir, tmp_path) @@ -1086,8 +1086,8 @@ def mock_generate(*args: object, **kwargs: object) -> None: @pytest.mark.unit def test_thumbnail_render_failure_tracks_only_pmtiles(self, tmp_path: Path) -> None: """A failed thumbnail render must not block PMTiles tracking (Issue #13).""" - from portolan_cli.pmtiles import generate_pmtiles_for_collection from portolan_cli.versions import read_versions + from portolan_cli.viz.pmtiles import generate_pmtiles_for_collection collection_dir = self._fresh_collection(tmp_path / "demographics") pmtiles_path = collection_dir / "data.pmtiles" @@ -1100,9 +1100,9 @@ def boom(*args: object, **kwargs: object) -> None: mock_module = MagicMock() with patch.dict("sys.modules", {"gpio_pmtiles": mock_module}): - with patch("portolan_cli.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): - with patch("portolan_cli.pmtiles.generate_pmtiles", mock_generate): - with patch("portolan_cli.pmtiles.generate_vector_thumbnail", boom): + with patch("portolan_cli.viz.pmtiles.shutil.which", return_value="/usr/bin/tippecanoe"): + with patch("portolan_cli.viz.pmtiles.generate_pmtiles", mock_generate): + with patch("portolan_cli.viz.pmtiles.generate_vector_thumbnail", boom): result = generate_pmtiles_for_collection(collection_dir, tmp_path) assert pmtiles_path in result.generated, "PMTiles must succeed despite thumbnail failure" @@ -1151,7 +1151,7 @@ class TestGetPMTilesSettings: @pytest.mark.unit def test_defaults_when_no_config(self, tmp_path: Path) -> None: - from portolan_cli.pmtiles import get_pmtiles_settings + from portolan_cli.viz.pmtiles import get_pmtiles_settings coll_path = _make_collection(tmp_path, "roads") settings = get_pmtiles_settings(tmp_path, "roads", coll_path) @@ -1163,7 +1163,7 @@ def test_defaults_when_no_config(self, tmp_path: Path) -> None: @pytest.mark.unit def test_reads_config_values(self, tmp_path: Path) -> None: - from portolan_cli.pmtiles import get_pmtiles_settings + from portolan_cli.viz.pmtiles import get_pmtiles_settings config = ( "pmtiles:\n" @@ -1188,10 +1188,10 @@ class TestGenerateOrSuggestPMTiles: @pytest.mark.unit def test_skips_when_disabled_and_not_flagged(self, tmp_path: Path) -> None: - from portolan_cli.pmtiles import generate_or_suggest_pmtiles + from portolan_cli.viz.pmtiles import generate_or_suggest_pmtiles _make_collection(tmp_path, "roads") - with patch("portolan_cli.pmtiles.generate_pmtiles_for_collection") as mock_gen: + with patch("portolan_cli.viz.pmtiles.generate_pmtiles_for_collection") as mock_gen: generate_or_suggest_pmtiles( tmp_path, {"roads"}, @@ -1203,11 +1203,11 @@ def test_skips_when_disabled_and_not_flagged(self, tmp_path: Path) -> None: @pytest.mark.unit def test_generates_when_flagged(self, tmp_path: Path) -> None: - from portolan_cli.pmtiles import PMTilesResult, generate_or_suggest_pmtiles + from portolan_cli.viz.pmtiles import PMTilesResult, generate_or_suggest_pmtiles coll_path = _make_collection(tmp_path, "roads") with patch( - "portolan_cli.pmtiles.generate_pmtiles_for_collection", + "portolan_cli.viz.pmtiles.generate_pmtiles_for_collection", return_value=PMTilesResult(), ) as mock_gen: generate_or_suggest_pmtiles( @@ -1222,14 +1222,14 @@ def test_generates_when_flagged(self, tmp_path: Path) -> None: @pytest.mark.unit def test_explicit_flag_unavailable_exits(self, tmp_path: Path) -> None: - from portolan_cli.pmtiles import ( + from portolan_cli.viz.pmtiles import ( PMTilesNotAvailableError, generate_or_suggest_pmtiles, ) _make_collection(tmp_path, "roads") with patch( - "portolan_cli.pmtiles.generate_pmtiles_for_collection", + "portolan_cli.viz.pmtiles.generate_pmtiles_for_collection", side_effect=PMTilesNotAvailableError(), ): with pytest.raises(SystemExit): @@ -1244,14 +1244,14 @@ def test_explicit_flag_unavailable_exits(self, tmp_path: Path) -> None: @pytest.mark.unit def test_auto_unavailable_does_not_exit(self, tmp_path: Path) -> None: """pmtiles.enabled but package missing warns instead of exiting (auto path).""" - from portolan_cli.pmtiles import ( + from portolan_cli.viz.pmtiles import ( PMTilesNotAvailableError, generate_or_suggest_pmtiles, ) _make_collection(tmp_path, "roads", "pmtiles:\n enabled: true\n") with patch( - "portolan_cli.pmtiles.generate_pmtiles_for_collection", + "portolan_cli.viz.pmtiles.generate_pmtiles_for_collection", side_effect=PMTilesNotAvailableError(), ): # Must not raise SystemExit on the auto path. @@ -1265,10 +1265,10 @@ def test_auto_unavailable_does_not_exit(self, tmp_path: Path) -> None: @pytest.mark.unit def test_missing_collection_json_skipped(self, tmp_path: Path) -> None: - from portolan_cli.pmtiles import generate_or_suggest_pmtiles + from portolan_cli.viz.pmtiles import generate_or_suggest_pmtiles (tmp_path / "catalog.json").write_text("{}") - with patch("portolan_cli.pmtiles.generate_pmtiles_for_collection") as mock_gen: + with patch("portolan_cli.viz.pmtiles.generate_pmtiles_for_collection") as mock_gen: generate_or_suggest_pmtiles( tmp_path, {"ghost"}, diff --git a/tests/unit/test_pull.py b/tests/unit/test_pull.py index 66ce720b..81c8c929 100644 --- a/tests/unit/test_pull.py +++ b/tests/unit/test_pull.py @@ -223,7 +223,7 @@ class TestPullResult: @pytest.mark.unit def test_pull_result_success(self) -> None: """PullResult should track successful pulls.""" - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult result = PullResult( success=True, @@ -244,7 +244,7 @@ def test_pull_result_success(self) -> None: @pytest.mark.unit def test_pull_result_with_uncommitted_changes(self) -> None: """PullResult should track uncommitted changes that blocked pull.""" - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult result = PullResult( success=False, @@ -261,7 +261,7 @@ def test_pull_result_with_uncommitted_changes(self) -> None: @pytest.mark.unit def test_pull_result_already_up_to_date(self) -> None: """PullResult should indicate when already up to date.""" - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult result = PullResult( success=True, @@ -288,7 +288,7 @@ class TestUncommittedChangeDetection: @pytest.mark.unit def test_detect_modified_file(self, catalog_with_versions: Path) -> None: """Should detect when local file differs from versions.json checksum.""" - from portolan_cli.pull import detect_uncommitted_changes + from portolan_cli.sync.pull import detect_uncommitted_changes # Modify the local file (different content = different checksum) data_file = catalog_with_versions / "test-collection" / "data.parquet" @@ -304,11 +304,11 @@ def test_detect_modified_file(self, catalog_with_versions: Path) -> None: @pytest.mark.unit def test_no_changes_when_file_matches(self, catalog_with_versions: Path) -> None: """Should return empty list when files match versions.json.""" - from portolan_cli.pull import detect_uncommitted_changes + from portolan_cli.sync.pull import detect_uncommitted_changes # The fixture creates matching file, but we need correct checksum # For this test, mock the checksum comparison - with patch("portolan_cli.pull.compute_checksum") as mock_checksum: + with patch("portolan_cli.sync.pull.compute_checksum") as mock_checksum: mock_checksum.return_value = "abc123" # Matches versions.json changes = detect_uncommitted_changes( @@ -321,7 +321,7 @@ def test_no_changes_when_file_matches(self, catalog_with_versions: Path) -> None @pytest.mark.unit def test_mtime_fast_path_skips_checksum(self, tmp_path: Path) -> None: """Should skip checksum when mtime and size match (ADR-0017 fast path).""" - from portolan_cli.pull import detect_uncommitted_changes + from portolan_cli.sync.pull import detect_uncommitted_changes catalog_root = tmp_path / "catalog" catalog_root.mkdir() @@ -361,7 +361,7 @@ def test_mtime_fast_path_skips_checksum(self, tmp_path: Path) -> None: } (portolan_dir / "versions.json").write_text(json.dumps(versions_data, indent=2)) - with patch("portolan_cli.pull.compute_checksum") as mock_checksum: + with patch("portolan_cli.sync.pull.compute_checksum") as mock_checksum: changes = detect_uncommitted_changes( catalog_root=catalog_root, collection="test", @@ -374,7 +374,7 @@ def test_mtime_fast_path_skips_checksum(self, tmp_path: Path) -> None: @pytest.mark.unit def test_mtime_mismatch_triggers_checksum(self, tmp_path: Path) -> None: """Should compute checksum when mtime differs from recorded value.""" - from portolan_cli.pull import detect_uncommitted_changes + from portolan_cli.sync.pull import detect_uncommitted_changes catalog_root = tmp_path / "catalog" catalog_root.mkdir() @@ -413,7 +413,7 @@ def test_mtime_mismatch_triggers_checksum(self, tmp_path: Path) -> None: } (portolan_dir / "versions.json").write_text(json.dumps(versions_data, indent=2)) - with patch("portolan_cli.pull.compute_checksum") as mock_checksum: + with patch("portolan_cli.sync.pull.compute_checksum") as mock_checksum: mock_checksum.return_value = "abc123" # Same checksum changes = detect_uncommitted_changes( @@ -428,7 +428,7 @@ def test_mtime_mismatch_triggers_checksum(self, tmp_path: Path) -> None: @pytest.mark.unit def test_size_mismatch_triggers_checksum(self, tmp_path: Path) -> None: """Should compute checksum when size differs from recorded value.""" - from portolan_cli.pull import detect_uncommitted_changes + from portolan_cli.sync.pull import detect_uncommitted_changes catalog_root = tmp_path / "catalog" catalog_root.mkdir() @@ -468,7 +468,7 @@ def test_size_mismatch_triggers_checksum(self, tmp_path: Path) -> None: } (portolan_dir / "versions.json").write_text(json.dumps(versions_data, indent=2)) - with patch("portolan_cli.pull.compute_checksum") as mock_checksum: + with patch("portolan_cli.sync.pull.compute_checksum") as mock_checksum: mock_checksum.return_value = "different_checksum" changes = detect_uncommitted_changes( @@ -483,7 +483,7 @@ def test_size_mismatch_triggers_checksum(self, tmp_path: Path) -> None: @pytest.mark.unit def test_no_mtime_recorded_falls_to_checksum(self, tmp_path: Path) -> None: """Should compute checksum when mtime is not recorded in versions.json.""" - from portolan_cli.pull import detect_uncommitted_changes + from portolan_cli.sync.pull import detect_uncommitted_changes catalog_root = tmp_path / "catalog" catalog_root.mkdir() @@ -522,7 +522,7 @@ def test_no_mtime_recorded_falls_to_checksum(self, tmp_path: Path) -> None: } (portolan_dir / "versions.json").write_text(json.dumps(versions_data, indent=2)) - with patch("portolan_cli.pull.compute_checksum") as mock_checksum: + with patch("portolan_cli.sync.pull.compute_checksum") as mock_checksum: mock_checksum.return_value = "abc123" # Matches changes = detect_uncommitted_changes( @@ -537,7 +537,7 @@ def test_no_mtime_recorded_falls_to_checksum(self, tmp_path: Path) -> None: @pytest.mark.unit def test_returns_empty_for_no_versions_file(self, tmp_path: Path) -> None: """Should return empty list when versions.json doesn't exist.""" - from portolan_cli.pull import detect_uncommitted_changes + from portolan_cli.sync.pull import detect_uncommitted_changes catalog_root = tmp_path / "catalog" catalog_root.mkdir() @@ -554,7 +554,7 @@ def test_returns_empty_for_no_versions_file(self, tmp_path: Path) -> None: @pytest.mark.unit def test_returns_empty_for_empty_versions(self, tmp_path: Path) -> None: """Should return empty list when versions.json has no versions.""" - from portolan_cli.pull import detect_uncommitted_changes + from portolan_cli.sync.pull import detect_uncommitted_changes catalog_root = tmp_path / "catalog" catalog_root.mkdir() @@ -580,7 +580,7 @@ def test_returns_empty_for_empty_versions(self, tmp_path: Path) -> None: @pytest.mark.unit def test_detect_missing_file(self, catalog_with_versions: Path) -> None: """Should detect when expected file is missing.""" - from portolan_cli.pull import detect_uncommitted_changes + from portolan_cli.sync.pull import detect_uncommitted_changes # Delete the data file data_file = catalog_with_versions / "test-collection" / "data.parquet" @@ -596,14 +596,14 @@ def test_detect_missing_file(self, catalog_with_versions: Path) -> None: @pytest.mark.unit def test_detect_new_untracked_file(self, catalog_with_versions: Path) -> None: """Should detect new files not in versions.json (like git status).""" - from portolan_cli.pull import detect_uncommitted_changes + from portolan_cli.sync.pull import detect_uncommitted_changes # Create a new file not tracked in versions.json new_file = catalog_with_versions / "new_data.parquet" new_file.write_bytes(b"new content") # Mock checksum for existing file to match - with patch("portolan_cli.pull.compute_checksum") as mock_checksum: + with patch("portolan_cli.sync.pull.compute_checksum") as mock_checksum: mock_checksum.return_value = "abc123" changes = detect_uncommitted_changes( @@ -627,7 +627,7 @@ class TestVersionDiffing: @pytest.mark.unit def test_diff_versions_newer_remote(self) -> None: """Should identify files changed between local and remote versions.""" - from portolan_cli.pull import diff_versions + from portolan_cli.sync.pull import diff_versions from portolan_cli.versions import Asset, Version, VersionsFile local_versions = VersionsFile( @@ -681,7 +681,7 @@ def test_diff_versions_newer_remote(self) -> None: @pytest.mark.unit def test_diff_versions_up_to_date(self) -> None: """Should indicate no changes when versions match.""" - from portolan_cli.pull import diff_versions + from portolan_cli.sync.pull import diff_versions from portolan_cli.versions import Asset, Version, VersionsFile versions = VersionsFile( @@ -708,7 +708,7 @@ def test_diff_versions_up_to_date(self) -> None: @pytest.mark.unit def test_diff_versions_no_local_versions(self) -> None: """Should download all files when local has no versions.""" - from portolan_cli.pull import diff_versions + from portolan_cli.sync.pull import diff_versions from portolan_cli.versions import Asset, Version, VersionsFile local_versions = VersionsFile( @@ -757,13 +757,13 @@ def test_pull_refuses_with_uncommitted_changes( self, catalog_with_versions: Path, remote_versions_data: dict ) -> None: """Pull should refuse when local has uncommitted changes.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull # Modify local file to create uncommitted change data_file = catalog_with_versions / "test-collection" / "data.parquet" data_file.write_bytes(b"modified content - uncommitted") - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: from portolan_cli.versions import _parse_versions_file mock_fetch.return_value = _parse_versions_file(remote_versions_data) @@ -783,14 +783,14 @@ def test_pull_force_overwrites_uncommitted( self, catalog_with_versions: Path, remote_versions_data: dict ) -> None: """Pull --force should overwrite uncommitted changes.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull # Modify local file data_file = catalog_with_versions / "test-collection" / "data.parquet" data_file.write_bytes(b"modified content - will be overwritten") - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: - with patch("portolan_cli.pull._download_assets_async") as mock_download: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull._download_assets_async") as mock_download: from portolan_cli.versions import _parse_versions_file mock_fetch.return_value = _parse_versions_file(remote_versions_data) @@ -816,10 +816,10 @@ def test_pull_dry_run_no_downloads( _fetch_remote_versions. This test verifies neither the remote fetch nor the download step is invoked. """ - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: - with patch("portolan_cli.pull._download_assets_async") as mock_download: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull._download_assets_async") as mock_download: result = pull( remote_url="s3://bucket/catalog", local_root=catalog_with_versions, @@ -836,7 +836,7 @@ def test_pull_dry_run_no_downloads( @pytest.mark.unit def test_pull_already_up_to_date(self, catalog_with_versions: Path) -> None: """Pull should indicate when already up to date.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull # Use same version as local (href is relative to catalog_root) same_versions_data = { @@ -860,8 +860,8 @@ def test_pull_already_up_to_date(self, catalog_with_versions: Path) -> None: ], } - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: - with patch("portolan_cli.pull.compute_checksum") as mock_checksum: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull.compute_checksum") as mock_checksum: from portolan_cli.versions import _parse_versions_file mock_fetch.return_value = _parse_versions_file(same_versions_data) @@ -882,11 +882,11 @@ def test_pull_updates_local_versions_json( self, catalog_with_versions: Path, remote_versions_data: dict ) -> None: """Pull should update local versions.json after successful download.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: - with patch("portolan_cli.pull._download_assets_async") as mock_download: - with patch("portolan_cli.pull.compute_checksum") as mock_checksum: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull._download_assets_async") as mock_download: + with patch("portolan_cli.sync.pull.compute_checksum") as mock_checksum: from portolan_cli.versions import _parse_versions_file mock_fetch.return_value = _parse_versions_file(remote_versions_data) @@ -918,7 +918,7 @@ class TestRemoteFetch: @pytest.mark.unit def test_fetch_remote_versions_s3(self) -> None: """Should fetch versions.json from S3.""" - from portolan_cli.pull import _fetch_remote_versions + from portolan_cli.sync.pull import _fetch_remote_versions remote_data = { "spec_version": "1.0.0", @@ -926,7 +926,7 @@ def test_fetch_remote_versions_s3(self) -> None: "versions": [], } - with patch("portolan_cli.pull.download_file") as mock_download: + with patch("portolan_cli.sync.pull.download_file") as mock_download: # Simulate download writing the file def write_versions(source: str, destination: Path, **kwargs) -> MagicMock: destination.parent.mkdir(parents=True, exist_ok=True) @@ -948,9 +948,9 @@ def write_versions(source: str, destination: Path, **kwargs) -> MagicMock: @pytest.mark.unit def test_fetch_remote_versions_not_found(self) -> None: """Should raise error when remote versions.json doesn't exist.""" - from portolan_cli.pull import PullError, _fetch_remote_versions + from portolan_cli.sync.pull import PullError, _fetch_remote_versions - with patch("portolan_cli.pull.download_file") as mock_download: + with patch("portolan_cli.sync.pull.download_file") as mock_download: mock_result = MagicMock() mock_result.success = False mock_result.errors = [(Path("versions.json"), FileNotFoundError("Not found"))] @@ -976,11 +976,11 @@ def test_pull_handles_download_failure( self, catalog_with_versions: Path, remote_versions_data: dict ) -> None: """Pull should handle download failures gracefully.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: - with patch("portolan_cli.pull._download_assets_async") as mock_download: - with patch("portolan_cli.pull.compute_checksum") as mock_checksum: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull._download_assets_async") as mock_download: + with patch("portolan_cli.sync.pull.compute_checksum") as mock_checksum: from portolan_cli.versions import _parse_versions_file mock_fetch.return_value = _parse_versions_file(remote_versions_data) @@ -998,7 +998,7 @@ def test_pull_handles_download_failure( @pytest.mark.unit def test_pull_invalid_remote_url(self, catalog_with_versions: Path) -> None: """Pull should reject invalid remote URLs.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull with pytest.raises(ValueError, match="URL"): pull( @@ -1010,14 +1010,14 @@ def test_pull_invalid_remote_url(self, catalog_with_versions: Path) -> None: @pytest.mark.unit def test_pull_missing_local_catalog(self, tmp_path: Path) -> None: """Pull should handle missing local catalog gracefully.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull # Empty directory - no .portolan empty_dir = tmp_path / "empty" empty_dir.mkdir() - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: - with patch("portolan_cli.pull._download_assets_async") as mock_download: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull._download_assets_async") as mock_download: from portolan_cli.versions import VersionsFile mock_fetch.return_value = VersionsFile( @@ -1048,7 +1048,7 @@ class TestPathTraversalProtection: @pytest.mark.unit def test_download_assets_rejects_path_traversal(self, tmp_path: Path) -> None: """Should reject hrefs containing path traversal sequences.""" - from portolan_cli.pull import _download_assets + from portolan_cli.sync.pull import _download_assets local_root = tmp_path / "catalog" local_root.mkdir() @@ -1073,7 +1073,7 @@ def test_download_assets_rejects_path_traversal(self, tmp_path: Path) -> None: @pytest.mark.unit def test_download_assets_rejects_absolute_paths(self, tmp_path: Path) -> None: """Should reject absolute hrefs that could write outside catalog.""" - from portolan_cli.pull import _download_assets + from portolan_cli.sync.pull import _download_assets local_root = tmp_path / "catalog" local_root.mkdir() @@ -1098,7 +1098,7 @@ def test_download_assets_rejects_absolute_paths(self, tmp_path: Path) -> None: @pytest.mark.unit def test_download_assets_accepts_safe_relative_paths(self, tmp_path: Path) -> None: """Should accept valid relative paths within catalog.""" - from portolan_cli.pull import _download_assets + from portolan_cli.sync.pull import _download_assets local_root = tmp_path / "catalog" local_root.mkdir() @@ -1111,7 +1111,7 @@ def test_download_assets_accepts_safe_relative_paths(self, tmp_path: Path) -> No } } - with patch("portolan_cli.pull.download_file") as mock_download: + with patch("portolan_cli.sync.pull.download_file") as mock_download: mock_result = MagicMock() mock_result.success = True mock_download.return_value = mock_result @@ -1138,7 +1138,7 @@ class TestLocalAheadDetection: @pytest.mark.unit def test_diff_versions_detects_local_ahead(self) -> None: """Should detect when local has versions not in remote.""" - from portolan_cli.pull import diff_versions + from portolan_cli.sync.pull import diff_versions from portolan_cli.versions import Asset, Version, VersionsFile # Local has v1.0.0 and v1.1.0 @@ -1193,7 +1193,7 @@ def test_diff_versions_detects_local_ahead(self) -> None: @pytest.mark.unit def test_diff_versions_detects_diverged(self) -> None: """Should detect when local and remote have diverged.""" - from portolan_cli.pull import diff_versions + from portolan_cli.sync.pull import diff_versions from portolan_cli.versions import Asset, Version, VersionsFile # Local has v1.0.0 and v1.1.0-local @@ -1269,7 +1269,7 @@ class TestProgressReporting: @pytest.mark.unit def test_download_assets_shows_progress(self, tmp_path: Path, capsys) -> None: """_download_assets should show (1/N) style progress.""" - from portolan_cli.pull import _download_assets + from portolan_cli.sync.pull import _download_assets local_root = tmp_path / "catalog" local_root.mkdir() @@ -1287,7 +1287,7 @@ def test_download_assets_shows_progress(self, tmp_path: Path, capsys) -> None: }, } - with patch("portolan_cli.pull.download_file") as mock_download: + with patch("portolan_cli.sync.pull.download_file") as mock_download: mock_result = MagicMock() mock_result.success = True mock_download.return_value = mock_result @@ -1306,7 +1306,7 @@ def test_download_assets_shows_progress(self, tmp_path: Path, capsys) -> None: @pytest.mark.unit def test_download_assets_dry_run_shows_progress(self, tmp_path: Path, capsys) -> None: """Dry-run should also show progress indicators.""" - from portolan_cli.pull import _download_assets + from portolan_cli.sync.pull import _download_assets local_root = tmp_path / "catalog" local_root.mkdir() @@ -1348,7 +1348,7 @@ class TestSyncStateConflicts: @pytest.mark.unit def test_check_sync_state_returns_none_when_force(self) -> None: """_check_sync_state_conflicts should return None when force=True.""" - from portolan_cli.pull import VersionDiff, _check_sync_state_conflicts + from portolan_cli.sync.pull import VersionDiff, _check_sync_state_conflicts diff = VersionDiff( local_version="1.1.0", @@ -1366,7 +1366,7 @@ def test_check_sync_state_returns_none_when_force(self) -> None: @pytest.mark.unit def test_check_sync_state_fails_when_local_ahead(self) -> None: """_check_sync_state_conflicts should fail when local is ahead of remote.""" - from portolan_cli.pull import VersionDiff, _check_sync_state_conflicts + from portolan_cli.sync.pull import VersionDiff, _check_sync_state_conflicts diff = VersionDiff( local_version="1.1.0", @@ -1386,7 +1386,7 @@ def test_check_sync_state_fails_when_local_ahead(self) -> None: @pytest.mark.unit def test_check_sync_state_fails_when_diverged(self) -> None: """_check_sync_state_conflicts should fail when local and remote diverged.""" - from portolan_cli.pull import VersionDiff, _check_sync_state_conflicts + from portolan_cli.sync.pull import VersionDiff, _check_sync_state_conflicts diff = VersionDiff( local_version="1.1.0", @@ -1406,7 +1406,7 @@ def test_check_sync_state_fails_when_diverged(self) -> None: @pytest.mark.unit def test_check_sync_state_returns_none_when_ok(self) -> None: """_check_sync_state_conflicts should return None when no conflicts.""" - from portolan_cli.pull import VersionDiff, _check_sync_state_conflicts + from portolan_cli.sync.pull import VersionDiff, _check_sync_state_conflicts diff = VersionDiff( local_version="1.0.0", @@ -1433,7 +1433,7 @@ class TestUncommittedConflicts: @pytest.mark.unit def test_check_uncommitted_returns_none_when_force(self, catalog_with_versions: Path) -> None: """_check_uncommitted_conflicts should return None when force=True.""" - from portolan_cli.pull import VersionDiff, _check_uncommitted_conflicts + from portolan_cli.sync.pull import VersionDiff, _check_uncommitted_conflicts diff = VersionDiff( local_version="1.0.0", @@ -1455,7 +1455,7 @@ def test_check_uncommitted_returns_none_when_force(self, catalog_with_versions: @pytest.mark.unit def test_check_uncommitted_fails_when_conflicts(self, catalog_with_versions: Path) -> None: """_check_uncommitted_conflicts should fail when files would be overwritten.""" - from portolan_cli.pull import VersionDiff, _check_uncommitted_conflicts + from portolan_cli.sync.pull import VersionDiff, _check_uncommitted_conflicts diff = VersionDiff( local_version="1.0.0", @@ -1479,7 +1479,7 @@ def test_check_uncommitted_fails_when_conflicts(self, catalog_with_versions: Pat @pytest.mark.unit def test_check_uncommitted_succeeds_when_no_overlap(self, catalog_with_versions: Path) -> None: """_check_uncommitted_conflicts should succeed when modified files won't be downloaded.""" - from portolan_cli.pull import VersionDiff, _check_uncommitted_conflicts + from portolan_cli.sync.pull import VersionDiff, _check_uncommitted_conflicts diff = VersionDiff( local_version="1.0.0", @@ -1492,7 +1492,7 @@ def test_check_uncommitted_succeeds_when_no_overlap(self, catalog_with_versions: data_file = catalog_with_versions / "test-collection" / "data.parquet" data_file.write_bytes(b"modified content") - with patch("portolan_cli.pull.detect_uncommitted_changes") as mock_detect: + with patch("portolan_cli.sync.pull.detect_uncommitted_changes") as mock_detect: mock_detect.return_value = ["data.parquet"] result = _check_uncommitted_conflicts( @@ -1514,7 +1514,7 @@ class TestDownloadAssetsErrorHandling: @pytest.mark.unit def test_download_assets_skips_missing_asset_metadata(self, tmp_path: Path) -> None: """_download_assets should skip files without asset metadata.""" - from portolan_cli.pull import _download_assets + from portolan_cli.sync.pull import _download_assets local_root = tmp_path / "catalog" local_root.mkdir() @@ -1528,7 +1528,7 @@ def test_download_assets_skips_missing_asset_metadata(self, tmp_path: Path) -> N # "missing.parquet" is not in remote_assets } - with patch("portolan_cli.pull.download_file") as mock_download: + with patch("portolan_cli.sync.pull.download_file") as mock_download: mock_result = MagicMock() mock_result.success = True mock_download.return_value = mock_result @@ -1548,7 +1548,7 @@ def test_download_assets_skips_missing_asset_metadata(self, tmp_path: Path) -> N @pytest.mark.unit def test_download_assets_counts_failures(self, tmp_path: Path) -> None: """_download_assets should count failed downloads.""" - from portolan_cli.pull import _download_assets + from portolan_cli.sync.pull import _download_assets local_root = tmp_path / "catalog" local_root.mkdir() @@ -1561,7 +1561,7 @@ def test_download_assets_counts_failures(self, tmp_path: Path) -> None: } } - with patch("portolan_cli.pull.download_file") as mock_download: + with patch("portolan_cli.sync.pull.download_file") as mock_download: mock_result = MagicMock() mock_result.success = False mock_download.return_value = mock_result @@ -1588,7 +1588,7 @@ class TestValidateSafePathEdgeCases: @pytest.mark.unit def test_validate_safe_path_windows_absolute_drive(self, tmp_path: Path) -> None: """_validate_safe_path should reject Windows absolute paths.""" - from portolan_cli.pull import _validate_safe_path + from portolan_cli.sync.pull import _validate_safe_path with pytest.raises(ValueError, match="[Aa]bsolute|escapes"): _validate_safe_path(tmp_path, "C:\\Windows\\System32") @@ -1596,7 +1596,7 @@ def test_validate_safe_path_windows_absolute_drive(self, tmp_path: Path) -> None @pytest.mark.unit def test_validate_safe_path_normalized_traversal(self, tmp_path: Path) -> None: """_validate_safe_path should reject normalized traversal attempts.""" - from portolan_cli.pull import _validate_safe_path + from portolan_cli.sync.pull import _validate_safe_path # Try to escape using multiple traversal sequences with pytest.raises(ValueError, match="path traversal|escapes"): @@ -1614,9 +1614,9 @@ class TestFetchRemoteVersionsErrors: @pytest.mark.unit def test_fetch_remote_versions_download_error(self) -> None: """_fetch_remote_versions should raise PullError on download failure.""" - from portolan_cli.pull import PullError, _fetch_remote_versions + from portolan_cli.sync.pull import PullError, _fetch_remote_versions - with patch("portolan_cli.pull.download_file") as mock_download: + with patch("portolan_cli.sync.pull.download_file") as mock_download: mock_result = MagicMock() mock_result.success = False mock_result.errors = [("file", Exception("Network error"))] @@ -1640,9 +1640,9 @@ class TestPullFunctionErrors: @pytest.mark.unit def test_pull_returns_failure_on_fetch_error(self, catalog_with_versions: Path) -> None: """Pull should return failure result when remote fetch fails.""" - from portolan_cli.pull import PullError, pull + from portolan_cli.sync.pull import PullError, pull - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: mock_fetch.side_effect = PullError("Network timeout") result = pull( @@ -1683,10 +1683,10 @@ def test_pull_with_malformed_local_versions( self, catalog_with_versions_malformed: Path ) -> None: """Pull should handle catalog with malformed local versions.json.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull from portolan_cli.versions import VersionsFile - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: # Remote is valid mock_fetch.return_value = VersionsFile( spec_version="1.0.0", @@ -1714,10 +1714,10 @@ def test_pull_with_wrong_current_version_type( self, catalog_with_non_string_current_version: Path ) -> None: """Pull should handle catalog with wrong type for current_version.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull from portolan_cli.versions import VersionsFile - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: mock_fetch.return_value = VersionsFile( spec_version="1.0.0", current_version="1.0.0", @@ -1741,11 +1741,11 @@ def test_pull_with_wrong_current_version_type( @pytest.mark.unit def test_fetch_remote_malformed_response(self, tmp_path: Path) -> None: """_fetch_remote_versions should raise when remote returns malformed data.""" - from portolan_cli.pull import PullError, _fetch_remote_versions + from portolan_cli.sync.pull import PullError, _fetch_remote_versions malformed_json = '{"spec_version": "1.0.0"}' # Missing current_version and versions - with patch("portolan_cli.pull.download_file") as mock_download: + with patch("portolan_cli.sync.pull.download_file") as mock_download: def write_malformed(source: str, destination: Path, **kwargs) -> MagicMock: destination.parent.mkdir(parents=True, exist_ok=True) @@ -1786,9 +1786,9 @@ def test_pull_dry_run_never_calls_fetch_remote_versions( called _fetch_remote_versions unconditionally, which triggered a real network connection even when the user passed --dry-run. """ - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: result = pull( remote_url="s3://bucket/catalog", local_root=catalog_with_versions, @@ -1803,9 +1803,9 @@ def test_pull_dry_run_never_calls_fetch_remote_versions( @pytest.mark.unit def test_pull_dry_run_returns_simulated_result(self, catalog_with_versions: Path) -> None: """pull(dry_run=True) should return a valid PullResult showing 'would pull'.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: result = pull( remote_url="s3://bucket/catalog", local_root=catalog_with_versions, @@ -1823,12 +1823,12 @@ def test_pull_dry_run_returns_simulated_result(self, catalog_with_versions: Path @pytest.mark.unit def test_pull_dry_run_does_not_write_versions_json(self, catalog_with_versions: Path) -> None: """pull(dry_run=True) must not modify local versions.json.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull versions_path = catalog_with_versions / "test-collection" / "versions.json" original_mtime = versions_path.stat().st_mtime - with patch("portolan_cli.pull._fetch_remote_versions_async"): + with patch("portolan_cli.sync.pull._fetch_remote_versions_async"): pull( remote_url="s3://bucket/catalog", local_root=catalog_with_versions, @@ -1844,11 +1844,11 @@ def test_pull_non_dry_run_still_calls_fetch_remote_versions( self, catalog_with_versions: Path, remote_versions_data: dict ) -> None: """Non-dry-run pull must still call _fetch_remote_versions (sanity check).""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull from portolan_cli.versions import _parse_versions_file - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: - with patch("portolan_cli.pull._download_assets_async") as mock_download: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull._download_assets_async") as mock_download: mock_fetch.return_value = _parse_versions_file(remote_versions_data) mock_download.return_value = (1, 0) @@ -1871,9 +1871,9 @@ def test_pull_dry_run_on_fresh_catalog_without_versions_json( This tests the edge case where a user runs `portolan pull --dry-run` on a catalog that has never been versioned before (no versions.json). """ - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull - with patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch: + with patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch: result = pull( remote_url="s3://bucket/catalog", local_root=fresh_catalog_no_versions, @@ -1901,7 +1901,7 @@ def test_populate_missing_file_sizes_collection_assets(self, tmp_path: Path) -> import asyncio import json - from portolan_cli.pull import _populate_missing_file_sizes + from portolan_cli.sync.pull import _populate_missing_file_sizes # Create collection.json with assets missing file:size collection_dir = tmp_path / "test-collection" @@ -1928,7 +1928,7 @@ async def mock_get_size(url: str, timeout: float = 30.0) -> int | None: return 12345 return None - with patch("portolan_cli.pull.get_remote_file_size_async", mock_get_size): + with patch("portolan_cli.sync.pull.get_remote_file_size_async", mock_get_size): count = asyncio.run( _populate_missing_file_sizes( local_root=tmp_path, @@ -1950,7 +1950,7 @@ def test_populate_missing_file_sizes_item_assets(self, tmp_path: Path) -> None: import asyncio import json - from portolan_cli.pull import _populate_missing_file_sizes + from portolan_cli.sync.pull import _populate_missing_file_sizes # Create collection with an item collection_dir = tmp_path / "test-collection" @@ -1978,7 +1978,7 @@ async def mock_get_size(url: str, timeout: float = 30.0) -> int | None: return 9999 return None - with patch("portolan_cli.pull.get_remote_file_size_async", mock_get_size): + with patch("portolan_cli.sync.pull.get_remote_file_size_async", mock_get_size): count = asyncio.run( _populate_missing_file_sizes( local_root=tmp_path, @@ -1998,7 +1998,7 @@ def test_populate_missing_file_sizes_no_collection(self, tmp_path: Path) -> None """_populate_missing_file_sizes returns 0 when collection.json missing.""" import asyncio - from portolan_cli.pull import _populate_missing_file_sizes + from portolan_cli.sync.pull import _populate_missing_file_sizes count = asyncio.run( _populate_missing_file_sizes( @@ -2017,7 +2017,7 @@ class TestResolveAssetUrl: @pytest.mark.unit def test_absolute_url_returned_unchanged(self) -> None: """Absolute URLs are returned as-is.""" - from portolan_cli.pull import _resolve_asset_url + from portolan_cli.sync.pull import _resolve_asset_url assert ( _resolve_asset_url("https://example.com/file.parquet", "https://host/cat", "coll") @@ -2027,7 +2027,7 @@ def test_absolute_url_returned_unchanged(self) -> None: @pytest.mark.unit def test_relative_dot_slash_with_rel_path(self) -> None: """./href is resolved relative to base_url/rel_path.""" - from portolan_cli.pull import _resolve_asset_url + from portolan_cli.sync.pull import _resolve_asset_url assert ( _resolve_asset_url("./data.parquet", "https://host/cat", "coll") @@ -2037,7 +2037,7 @@ def test_relative_dot_slash_with_rel_path(self) -> None: @pytest.mark.unit def test_relative_dot_slash_empty_rel_path(self) -> None: """./href with empty rel_path resolves to base_url/href.""" - from portolan_cli.pull import _resolve_asset_url + from portolan_cli.sync.pull import _resolve_asset_url assert ( _resolve_asset_url("./data.parquet", "https://host/cat", "") @@ -2047,7 +2047,7 @@ def test_relative_dot_slash_empty_rel_path(self) -> None: @pytest.mark.unit def test_parent_path_with_nested_rel_path(self) -> None: """../href navigates up one level from rel_path.""" - from portolan_cli.pull import _resolve_asset_url + from portolan_cli.sync.pull import _resolve_asset_url assert ( _resolve_asset_url("../shared/data.parquet", "https://host/cat", "coll/item") @@ -2057,7 +2057,7 @@ def test_parent_path_with_nested_rel_path(self) -> None: @pytest.mark.unit def test_parent_path_with_single_segment_rel_path(self) -> None: """../href with single-segment rel_path resolves to base_url.""" - from portolan_cli.pull import _resolve_asset_url + from portolan_cli.sync.pull import _resolve_asset_url assert ( _resolve_asset_url("../data.parquet", "https://host/cat", "coll") @@ -2067,7 +2067,7 @@ def test_parent_path_with_single_segment_rel_path(self) -> None: @pytest.mark.unit def test_parent_path_empty_rel_path(self) -> None: """../href with empty rel_path still resolves (edge case).""" - from portolan_cli.pull import _resolve_asset_url + from portolan_cli.sync.pull import _resolve_asset_url assert ( _resolve_asset_url("../data.parquet", "https://host/cat", "") @@ -2077,7 +2077,7 @@ def test_parent_path_empty_rel_path(self) -> None: @pytest.mark.unit def test_bare_filename_with_rel_path(self) -> None: """Bare filename is resolved relative to base_url/rel_path.""" - from portolan_cli.pull import _resolve_asset_url + from portolan_cli.sync.pull import _resolve_asset_url assert ( _resolve_asset_url("data.parquet", "https://host/cat", "coll") @@ -2087,7 +2087,7 @@ def test_bare_filename_with_rel_path(self) -> None: @pytest.mark.unit def test_bare_filename_empty_rel_path(self) -> None: """Bare filename with empty rel_path resolves to base_url/filename.""" - from portolan_cli.pull import _resolve_asset_url + from portolan_cli.sync.pull import _resolve_asset_url assert ( _resolve_asset_url("data.parquet", "https://host/cat", "") diff --git a/tests/unit/test_pull_async.py b/tests/unit/test_pull_async.py index 9c59dac3..48299d9c 100644 --- a/tests/unit/test_pull_async.py +++ b/tests/unit/test_pull_async.py @@ -23,7 +23,7 @@ import pytest -from portolan_cli.pull import ( +from portolan_cli.sync.pull import ( PullResult, pull_async, ) @@ -129,8 +129,8 @@ async def mock_download_file_async(store: Any, key: str, path: Path) -> tuple[bo return True, 1000 with ( - patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch, - patch("portolan_cli.pull._download_file_async", mock_download_file_async), + patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch, + patch("portolan_cli.sync.pull._download_file_async", mock_download_file_async), ): # Setup mock to return remote versions with 10 files from portolan_cli.versions import Asset, Version, VersionsFile @@ -206,8 +206,8 @@ async def mock_download_file_async(store: Any, key: str, path: Path) -> tuple[bo return True, 1000 with ( - patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch, - patch("portolan_cli.pull._download_file_async", mock_download_file_async), + patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch, + patch("portolan_cli.sync.pull._download_file_async", mock_download_file_async), ): from portolan_cli.versions import Asset, Version, VersionsFile @@ -274,8 +274,8 @@ async def mock_download_with_rate_limit(store: Any, key: str, path: Path) -> tup return True, 1000 with ( - patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch, - patch("portolan_cli.pull._download_file_async", mock_download_with_rate_limit), + patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch, + patch("portolan_cli.sync.pull._download_file_async", mock_download_with_rate_limit), ): from portolan_cli.versions import Asset, Version, VersionsFile @@ -335,8 +335,8 @@ async def mock_download_always_fails(store: Any, key: str, path: Path) -> tuple[ raise Exception("Network error") with ( - patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch, - patch("portolan_cli.pull._download_file_async", mock_download_always_fails), + patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch, + patch("portolan_cli.sync.pull._download_file_async", mock_download_always_fails), ): from portolan_cli.versions import Asset, Version, VersionsFile @@ -394,9 +394,9 @@ def test_pull_uses_async_internally( 2. Results are properly returned from the async call """ with ( - patch("portolan_cli.pull.pull_async") as mock_async, + patch("portolan_cli.sync.pull.pull_async") as mock_async, ): - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull # Setup mock to return a result mock_async.return_value = PullResult( @@ -440,9 +440,9 @@ async def mock_download_file_async(store: Any, key: str, path: Path) -> tuple[bo return True, 1000 with ( - patch("portolan_cli.pull._fetch_remote_versions_async") as mock_fetch, - patch("portolan_cli.pull._download_file_async", mock_download_file_async), - patch("portolan_cli.pull.info") as mock_info, + patch("portolan_cli.sync.pull._fetch_remote_versions_async") as mock_fetch, + patch("portolan_cli.sync.pull._download_file_async", mock_download_file_async), + patch("portolan_cli.sync.pull.info") as mock_info, ): from portolan_cli.versions import Asset, Version, VersionsFile diff --git a/tests/unit/test_pull_parallel.py b/tests/unit/test_pull_parallel.py index 22e85a0b..c5b74903 100644 --- a/tests/unit/test_pull_parallel.py +++ b/tests/unit/test_pull_parallel.py @@ -13,7 +13,7 @@ import pytest -from portolan_cli.pull import ( +from portolan_cli.sync.pull import ( PullResult, pull_all_collections, ) @@ -44,7 +44,7 @@ def _create_collection(catalog_root: Path, name: str) -> None: class TestPullAllCollectionsParallel: """Tests for parallel execution in pull_all_collections().""" - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_accepts_workers_parameter(self, mock_pull: MagicMock, tmp_path: Path) -> None: """pull_all_collections accepts workers parameter.""" _setup_valid_catalog(tmp_path) @@ -67,7 +67,7 @@ def test_accepts_workers_parameter(self, mock_pull: MagicMock, tmp_path: Path) - assert result.success is True - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_workers_1_is_sequential(self, mock_pull: MagicMock, tmp_path: Path) -> None: """workers=1 executes sequentially.""" _setup_valid_catalog(tmp_path) @@ -98,7 +98,7 @@ def track_calls(**kwargs: Any) -> PullResult: # Sequential execution should maintain sorted order assert call_order == ["col1", "col2", "col3"] - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_workers_greater_than_1_executes_concurrently( self, mock_pull: MagicMock, tmp_path: Path ) -> None: @@ -140,7 +140,7 @@ async def track_calls(**kwargs: Any) -> PullResult: ) assert mock_pull.call_count == 4 - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_workers_none_uses_default(self, mock_pull: MagicMock, tmp_path: Path) -> None: """workers=None uses get_default_workers() for auto-detection.""" _setup_valid_catalog(tmp_path) @@ -164,7 +164,7 @@ def test_workers_none_uses_default(self, mock_pull: MagicMock, tmp_path: Path) - assert result.success is True mock_pull.assert_called_once() - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_parallel_aggregates_results_correctly( self, mock_pull: MagicMock, tmp_path: Path ) -> None: @@ -197,7 +197,7 @@ def return_results(**kwargs: Any) -> PullResult: assert result.successful_collections == 3 assert result.total_files_downloaded == 10 # 2 + 3 + 5 - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_parallel_handles_individual_failures( self, mock_pull: MagicMock, tmp_path: Path ) -> None: @@ -238,7 +238,7 @@ def mixed_results(**kwargs: Any) -> PullResult: assert result.failed_collections == 1 assert "col2" in result.collection_errors - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_parallel_handles_exceptions(self, mock_pull: MagicMock, tmp_path: Path) -> None: """Parallel execution catches and reports exceptions from workers.""" _setup_valid_catalog(tmp_path) @@ -269,7 +269,7 @@ def raise_on_col2(**kwargs: Any) -> PullResult: assert result.failed_collections == 1 assert "col2" in result.collection_errors - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_workers_capped_at_collection_count(self, mock_pull: MagicMock, tmp_path: Path) -> None: """Workers are capped at the number of collections (no wasted threads).""" _setup_valid_catalog(tmp_path) @@ -295,7 +295,7 @@ def test_workers_capped_at_collection_count(self, mock_pull: MagicMock, tmp_path assert result.success is True assert mock_pull.call_count == 2 - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_dry_run_with_parallel(self, mock_pull: MagicMock, tmp_path: Path) -> None: """Dry run works correctly with parallel execution.""" _setup_valid_catalog(tmp_path) @@ -323,7 +323,7 @@ def test_dry_run_with_parallel(self, mock_pull: MagicMock, tmp_path: Path) -> No for call in mock_pull.call_args_list: assert call.kwargs["dry_run"] is True - @patch("portolan_cli.pull.pull_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.pull.pull_async", new_callable=AsyncMock) def test_parallel_handles_unexpected_exception_types( self, mock_pull: MagicMock, tmp_path: Path ) -> None: diff --git a/tests/unit/test_pull_restore.py b/tests/unit/test_pull_restore.py index 96865274..6c24df66 100644 --- a/tests/unit/test_pull_restore.py +++ b/tests/unit/test_pull_restore.py @@ -127,7 +127,7 @@ class TestFindMissingFiles: @pytest.mark.unit def test_all_files_exist_returns_empty(self, catalog_with_matching_versions: Path) -> None: """When all files exist, return empty list.""" - from portolan_cli.pull import find_missing_files + from portolan_cli.sync.pull import find_missing_files catalog_root = catalog_with_matching_versions collection = "test-collection" @@ -140,7 +140,7 @@ def test_all_files_exist_returns_empty(self, catalog_with_matching_versions: Pat @pytest.mark.unit def test_one_file_missing_returns_that_file(self, catalog_with_matching_versions: Path) -> None: """When one file is missing, return it in the list.""" - from portolan_cli.pull import find_missing_files + from portolan_cli.sync.pull import find_missing_files catalog_root = catalog_with_matching_versions collection = "test-collection" @@ -155,7 +155,7 @@ def test_one_file_missing_returns_that_file(self, catalog_with_matching_versions @pytest.mark.unit def test_all_files_missing_returns_all(self, catalog_with_matching_versions: Path) -> None: """When all files are missing, return all of them.""" - from portolan_cli.pull import find_missing_files + from portolan_cli.sync.pull import find_missing_files catalog_root = catalog_with_matching_versions collection = "test-collection" @@ -171,7 +171,7 @@ def test_all_files_missing_returns_all(self, catalog_with_matching_versions: Pat @pytest.mark.unit def test_no_versions_json_returns_empty(self, tmp_path: Path) -> None: """When no versions.json exists, return empty list (nothing to restore).""" - from portolan_cli.pull import find_missing_files + from portolan_cli.sync.pull import find_missing_files catalog_root = tmp_path / "catalog" catalog_root.mkdir() @@ -186,7 +186,7 @@ def test_no_versions_json_returns_empty(self, tmp_path: Path) -> None: @pytest.mark.unit def test_empty_versions_returns_empty(self, tmp_path: Path) -> None: """When versions.json has no versions, return empty list.""" - from portolan_cli.pull import find_missing_files + from portolan_cli.sync.pull import find_missing_files catalog_root = tmp_path / "catalog" catalog_root.mkdir() @@ -221,7 +221,7 @@ def test_restore_downloads_missing_files_when_versions_match( matching_remote_versions: dict[str, Any], ) -> None: """restore=True should download missing files even when versions match.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull from portolan_cli.versions import _parse_versions_file catalog_root = catalog_with_matching_versions @@ -247,15 +247,15 @@ async def mock_download(*args: Any, **kwargs: Any) -> tuple[int, int]: with ( patch( - "portolan_cli.pull._fetch_remote_versions_async", + "portolan_cli.sync.pull._fetch_remote_versions_async", new=AsyncMock(return_value=remote_versions), ), patch( - "portolan_cli.pull._download_assets_async", + "portolan_cli.sync.pull._download_assets_async", new=mock_download, ), patch( - "portolan_cli.pull._setup_store_and_kwargs", + "portolan_cli.sync.pull._setup_store_and_kwargs", return_value=(AsyncMock(), {}), ), ): @@ -279,7 +279,7 @@ def test_restore_false_does_not_download_missing_files( matching_remote_versions: dict[str, Any], ) -> None: """Without restore=True, missing files are NOT downloaded (original behavior).""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull from portolan_cli.versions import _parse_versions_file catalog_root = catalog_with_matching_versions @@ -293,11 +293,11 @@ def test_restore_false_does_not_download_missing_files( with ( patch( - "portolan_cli.pull._fetch_remote_versions_async", + "portolan_cli.sync.pull._fetch_remote_versions_async", new=AsyncMock(return_value=remote_versions), ), patch( - "portolan_cli.pull._setup_store_and_kwargs", + "portolan_cli.sync.pull._setup_store_and_kwargs", return_value=(AsyncMock(), {}), ), ): @@ -320,7 +320,7 @@ def test_restore_with_all_files_present_is_noop( matching_remote_versions: dict[str, Any], ) -> None: """restore=True with all files present should still be 'up to date'.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull from portolan_cli.versions import _parse_versions_file catalog_root = catalog_with_matching_versions @@ -331,11 +331,11 @@ def test_restore_with_all_files_present_is_noop( with ( patch( - "portolan_cli.pull._fetch_remote_versions_async", + "portolan_cli.sync.pull._fetch_remote_versions_async", new=AsyncMock(return_value=remote_versions), ), patch( - "portolan_cli.pull._setup_store_and_kwargs", + "portolan_cli.sync.pull._setup_store_and_kwargs", return_value=(AsyncMock(), {}), ), ): @@ -362,7 +362,7 @@ def test_restore_combined_with_version_update( the deleted file is considered an "uncommitted change". We need --force to proceed with the pull in this case. """ - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull from portolan_cli.versions import _parse_versions_file catalog_root = catalog_with_matching_versions @@ -434,15 +434,15 @@ async def mock_download(*args: Any, **kwargs: Any) -> tuple[int, int]: with ( patch( - "portolan_cli.pull._fetch_remote_versions_async", + "portolan_cli.sync.pull._fetch_remote_versions_async", new=AsyncMock(return_value=remote_versions), ), patch( - "portolan_cli.pull._download_assets_async", + "portolan_cli.sync.pull._download_assets_async", new=mock_download, ), patch( - "portolan_cli.pull._setup_store_and_kwargs", + "portolan_cli.sync.pull._setup_store_and_kwargs", return_value=(AsyncMock(), {}), ), ): @@ -470,7 +470,7 @@ def test_dry_run_with_restore_shows_missing_files( catalog_with_matching_versions: Path, ) -> None: """dry-run with restore should report missing files without downloading.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull catalog_root = catalog_with_matching_versions collection = "test-collection" @@ -502,7 +502,7 @@ def test_restore_and_force_together( matching_remote_versions: dict[str, Any], ) -> None: """--restore and --force can be used together.""" - from portolan_cli.pull import pull + from portolan_cli.sync.pull import pull from portolan_cli.versions import _parse_versions_file catalog_root = catalog_with_matching_versions @@ -524,15 +524,15 @@ async def mock_download(*args: Any, **kwargs: Any) -> tuple[int, int]: with ( patch( - "portolan_cli.pull._fetch_remote_versions_async", + "portolan_cli.sync.pull._fetch_remote_versions_async", new=AsyncMock(return_value=remote_versions), ), patch( - "portolan_cli.pull._download_assets_async", + "portolan_cli.sync.pull._download_assets_async", new=mock_download, ), patch( - "portolan_cli.pull._setup_store_and_kwargs", + "portolan_cli.sync.pull._setup_store_and_kwargs", return_value=(AsyncMock(), {}), ), ): @@ -559,7 +559,7 @@ class TestPullResultRestore: @pytest.mark.unit def test_pull_result_tracks_files_restored(self) -> None: """PullResult should have a files_restored field.""" - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult result = PullResult( success=True, @@ -575,7 +575,7 @@ def test_pull_result_tracks_files_restored(self) -> None: @pytest.mark.unit def test_pull_result_files_restored_defaults_to_zero(self) -> None: """files_restored should default to 0 for backward compatibility.""" - from portolan_cli.pull import PullResult + from portolan_cli.sync.pull import PullResult result = PullResult( success=True, diff --git a/tests/unit/test_push.py b/tests/unit/test_push.py index 56504d74..dce20192 100644 --- a/tests/unit/test_push.py +++ b/tests/unit/test_push.py @@ -26,7 +26,7 @@ from hypothesis import given from hypothesis import strategies as st -from portolan_cli.push import UploadMetrics +from portolan_cli.sync.push import UploadMetrics if TYPE_CHECKING: pass @@ -282,7 +282,7 @@ class TestPushResult: @pytest.mark.unit def test_push_result_success(self) -> None: """PushResult should capture successful push stats.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult result = PushResult( success=True, @@ -301,7 +301,7 @@ def test_push_result_success(self) -> None: @pytest.mark.unit def test_push_result_with_conflicts(self) -> None: """PushResult should capture conflicts when detected.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult result = PushResult( success=False, @@ -317,7 +317,7 @@ def test_push_result_with_conflicts(self) -> None: @pytest.mark.unit def test_push_result_with_errors(self) -> None: """PushResult should capture upload errors.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult result = PushResult( success=False, @@ -342,7 +342,7 @@ class TestDiffVersionLists: @pytest.mark.unit def test_diff_local_only_changes(self) -> None: """Diff should detect versions that exist only locally.""" - from portolan_cli.push import diff_version_lists + from portolan_cli.sync.push import diff_version_lists local_versions = ["1.0.0", "1.1.0"] remote_versions = ["1.0.0"] @@ -356,7 +356,7 @@ def test_diff_local_only_changes(self) -> None: @pytest.mark.unit def test_diff_remote_only_changes(self) -> None: """Diff should detect versions that exist only remotely (conflict).""" - from portolan_cli.push import diff_version_lists + from portolan_cli.sync.push import diff_version_lists local_versions = ["1.0.0"] remote_versions = ["1.0.0", "1.0.1"] @@ -370,7 +370,7 @@ def test_diff_remote_only_changes(self) -> None: @pytest.mark.unit def test_diff_both_diverged(self) -> None: """Diff should detect when both local and remote have unique versions.""" - from portolan_cli.push import diff_version_lists + from portolan_cli.sync.push import diff_version_lists local_versions = ["1.0.0", "1.1.0"] remote_versions = ["1.0.0", "1.0.1"] @@ -384,7 +384,7 @@ def test_diff_both_diverged(self) -> None: @pytest.mark.unit def test_diff_empty_remote(self) -> None: """Diff should handle empty remote (first push).""" - from portolan_cli.push import diff_version_lists + from portolan_cli.sync.push import diff_version_lists local_versions = ["1.0.0", "1.1.0"] remote_versions: list[str] = [] @@ -398,7 +398,7 @@ def test_diff_empty_remote(self) -> None: @pytest.mark.unit def test_diff_identical(self) -> None: """Diff should detect when local and remote are identical.""" - from portolan_cli.push import diff_version_lists + from portolan_cli.sync.push import diff_version_lists local_versions = ["1.0.0", "1.1.0"] remote_versions = ["1.0.0", "1.1.0"] @@ -421,7 +421,7 @@ class TestConflictDetection: @pytest.mark.unit def test_has_conflict_when_remote_diverged(self) -> None: """Should detect conflict when remote has versions not in local.""" - from portolan_cli.push import VersionDiff + from portolan_cli.sync.push import VersionDiff diff = VersionDiff( local_only=["1.1.0"], @@ -434,7 +434,7 @@ def test_has_conflict_when_remote_diverged(self) -> None: @pytest.mark.unit def test_no_conflict_when_only_local_changes(self) -> None: """No conflict when only local has new versions.""" - from portolan_cli.push import VersionDiff + from portolan_cli.sync.push import VersionDiff diff = VersionDiff( local_only=["1.1.0"], @@ -447,7 +447,7 @@ def test_no_conflict_when_only_local_changes(self) -> None: @pytest.mark.unit def test_no_conflict_when_identical(self) -> None: """No conflict when local and remote are identical.""" - from portolan_cli.push import VersionDiff + from portolan_cli.sync.push import VersionDiff diff = VersionDiff( local_only=[], @@ -469,7 +469,7 @@ class TestPush: @pytest.mark.unit def test_push_dry_run_no_upload(self, local_catalog: Path) -> None: """Dry-run should not perform actual upload.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push # Dry-run returns early without any network calls (Bug #137) # No need to patch - the function returns before upload @@ -486,10 +486,10 @@ def test_push_dry_run_no_upload(self, local_catalog: Path) -> None: @pytest.mark.unit def test_push_detects_conflict_without_force(self, local_catalog: Path) -> None: """Push should fail when remote diverged and --force not specified.""" - from portolan_cli.push import PushConflictError, push + from portolan_cli.sync.push import PushConflictError, push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: # Remote has a version we don't have locally mock_fetch.return_value = ( @@ -517,10 +517,10 @@ def test_push_detects_conflict_without_force(self, local_catalog: Path) -> None: @pytest.mark.unit def test_push_with_force_ignores_conflict(self, local_catalog: Path) -> None: """Push with --force should overwrite despite remote changes.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = ( { @@ -535,17 +535,17 @@ def test_push_with_force_ignores_conflict(self, local_catalog: Path) -> None: ) with patch( - "portolan_cli.push._upload_assets_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock ) as mock_upload_assets: mock_upload_assets.return_value = (1, [], ["catalog/data.parquet"], UploadMetrics()) with patch( - "portolan_cli.push._upload_stac_files_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_stac_files_async", new_callable=AsyncMock ) as mock_upload_stac: mock_upload_stac.return_value = (2, [], ["stac/collection.json"]) with patch( - "portolan_cli.push._upload_versions_json_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_versions_json_async", new_callable=AsyncMock ) as mock_upload_versions: mock_upload_versions.return_value = None @@ -561,25 +561,25 @@ def test_push_with_force_ignores_conflict(self, local_catalog: Path) -> None: @pytest.mark.unit def test_push_first_time_no_remote(self, local_catalog: Path) -> None: """First push (no remote versions.json) should succeed.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (None, None) # No remote versions.json with patch( - "portolan_cli.push._upload_assets_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock ) as mock_upload_assets: mock_upload_assets.return_value = (1, [], ["catalog/data.parquet"], UploadMetrics()) with patch( - "portolan_cli.push._upload_stac_files_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_stac_files_async", new_callable=AsyncMock ) as mock_upload_stac: mock_upload_stac.return_value = (2, [], ["stac/collection.json"]) with patch( - "portolan_cli.push._upload_versions_json_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_versions_json_async", new_callable=AsyncMock ) as mock_upload_versions: mock_upload_versions.return_value = None @@ -595,14 +595,14 @@ def test_push_first_time_no_remote(self, local_catalog: Path) -> None: @pytest.mark.unit def test_push_nothing_to_push(self, local_catalog: Path) -> None: """Push when local == remote should report nothing to push.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push # Read local versions to create matching remote versions_path = local_catalog / "test" / "versions.json" local_data = json.loads(versions_path.read_text()) with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (local_data, "etag-123") @@ -628,10 +628,10 @@ class TestEtagOptimisticLocking: @pytest.mark.unit def test_push_uses_etag_for_conditional_put(self, local_catalog: Path) -> None: """Push should use etag for conditional put (optimistic locking).""" - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = ( { @@ -643,17 +643,17 @@ def test_push_uses_etag_for_conditional_put(self, local_catalog: Path) -> None: ) with patch( - "portolan_cli.push._upload_assets_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock ) as mock_upload_assets: mock_upload_assets.return_value = (1, [], ["catalog/data.parquet"], UploadMetrics()) with patch( - "portolan_cli.push._upload_stac_files_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_stac_files_async", new_callable=AsyncMock ) as mock_upload_stac: mock_upload_stac.return_value = (2, [], ["stac/collection.json"]) with patch( - "portolan_cli.push._upload_versions_json_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_versions_json_async", new_callable=AsyncMock ) as mock_upload_versions: mock_upload_versions.return_value = None @@ -672,10 +672,10 @@ def test_push_uses_etag_for_conditional_put(self, local_catalog: Path) -> None: @pytest.mark.unit def test_push_raises_on_etag_mismatch(self, local_catalog: Path) -> None: """Push should raise when etag mismatch (remote changed during push).""" - from portolan_cli.push import PushConflictError, push + from portolan_cli.sync.push import PushConflictError, push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = ( { @@ -687,24 +687,24 @@ def test_push_raises_on_etag_mismatch(self, local_catalog: Path) -> None: ) with patch( - "portolan_cli.push._upload_assets_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock ) as mock_upload_assets: mock_upload_assets.return_value = (1, [], ["catalog/data.parquet"], UploadMetrics()) with patch( - "portolan_cli.push._upload_stac_files_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_stac_files_async", new_callable=AsyncMock ) as mock_upload_stac: mock_upload_stac.return_value = (2, [], ["stac/collection.json"]) with patch( - "portolan_cli.push._upload_versions_json_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_versions_json_async", new_callable=AsyncMock ) as mock_upload_versions: # Simulate PushConflictError from etag mismatch mock_upload_versions.side_effect = PushConflictError( "Remote changed during push, re-run push to try again" ) - with patch("portolan_cli.push._cleanup_uploaded_assets"): + with patch("portolan_cli.sync.push._cleanup_uploaded_assets"): with pytest.raises(PushConflictError) as exc_info: push( catalog_root=local_catalog, @@ -726,7 +726,7 @@ class TestManifestLastOrdering: @pytest.mark.unit def test_assets_uploaded_before_versions(self, local_catalog: Path) -> None: """Assets should be uploaded before STAC files before versions.json (manifest-last).""" - from portolan_cli.push import push + from portolan_cli.sync.push import push call_order: list[str] = [] @@ -743,22 +743,22 @@ def track_versions(*args, **kwargs): return None with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (None, None) with patch( - "portolan_cli.push._upload_assets_async", + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock, side_effect=track_assets, ): with patch( - "portolan_cli.push._upload_stac_files_async", + "portolan_cli.sync.push._upload_stac_files_async", new_callable=AsyncMock, side_effect=track_stac, ): with patch( - "portolan_cli.push._upload_versions_json_async", + "portolan_cli.sync.push._upload_versions_json_async", new_callable=AsyncMock, side_effect=track_versions, ): @@ -775,15 +775,15 @@ def track_versions(*args, **kwargs): @pytest.mark.unit def test_versions_not_uploaded_if_assets_fail(self, local_catalog: Path) -> None: """versions.json should not be uploaded if asset upload fails.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (None, None) with patch( - "portolan_cli.push._upload_assets_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock ) as mock_upload_assets: mock_upload_assets.return_value = ( 0, @@ -793,9 +793,9 @@ def test_versions_not_uploaded_if_assets_fail(self, local_catalog: Path) -> None ) with patch( - "portolan_cli.push._upload_versions_json_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_versions_json_async", new_callable=AsyncMock ) as mock_upload_versions: - with patch("portolan_cli.push._cleanup_uploaded_assets"): + with patch("portolan_cli.sync.push._cleanup_uploaded_assets"): result = push( catalog_root=local_catalog, collection="test", @@ -819,24 +819,24 @@ class TestStoreSetup: @pytest.mark.unit def test_push_with_profile(self, local_catalog: Path) -> None: """Push should pass profile to store setup.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (None, None) - with patch("portolan_cli.push.setup_store") as mock_setup: + with patch("portolan_cli.sync.push.setup_store") as mock_setup: mock_store = MagicMock() mock_setup.return_value = (mock_store, "prefix") with patch( - "portolan_cli.push._upload_assets_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock ) as mock_upload: mock_upload.return_value = (1, [], ["catalog/data.parquet"], UploadMetrics()) with patch( - "portolan_cli.push._upload_versions_json_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_versions_json_async", new_callable=AsyncMock ): push( catalog_root=local_catalog, @@ -861,7 +861,7 @@ class TestMissingAssetDetection: @pytest.mark.unit def test_get_assets_to_upload_raises_on_missing_file(self, local_catalog: Path) -> None: """Should raise FileNotFoundError when referenced asset doesn't exist.""" - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload versions_data = { "versions": [ @@ -903,10 +903,10 @@ def test_push_force_with_remote_only_versions_and_local_changes( Scenario: Remote has v1.0.0 and v1.0.1, local has v1.0.0 and v1.1.0. With --force, push should proceed (local has changes to push). """ - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: # Remote has v1.0.1 that local doesn't have mock_fetch.return_value = ( @@ -922,17 +922,17 @@ def test_push_force_with_remote_only_versions_and_local_changes( ) with patch( - "portolan_cli.push._upload_assets_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock ) as mock_upload_assets: mock_upload_assets.return_value = (1, [], ["catalog/data.parquet"], UploadMetrics()) with patch( - "portolan_cli.push._upload_stac_files_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_stac_files_async", new_callable=AsyncMock ) as mock_upload_stac: mock_upload_stac.return_value = (2, [], ["stac/collection.json"]) with patch( - "portolan_cli.push._upload_versions_json_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_versions_json_async", new_callable=AsyncMock ) as mock_upload_versions: mock_upload_versions.return_value = None @@ -958,7 +958,7 @@ def test_push_force_overwrites_when_no_local_only(self, tmp_path: Path) -> None: This tests the fix for the --force early return bug. """ - from portolan_cli.push import push + from portolan_cli.sync.push import push # Create local catalog with ONLY v1.0.0 catalog_dir = tmp_path / "catalog" @@ -1009,7 +1009,7 @@ def test_push_force_overwrites_when_no_local_only(self, tmp_path: Path) -> None: (item_dir / "data.parquet").write_bytes(b"x" * 1024) with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: # Remote has v1.0.0 AND v1.0.1 - local is missing v1.0.1 mock_fetch.return_value = ( @@ -1025,7 +1025,7 @@ def test_push_force_overwrites_when_no_local_only(self, tmp_path: Path) -> None: ) with patch( - "portolan_cli.push._upload_assets_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock ) as mock_upload_assets: mock_upload_assets.return_value = ( 0, @@ -1035,12 +1035,12 @@ def test_push_force_overwrites_when_no_local_only(self, tmp_path: Path) -> None: ) # No assets to upload with patch( - "portolan_cli.push._upload_stac_files_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_stac_files_async", new_callable=AsyncMock ) as mock_upload_stac: mock_upload_stac.return_value = (1, [], ["stac/collection.json"]) with patch( - "portolan_cli.push._upload_versions_json_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_versions_json_async", new_callable=AsyncMock ) as mock_upload_versions: mock_upload_versions.return_value = None @@ -1115,12 +1115,12 @@ def test_force_reuploads_metadata_when_versions_identical(self, tmp_path: Path) Before the fix, the "Nothing to push" gate returned early even with --force, so the STAC upload phase never ran. """ - from portolan_cli.push import push + from portolan_cli.sync.push import push catalog_dir = self._build_identical_version_catalog(tmp_path) with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: # Remote has the EXACT same single version as local. mock_fetch.return_value = ( @@ -1132,15 +1132,15 @@ def test_force_reuploads_metadata_when_versions_identical(self, tmp_path: Path) "etag-123", ) with patch( - "portolan_cli.push._upload_assets_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock ) as mock_upload_assets: mock_upload_assets.return_value = (0, [], [], UploadMetrics()) with patch( - "portolan_cli.push._upload_stac_files_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_stac_files_async", new_callable=AsyncMock ) as mock_upload_stac: mock_upload_stac.return_value = (1, [], ["stac/collection.json"]) with patch( - "portolan_cli.push._upload_versions_json_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_versions_json_async", new_callable=AsyncMock ) as mock_upload_versions: mock_upload_versions.return_value = None @@ -1162,12 +1162,12 @@ def test_no_force_skips_upload_when_versions_identical(self, tmp_path: Path) -> This pins the other side of the Issue #496 gate: the "Nothing to push" short-circuit must remain intact when force is not set. """ - from portolan_cli.push import push + from portolan_cli.sync.push import push catalog_dir = self._build_identical_version_catalog(tmp_path) with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = ( { @@ -1178,7 +1178,7 @@ def test_no_force_skips_upload_when_versions_identical(self, tmp_path: Path) -> "etag-123", ) with patch( - "portolan_cli.push._upload_stac_files_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_stac_files_async", new_callable=AsyncMock ) as mock_upload_stac: result = push( catalog_root=catalog_dir, @@ -1205,13 +1205,13 @@ class TestOrphanCleanup: @pytest.mark.asyncio async def test_upload_assets_returns_uploaded_keys(self, local_catalog: Path) -> None: """_upload_assets_async should return list of uploaded object keys.""" - from portolan_cli.push import _upload_assets_async + from portolan_cli.sync.push import _upload_assets_async # Create the test file (needed for size calculation) test_file = local_catalog / "data.parquet" test_file.write_bytes(b"test data") - with patch("portolan_cli.push.obs.put_async", new_callable=AsyncMock) as mock_put: + with patch("portolan_cli.sync.push.obs.put_async", new_callable=AsyncMock) as mock_put: mock_put.return_value = None mock_store = MagicMock() @@ -1232,9 +1232,9 @@ async def test_upload_assets_returns_uploaded_keys(self, local_catalog: Path) -> @pytest.mark.unit def test_cleanup_deletes_uploaded_assets(self) -> None: """_cleanup_uploaded_assets should delete all uploaded keys.""" - from portolan_cli.push import _cleanup_uploaded_assets + from portolan_cli.sync.push import _cleanup_uploaded_assets - with patch("portolan_cli.push.obs.delete") as mock_delete: + with patch("portolan_cli.sync.push.obs.delete") as mock_delete: mock_store = MagicMock() uploaded_keys = ["catalog/file1.parquet", "catalog/file2.parquet"] @@ -1247,9 +1247,9 @@ def test_cleanup_deletes_uploaded_assets(self) -> None: @pytest.mark.unit def test_cleanup_handles_empty_list(self) -> None: """_cleanup_uploaded_assets should handle empty key list gracefully.""" - from portolan_cli.push import _cleanup_uploaded_assets + from portolan_cli.sync.push import _cleanup_uploaded_assets - with patch("portolan_cli.push.obs.delete") as mock_delete: + with patch("portolan_cli.sync.push.obs.delete") as mock_delete: mock_store = MagicMock() _cleanup_uploaded_assets(mock_store, []) @@ -1258,9 +1258,9 @@ def test_cleanup_handles_empty_list(self) -> None: @pytest.mark.unit def test_cleanup_continues_on_delete_failure(self) -> None: """_cleanup_uploaded_assets should continue if individual deletes fail.""" - from portolan_cli.push import _cleanup_uploaded_assets + from portolan_cli.sync.push import _cleanup_uploaded_assets - with patch("portolan_cli.push.obs.delete") as mock_delete: + with patch("portolan_cli.sync.push.obs.delete") as mock_delete: # First delete fails, second succeeds mock_delete.side_effect = [Exception("Network error"), None] @@ -1275,29 +1275,31 @@ def test_cleanup_continues_on_delete_failure(self) -> None: @pytest.mark.unit def test_push_cleans_up_on_versions_json_failure(self, local_catalog: Path) -> None: """Push should clean up uploaded assets if versions.json upload fails.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (None, None) with patch( - "portolan_cli.push._upload_assets_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock ) as mock_upload_assets: mock_upload_assets.return_value = (1, [], ["catalog/data.parquet"], UploadMetrics()) with patch( - "portolan_cli.push._upload_stac_files_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_stac_files_async", new_callable=AsyncMock ) as mock_upload_stac: mock_upload_stac.return_value = (2, [], ["stac/collection.json"]) with patch( - "portolan_cli.push._upload_versions_json_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_versions_json_async", new_callable=AsyncMock ) as mock_upload_versions: mock_upload_versions.side_effect = Exception("Network timeout") - with patch("portolan_cli.push._cleanup_uploaded_assets") as mock_cleanup: + with patch( + "portolan_cli.sync.push._cleanup_uploaded_assets" + ) as mock_cleanup: result = push( catalog_root=local_catalog, collection="test", @@ -1313,10 +1315,10 @@ def test_push_cleans_up_on_versions_json_failure(self, local_catalog: Path) -> N @pytest.mark.unit def test_push_cleans_up_on_etag_conflict(self, local_catalog: Path) -> None: """Push should clean up uploaded assets on etag mismatch.""" - from portolan_cli.push import PushConflictError, push + from portolan_cli.sync.push import PushConflictError, push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = ( { @@ -1328,21 +1330,23 @@ def test_push_cleans_up_on_etag_conflict(self, local_catalog: Path) -> None: ) with patch( - "portolan_cli.push._upload_assets_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock ) as mock_upload_assets: mock_upload_assets.return_value = (1, [], ["catalog/data.parquet"], UploadMetrics()) with patch( - "portolan_cli.push._upload_stac_files_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_stac_files_async", new_callable=AsyncMock ) as mock_upload_stac: mock_upload_stac.return_value = (2, [], ["stac/collection.json"]) with patch( - "portolan_cli.push._upload_versions_json_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_versions_json_async", new_callable=AsyncMock ) as mock_upload_versions: mock_upload_versions.side_effect = PushConflictError("Etag mismatch") - with patch("portolan_cli.push._cleanup_uploaded_assets") as mock_cleanup: + with patch( + "portolan_cli.sync.push._cleanup_uploaded_assets" + ) as mock_cleanup: with pytest.raises(PushConflictError): push( catalog_root=local_catalog, @@ -1369,13 +1373,13 @@ async def test_upload_assets_shows_progress(self, local_catalog: Path, capsys) - Note: With json_mode=False, a Rich progress bar handles the display. With suppress_progress=False and json_mode=True, text progress is shown. """ - from portolan_cli.push import _upload_assets_async + from portolan_cli.sync.push import _upload_assets_async # Create the test file (needed for size calculation) test_file = local_catalog / "data.parquet" test_file.write_bytes(b"test data") - with patch("portolan_cli.push.obs.put_async", new_callable=AsyncMock) as mock_put: + with patch("portolan_cli.sync.push.obs.put_async", new_callable=AsyncMock) as mock_put: mock_put.return_value = None mock_store = MagicMock() @@ -1398,7 +1402,7 @@ async def test_upload_assets_shows_progress(self, local_catalog: Path, capsys) - @pytest.mark.unit def test_push_dry_run_shows_progress(self, local_catalog: Path, capsys) -> None: """Dry-run at push level should show what would be uploaded.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push # dry_run is handled at push() level, not in _upload_assets_async result = push( @@ -1424,12 +1428,12 @@ class TestMultiCloudStoreSetup: @pytest.mark.unit def test_setup_store_s3_with_profile(self) -> None: """setup_store should load credentials from AWS profile.""" - from portolan_cli.upload import setup_store + from portolan_cli.sync.upload import setup_store - with patch("portolan_cli.upload._load_aws_credentials_from_profile") as mock_load: + with patch("portolan_cli.sync.upload._load_aws_credentials_from_profile") as mock_load: mock_load.return_value = ("access_key", "secret_key", None, "us-east-1") - with patch("portolan_cli.upload.S3Store") as mock_s3: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3: mock_s3.return_value = MagicMock() store, prefix = setup_store("s3://mybucket/catalog", profile="myprofile") @@ -1446,7 +1450,7 @@ def test_setup_store_s3_with_profile(self) -> None: @pytest.mark.unit def test_setup_store_s3_from_environment(self) -> None: """setup_store should use AWS credentials from environment.""" - from portolan_cli.upload import setup_store + from portolan_cli.sync.upload import setup_store with patch.dict( "os.environ", @@ -1457,7 +1461,7 @@ def test_setup_store_s3_from_environment(self) -> None: }, clear=True, ): - with patch("portolan_cli.upload.S3Store") as mock_s3: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3: mock_s3.return_value = MagicMock() store, prefix = setup_store("s3://mybucket/prefix") @@ -1472,7 +1476,7 @@ def test_setup_store_s3_from_environment(self) -> None: @pytest.mark.unit def test_setup_store_s3_uses_default_region_env(self) -> None: """setup_store should fallback to AWS_DEFAULT_REGION if AWS_REGION not set.""" - from portolan_cli.upload import setup_store + from portolan_cli.sync.upload import setup_store with patch.dict( "os.environ", @@ -1483,7 +1487,7 @@ def test_setup_store_s3_uses_default_region_env(self) -> None: }, clear=True, ): - with patch("portolan_cli.upload.S3Store") as mock_s3: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3: mock_s3.return_value = MagicMock() setup_store("s3://mybucket/prefix") @@ -1494,7 +1498,7 @@ def test_setup_store_s3_uses_default_region_env(self) -> None: @pytest.mark.unit def test_setup_store_s3_no_region(self) -> None: """setup_store should work without region (uses AWS SDK defaults).""" - from portolan_cli.upload import setup_store + from portolan_cli.sync.upload import setup_store with patch.dict( "os.environ", @@ -1504,7 +1508,7 @@ def test_setup_store_s3_no_region(self) -> None: }, clear=True, ): - with patch("portolan_cli.upload.S3Store") as mock_s3: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3: mock_s3.return_value = MagicMock() setup_store("s3://mybucket/data") @@ -1519,9 +1523,9 @@ def test_setup_store_gcs(self) -> None: Note: GCS credential handling is delegated to obstore library. We just verify the URL is parsed correctly and from_url is called. """ - from portolan_cli.upload import setup_store + from portolan_cli.sync.upload import setup_store - with patch("portolan_cli.upload.obs.store.from_url") as mock_from_url: + with patch("portolan_cli.sync.upload.obs.store.from_url") as mock_from_url: mock_from_url.return_value = MagicMock() store, prefix = setup_store("gs://mybucket/catalog") @@ -1536,9 +1540,9 @@ def test_setup_store_gcs_no_credentials(self) -> None: Note: GCS credential handling is delegated to obstore library. We just verify the URL is parsed correctly and from_url is called. """ - from portolan_cli.upload import setup_store + from portolan_cli.sync.upload import setup_store - with patch("portolan_cli.upload.obs.store.from_url") as mock_from_url: + with patch("portolan_cli.sync.upload.obs.store.from_url") as mock_from_url: mock_from_url.return_value = MagicMock() store, prefix = setup_store("gs://mybucket/prefix") @@ -1553,9 +1557,9 @@ def test_setup_store_azure_with_key(self) -> None: Note: Azure credential handling is delegated to obstore library. We just verify the URL is parsed correctly and from_url is called. """ - from portolan_cli.upload import setup_store + from portolan_cli.sync.upload import setup_store - with patch("portolan_cli.upload.obs.store.from_url") as mock_from_url: + with patch("portolan_cli.sync.upload.obs.store.from_url") as mock_from_url: mock_from_url.return_value = MagicMock() store, prefix = setup_store("az://account/mycontainer/catalog") @@ -1570,9 +1574,9 @@ def test_setup_store_azure_with_sas_token(self) -> None: Note: Azure credential handling (SAS tokens) is delegated to obstore library. We just verify the URL is parsed correctly and from_url is called. """ - from portolan_cli.upload import setup_store + from portolan_cli.sync.upload import setup_store - with patch("portolan_cli.upload.obs.store.from_url") as mock_from_url: + with patch("portolan_cli.sync.upload.obs.store.from_url") as mock_from_url: mock_from_url.return_value = MagicMock() store, prefix = setup_store("az://account/mycontainer/data") @@ -1587,9 +1591,9 @@ def test_setup_store_azure_parses_prefix(self) -> None: Azure URLs have format: az://account/container/path Note: Azure credential precedence is delegated to obstore library. """ - from portolan_cli.upload import setup_store + from portolan_cli.sync.upload import setup_store - with patch("portolan_cli.upload.obs.store.from_url") as mock_from_url: + with patch("portolan_cli.sync.upload.obs.store.from_url") as mock_from_url: mock_from_url.return_value = MagicMock() # Azure format: az://account/container/prefix @@ -1607,13 +1611,13 @@ def test_setup_store_unknown_scheme_uses_from_url(self) -> None: validates the scheme first and raises ValueError for unsupported schemes, we need to mock it to allow the unknown scheme through. """ - from portolan_cli.upload import setup_store + from portolan_cli.sync.upload import setup_store - with patch("portolan_cli.upload.parse_object_store_url") as mock_parse: + with patch("portolan_cli.sync.upload.parse_object_store_url") as mock_parse: # Return a fake bucket URL with unsupported scheme mock_parse.return_value = ("custom://bucket", "catalog") - with patch("portolan_cli.upload.obs.store.from_url") as mock_from_url: + with patch("portolan_cli.sync.upload.obs.store.from_url") as mock_from_url: mock_from_url.return_value = MagicMock() store, prefix = setup_store("custom://bucket/catalog") @@ -1633,14 +1637,14 @@ class TestFetchRemoteVersions: @pytest.mark.unit def test_fetch_remote_versions_success(self) -> None: """_fetch_remote_versions should return parsed data and etag.""" - from portolan_cli.push import _fetch_remote_versions + from portolan_cli.sync.push import _fetch_remote_versions mock_store = MagicMock() mock_result = MagicMock() mock_result.bytes.return_value = b'{"spec_version": "1.0.0", "versions": []}' mock_result.meta = {"e_tag": "etag-123"} - with patch("portolan_cli.push.obs.get") as mock_get: + with patch("portolan_cli.sync.push.obs.get") as mock_get: mock_get.return_value = mock_result data, etag = _fetch_remote_versions(mock_store, "catalog", "test-collection") @@ -1652,11 +1656,11 @@ def test_fetch_remote_versions_success(self) -> None: @pytest.mark.unit def test_fetch_remote_versions_file_not_found(self) -> None: """_fetch_remote_versions should return None for missing file.""" - from portolan_cli.push import _fetch_remote_versions + from portolan_cli.sync.push import _fetch_remote_versions mock_store = MagicMock() - with patch("portolan_cli.push.obs.get") as mock_get: + with patch("portolan_cli.sync.push.obs.get") as mock_get: mock_get.side_effect = FileNotFoundError("Not found") data, etag = _fetch_remote_versions(mock_store, "catalog", "test-collection") @@ -1667,11 +1671,11 @@ def test_fetch_remote_versions_file_not_found(self) -> None: @pytest.mark.unit def test_fetch_remote_versions_not_found_string_error(self) -> None: """_fetch_remote_versions should handle 'not found' string errors.""" - from portolan_cli.push import _fetch_remote_versions + from portolan_cli.sync.push import _fetch_remote_versions mock_store = MagicMock() - with patch("portolan_cli.push.obs.get") as mock_get: + with patch("portolan_cli.sync.push.obs.get") as mock_get: mock_get.side_effect = Exception("Object does not exist in bucket") data, etag = _fetch_remote_versions(mock_store, "catalog", "test-collection") @@ -1682,11 +1686,11 @@ def test_fetch_remote_versions_not_found_string_error(self) -> None: @pytest.mark.unit def test_fetch_remote_versions_404_error(self) -> None: """_fetch_remote_versions should handle 404 errors.""" - from portolan_cli.push import _fetch_remote_versions + from portolan_cli.sync.push import _fetch_remote_versions mock_store = MagicMock() - with patch("portolan_cli.push.obs.get") as mock_get: + with patch("portolan_cli.sync.push.obs.get") as mock_get: mock_get.side_effect = Exception("404 Not Found") data, etag = _fetch_remote_versions(mock_store, "catalog", "test-collection") @@ -1697,11 +1701,11 @@ def test_fetch_remote_versions_404_error(self) -> None: @pytest.mark.unit def test_fetch_remote_versions_no_such_key_error(self) -> None: """_fetch_remote_versions should handle NoSuchKey errors.""" - from portolan_cli.push import _fetch_remote_versions + from portolan_cli.sync.push import _fetch_remote_versions mock_store = MagicMock() - with patch("portolan_cli.push.obs.get") as mock_get: + with patch("portolan_cli.sync.push.obs.get") as mock_get: mock_get.side_effect = Exception("NoSuchKey: The specified key does not exist") data, etag = _fetch_remote_versions(mock_store, "catalog", "test-collection") @@ -1712,11 +1716,11 @@ def test_fetch_remote_versions_no_such_key_error(self) -> None: @pytest.mark.unit def test_fetch_remote_versions_other_error_raises(self) -> None: """_fetch_remote_versions should re-raise non-not-found errors.""" - from portolan_cli.push import _fetch_remote_versions + from portolan_cli.sync.push import _fetch_remote_versions mock_store = MagicMock() - with patch("portolan_cli.push.obs.get") as mock_get: + with patch("portolan_cli.sync.push.obs.get") as mock_get: mock_get.side_effect = Exception("Access denied: permission error") with pytest.raises(Exception, match="Access denied"): @@ -1735,7 +1739,7 @@ class TestUploadAssetsErrorHandling: @pytest.mark.asyncio async def test_upload_assets_handles_exception(self, local_catalog: Path) -> None: """_upload_assets_async should catch and report upload exceptions.""" - from portolan_cli.push import _upload_assets_async + from portolan_cli.sync.push import _upload_assets_async # Create the test file (needed for size calculation) test_file = local_catalog / "data.parquet" @@ -1743,7 +1747,7 @@ async def test_upload_assets_handles_exception(self, local_catalog: Path) -> Non mock_store = MagicMock() - with patch("portolan_cli.push.obs.put_async", new_callable=AsyncMock) as mock_put: + with patch("portolan_cli.sync.push.obs.put_async", new_callable=AsyncMock) as mock_put: mock_put.side_effect = Exception("Network timeout") files_uploaded, errors, uploaded_keys, _metrics = await _upload_assets_async( @@ -1770,12 +1774,12 @@ class TestUploadVersionsJsonErrorHandling: @pytest.mark.unit def test_upload_versions_json_precondition_error(self) -> None: """_upload_versions_json should raise PushConflictError on precondition failure.""" - from portolan_cli.push import PushConflictError, _upload_versions_json + from portolan_cli.sync.push import PushConflictError, _upload_versions_json mock_store = MagicMock() versions_data = {"spec_version": "1.0.0", "versions": []} - with patch("portolan_cli.push.obs.put") as mock_put: + with patch("portolan_cli.sync.push.obs.put") as mock_put: mock_put.side_effect = Exception("PreconditionFailed: etag mismatch") with pytest.raises(PushConflictError, match="Remote changed during push"): @@ -1790,12 +1794,12 @@ def test_upload_versions_json_precondition_error(self) -> None: @pytest.mark.unit def test_upload_versions_json_with_force(self) -> None: """_upload_versions_json with force=True should not use etag.""" - from portolan_cli.push import _upload_versions_json + from portolan_cli.sync.push import _upload_versions_json mock_store = MagicMock() versions_data = {"spec_version": "1.0.0", "versions": []} - with patch("portolan_cli.push.obs.put") as mock_put: + with patch("portolan_cli.sync.push.obs.put") as mock_put: mock_put.return_value = None _upload_versions_json( @@ -1816,12 +1820,12 @@ def test_upload_versions_json_with_force(self) -> None: @pytest.mark.unit def test_upload_versions_json_first_push_no_etag(self) -> None: """_upload_versions_json with etag=None should use overwrite mode.""" - from portolan_cli.push import _upload_versions_json + from portolan_cli.sync.push import _upload_versions_json mock_store = MagicMock() versions_data = {"spec_version": "1.0.0", "versions": []} - with patch("portolan_cli.push.obs.put") as mock_put: + with patch("portolan_cli.sync.push.obs.put") as mock_put: mock_put.return_value = None _upload_versions_json( @@ -1838,12 +1842,12 @@ def test_upload_versions_json_first_push_no_etag(self) -> None: @pytest.mark.unit def test_upload_versions_json_other_error_reraises(self) -> None: """_upload_versions_json should re-raise non-precondition errors.""" - from portolan_cli.push import _upload_versions_json + from portolan_cli.sync.push import _upload_versions_json mock_store = MagicMock() versions_data = {"spec_version": "1.0.0", "versions": []} - with patch("portolan_cli.push.obs.put") as mock_put: + with patch("portolan_cli.sync.push.obs.put") as mock_put: mock_put.side_effect = Exception("Network timeout") with pytest.raises(Exception, match="Network timeout"): @@ -1867,7 +1871,7 @@ class TestMalformedDataHandling: @pytest.mark.unit def test_read_local_versions_invalid_json(self, local_catalog_invalid_json: Path) -> None: """_read_local_versions should raise ValueError on invalid JSON.""" - from portolan_cli.push import _read_local_versions + from portolan_cli.sync.push import _read_local_versions with pytest.raises(ValueError, match="Invalid JSON"): _read_local_versions( @@ -1878,7 +1882,7 @@ def test_read_local_versions_invalid_json(self, local_catalog_invalid_json: Path @pytest.mark.unit def test_read_local_versions_missing_keys(self, local_catalog_malformed: Path) -> None: """_read_local_versions should return data but push should fail on missing keys.""" - from portolan_cli.push import _read_local_versions + from portolan_cli.sync.push import _read_local_versions # _read_local_versions just parses JSON, it doesn't validate schema # The schema validation happens later in the push process @@ -1903,7 +1907,7 @@ def test_push_with_malformed_local_versions_keyerror( and current_version. When push tries to read and process this, it should fail with a KeyError when accessing these missing fields. """ - from portolan_cli.push import _read_local_versions + from portolan_cli.sync.push import _read_local_versions # Read the malformed versions.json data = _read_local_versions( @@ -1932,7 +1936,7 @@ def test_push_with_malformed_remote_versions_missing_key( When diff_version_lists tries to extract versions, it should handle this gracefully or raise an appropriate error. """ - from portolan_cli.push import diff_version_lists + from portolan_cli.sync.push import diff_version_lists # The malformed data has versions but is missing spec_version # Attempting to extract version strings should still work @@ -1998,7 +2002,7 @@ def test_push_result_dataclass_accepts_valid_inputs( errors: list[str], ) -> None: """PushResult should accept any valid combination of inputs.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult result = PushResult( success=success, @@ -2026,11 +2030,11 @@ def test_dry_run_result_always_has_zero_counts(self, local_catalog: Path) -> Non 2. Incremental push (remote has some versions) 3. Nothing to push (remote matches local) """ - from portolan_cli.push import push + from portolan_cli.sync.push import push # Scenario 1: First push (remote has no versions) with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (None, None) @@ -2048,7 +2052,7 @@ def test_dry_run_result_always_has_zero_counts(self, local_catalog: Path) -> Non # Scenario 2: Incremental push (remote has v1.0.0, local has v1.0.0 + v1.1.0) with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = ( { @@ -2083,7 +2087,7 @@ def test_dry_run_result_always_has_zero_counts(self, local_catalog: Path) -> Non # Scenario 3: Nothing to push (remote matches local exactly) local_versions = json.loads((local_catalog / "test" / "versions.json").read_text()) with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: mock_fetch.return_value = (local_versions, "etag-456") @@ -2116,7 +2120,7 @@ def test_diff_partitions_versions( Property: local_only ∪ remote_only ∪ common = local ∪ remote Property: local_only ∩ remote_only = ∅ """ - from portolan_cli.push import diff_version_lists + from portolan_cli.sync.push import diff_version_lists diff = diff_version_lists(local_versions, remote_versions) @@ -2137,7 +2141,7 @@ def test_identical_versions_means_nothing_to_push(self, versions: list[str]) -> This is the "nothing to push" case - the invariant our fix addresses. """ - from portolan_cli.push import diff_version_lists + from portolan_cli.sync.push import diff_version_lists diff = diff_version_lists(versions, versions) @@ -2153,7 +2157,7 @@ def test_identical_versions_means_nothing_to_push(self, versions: list[str]) -> ) def test_empty_remote_means_local_only(self, local_versions: list[str]) -> None: """When remote is empty, all local versions should be in local_only.""" - from portolan_cli.push import diff_version_lists + from portolan_cli.sync.push import diff_version_lists diff = diff_version_lists(local_versions, []) @@ -2183,12 +2187,12 @@ def test_push_dry_run_never_calls_fetch_remote_versions(self, local_catalog: Pat always called _setup_store then _fetch_remote_versions regardless of dry_run, making real S3/GCS/Azure connections. """ - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: - with patch("portolan_cli.push.setup_store") as mock_setup: + with patch("portolan_cli.sync.push.setup_store") as mock_setup: mock_setup.return_value = (MagicMock(), "prefix") result = push( @@ -2205,9 +2209,9 @@ def test_push_dry_run_never_calls_fetch_remote_versions(self, local_catalog: Pat @pytest.mark.unit def test_push_dry_run_never_calls_setup_store(self, local_catalog: Path) -> None: """push(dry_run=True) must not call _setup_store (which creates cloud connections).""" - from portolan_cli.push import push + from portolan_cli.sync.push import push - with patch("portolan_cli.push.setup_store") as mock_setup: + with patch("portolan_cli.sync.push.setup_store") as mock_setup: result = push( catalog_root=local_catalog, collection="test", @@ -2221,10 +2225,12 @@ def test_push_dry_run_never_calls_setup_store(self, local_catalog: Path) -> None @pytest.mark.unit def test_push_dry_run_shows_would_push_message(self, local_catalog: Path) -> None: """push(dry_run=True) should report files that would be uploaded.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push - with patch("portolan_cli.push.setup_store"): - with patch("portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock): + with patch("portolan_cli.sync.push.setup_store"): + with patch( + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock + ): result = push( catalog_root=local_catalog, collection="test", @@ -2240,12 +2246,14 @@ def test_push_dry_run_shows_would_push_message(self, local_catalog: Path) -> Non @pytest.mark.unit def test_push_dry_run_does_not_call_upload_assets(self, local_catalog: Path) -> None: """push(dry_run=True) must not call _upload_assets.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push - with patch("portolan_cli.push.setup_store"): - with patch("portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock): + with patch("portolan_cli.sync.push.setup_store"): + with patch( + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock + ): with patch( - "portolan_cli.push._upload_assets_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock ) as mock_upload: push( catalog_root=local_catalog, @@ -2259,21 +2267,21 @@ def test_push_dry_run_does_not_call_upload_assets(self, local_catalog: Path) -> @pytest.mark.unit def test_push_non_dry_run_still_calls_fetch_remote_versions(self, local_catalog: Path) -> None: """Non-dry-run push must still call _fetch_remote_versions (sanity check).""" - from portolan_cli.push import push + from portolan_cli.sync.push import push with patch( - "portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock ) as mock_fetch: - with patch("portolan_cli.push.setup_store") as mock_setup: + with patch("portolan_cli.sync.push.setup_store") as mock_setup: mock_fetch.return_value = (None, None) mock_setup.return_value = (MagicMock(), "prefix") with patch( - "portolan_cli.push._upload_assets_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_assets_async", new_callable=AsyncMock ) as mock_upload: mock_upload.return_value = (1, [], ["key"], UploadMetrics()) with patch( - "portolan_cli.push._upload_versions_json_async", new_callable=AsyncMock + "portolan_cli.sync.push._upload_versions_json_async", new_callable=AsyncMock ): push( catalog_root=local_catalog, @@ -2296,7 +2304,7 @@ def test_push_dry_run_handles_missing_asset_gracefully(self, tmp_path: Path) -> """ import json - from portolan_cli.push import push + from portolan_cli.sync.push import push # Create catalog with versions.json referencing a missing file catalog_dir = tmp_path / "catalog_missing_asset" @@ -2327,8 +2335,10 @@ def test_push_dry_run_handles_missing_asset_gracefully(self, tmp_path: Path) -> (collection_dir / "versions.json").write_text(json.dumps(versions_data, indent=2)) # NOTE: We deliberately do NOT create the asset file - with patch("portolan_cli.push.setup_store"): - with patch("portolan_cli.push._fetch_remote_versions_async", new_callable=AsyncMock): + with patch("portolan_cli.sync.push.setup_store"): + with patch( + "portolan_cli.sync.push._fetch_remote_versions_async", new_callable=AsyncMock + ): # Should NOT raise - dry-run is forgiving result = push( catalog_root=catalog_dir, @@ -2355,7 +2365,7 @@ class TestDiscoverStacFilesReadmes: @pytest.mark.unit def test_discovers_collection_readme(self, tmp_path: Path) -> None: """_discover_stac_files should find collection-level README.md.""" - from portolan_cli.push import _discover_stac_files + from portolan_cli.sync.push import _discover_stac_files # Setup catalog with collection.json and README catalog_dir = tmp_path / "catalog" @@ -2374,7 +2384,7 @@ def test_discovers_collection_readme(self, tmp_path: Path) -> None: @pytest.mark.unit def test_discovers_catalog_readme_when_include_catalog(self, tmp_path: Path) -> None: """_discover_stac_files should find root README.md when include_catalog=True.""" - from portolan_cli.push import _discover_stac_files + from portolan_cli.sync.push import _discover_stac_files # Setup catalog with root README and collection catalog_dir = tmp_path / "catalog" @@ -2397,7 +2407,7 @@ def test_discovers_catalog_readme_when_include_catalog(self, tmp_path: Path) -> @pytest.mark.unit def test_no_readme_when_missing(self, tmp_path: Path) -> None: """_discover_stac_files should handle missing README.md gracefully.""" - from portolan_cli.push import _discover_stac_files + from portolan_cli.sync.push import _discover_stac_files # Setup catalog without README catalog_dir = tmp_path / "catalog" @@ -2432,7 +2442,7 @@ def test_skips_assets_already_on_remote_by_sha256(self, tmp_path: Path) -> None: has 1 of those assets with same sha256. Only the new asset should be uploaded. """ - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload catalog_dir = tmp_path / "catalog" catalog_dir.mkdir() @@ -2495,7 +2505,7 @@ def test_uploads_changed_assets_with_different_sha256(self, tmp_path: Path) -> N Scenario: Same file path, different content (different sha256). """ - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload catalog_dir = tmp_path / "catalog" catalog_dir.mkdir() @@ -2555,7 +2565,7 @@ def test_uploads_renamed_file_even_with_same_sha256(self, tmp_path: Path) -> Non Scenario: data.parquet renamed to renamed.parquet (same content). """ - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload catalog_dir = tmp_path / "catalog" catalog_dir.mkdir() @@ -2611,7 +2621,7 @@ def test_uploads_all_assets_when_no_remote(self, tmp_path: Path) -> None: This is the first push case. """ - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload catalog_dir = tmp_path / "catalog" catalog_dir.mkdir() @@ -2658,7 +2668,7 @@ def test_considers_all_remote_versions_for_sha256_check(self, tmp_path: Path) -> Local version 2.0.0 has an asset that matches 1.0.0's sha256. It should be skipped even though it's not in 1.1.0. """ - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload catalog_dir = tmp_path / "catalog" catalog_dir.mkdir() @@ -2716,7 +2726,7 @@ def test_considers_all_remote_versions_for_sha256_check(self, tmp_path: Path) -> @pytest.mark.unit def test_handles_empty_remote_versions_list(self, tmp_path: Path) -> None: """Remote with empty versions list should upload all assets.""" - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload catalog_dir = tmp_path / "catalog" catalog_dir.mkdir() @@ -2755,7 +2765,7 @@ def test_large_catalog_diffing_performance(self, tmp_path: Path) -> None: This is the exact scenario described in the issue. """ - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload catalog_dir = tmp_path / "catalog" catalog_dir.mkdir() @@ -2819,7 +2829,7 @@ def test_handles_missing_sha256_in_remote_asset(self, tmp_path: Path) -> None: Edge case: malformed remote versions.json. """ - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload catalog_dir = tmp_path / "catalog" catalog_dir.mkdir() @@ -2871,7 +2881,7 @@ def test_handles_missing_sha256_in_local_asset(self, tmp_path: Path) -> None: Edge case: malformed local versions.json. """ - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload catalog_dir = tmp_path / "catalog" catalog_dir.mkdir() @@ -2939,7 +2949,7 @@ def test_real_sha256_diffing_skips_identical_files(self, tmp_path: Path) -> None """ import hashlib - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload catalog_dir = tmp_path / "catalog" catalog_dir.mkdir() @@ -3003,7 +3013,7 @@ def test_real_sha256_diffing_uploads_modified_files(self, tmp_path: Path) -> Non """ import hashlib - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload catalog_dir = tmp_path / "catalog" catalog_dir.mkdir() @@ -3075,7 +3085,7 @@ def test_real_sha256_incremental_add_scenario(self, tmp_path: Path) -> None: """ import hashlib - from portolan_cli.push import _get_assets_to_upload + from portolan_cli.sync.push import _get_assets_to_upload catalog_dir = tmp_path / "catalog" catalog_dir.mkdir() diff --git a/tests/unit/test_push_async.py b/tests/unit/test_push_async.py index 05a3ecca..6b42ad7b 100644 --- a/tests/unit/test_push_async.py +++ b/tests/unit/test_push_async.py @@ -457,7 +457,7 @@ class TestPushAsyncConcurrency: @pytest.mark.asyncio async def test_push_async_uploads_concurrently(self, async_catalog: Path) -> None: """Verify push_async() uses concurrent uploads.""" - from portolan_cli.push import push_async + from portolan_cli.sync.push import push_async # Track upload timing to verify concurrency upload_times: list[float] = [] @@ -471,10 +471,10 @@ async def mock_put(store: Any, key: str, content: Any, **kwargs: Any) -> None: async with upload_lock: upload_times.append(time.perf_counter() - start) - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put): - with patch("portolan_cli.push.obs.get_async", return_value=None): + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put): + with patch("portolan_cli.sync.push.obs.get_async", return_value=None): with patch( - "portolan_cli.push._fetch_remote_versions_async", return_value=(None, None) + "portolan_cli.sync.push._fetch_remote_versions_async", return_value=(None, None) ): result = await push_async( catalog_root=async_catalog, @@ -493,7 +493,7 @@ async def mock_put(store: Any, key: str, content: Any, **kwargs: Any) -> None: @pytest.mark.asyncio async def test_push_async_respects_concurrency_limit(self, many_files_catalog: Path) -> None: """Verify push_async() respects the concurrency parameter.""" - from portolan_cli.push import push_async + from portolan_cli.sync.push import push_async max_concurrent = 0 current_concurrent = 0 @@ -510,8 +510,10 @@ async def mock_put(store: Any, key: str, content: Any, **kwargs: Any) -> None: concurrency_limit = 10 - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put): - with patch("portolan_cli.push._fetch_remote_versions_async", return_value=(None, None)): + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put): + with patch( + "portolan_cli.sync.push._fetch_remote_versions_async", return_value=(None, None) + ): await push_async( catalog_root=many_files_catalog, collection="test", @@ -526,7 +528,7 @@ async def mock_put(store: Any, key: str, content: Any, **kwargs: Any) -> None: @pytest.mark.asyncio async def test_push_async_handles_rate_limit(self, async_catalog: Path) -> None: """Verify push_async() handles rate limit errors gracefully.""" - from portolan_cli.push import push_async + from portolan_cli.sync.push import push_async call_count = 0 @@ -539,8 +541,10 @@ async def mock_put_with_rate_limit( raise Exception("SlowDown: Rate limit exceeded") await asyncio.sleep(0.001) - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put_with_rate_limit): - with patch("portolan_cli.push._fetch_remote_versions_async", return_value=(None, None)): + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_with_rate_limit): + with patch( + "portolan_cli.sync.push._fetch_remote_versions_async", return_value=(None, None) + ): result = await push_async( catalog_root=async_catalog, collection="test", @@ -555,13 +559,15 @@ async def mock_put_with_rate_limit( @pytest.mark.asyncio async def test_push_async_circuit_breaker_on_failures(self, async_catalog: Path) -> None: """Verify push_async() circuit breaker trips on cascading failures.""" - from portolan_cli.push import push_async + from portolan_cli.sync.push import push_async async def mock_put_always_fails(store: Any, key: str, content: Any, **kwargs: Any) -> None: raise ConnectionError("Network unavailable") - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put_always_fails): - with patch("portolan_cli.push._fetch_remote_versions_async", return_value=(None, None)): + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_always_fails): + with patch( + "portolan_cli.sync.push._fetch_remote_versions_async", return_value=(None, None) + ): result = await push_async( catalog_root=async_catalog, collection="test", @@ -651,7 +657,7 @@ class TestPushVersionDiffRename: def test_push_version_diff_exists(self) -> None: """Verify PushVersionDiff class exists.""" - from portolan_cli.push import PushVersionDiff + from portolan_cli.sync.push import PushVersionDiff diff = PushVersionDiff( local_only=["1.0.0"], @@ -663,7 +669,7 @@ def test_push_version_diff_exists(self) -> None: def test_push_version_diff_has_conflict_property(self) -> None: """Verify PushVersionDiff.has_conflict works correctly.""" - from portolan_cli.push import PushVersionDiff + from portolan_cli.sync.push import PushVersionDiff # No conflict diff_no_conflict = PushVersionDiff( diff --git a/tests/unit/test_push_catalog_wide.py b/tests/unit/test_push_catalog_wide.py index 418c311c..e56393f8 100644 --- a/tests/unit/test_push_catalog_wide.py +++ b/tests/unit/test_push_catalog_wide.py @@ -13,7 +13,7 @@ import pytest -from portolan_cli.push import ( +from portolan_cli.sync.push import ( PushResult, discover_collections, push_all_collections, @@ -108,7 +108,7 @@ def test_returns_sorted_collections(self, tmp_path: Path) -> None: class TestPushAllCollections: """Tests for push_all_collections() function.""" - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_pushes_single_collection(self, mock_push: MagicMock, tmp_path: Path) -> None: """push_all_collections pushes a single collection successfully.""" _setup_valid_catalog(tmp_path) @@ -152,7 +152,7 @@ def test_pushes_single_collection(self, mock_push: MagicMock, tmp_path: Path) -> call_kwargs["include_catalog"] is False ) # push_all_collections uploads catalog.json once at end - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_pushes_multiple_collections_sequentially( self, mock_push: MagicMock, tmp_path: Path ) -> None: @@ -188,7 +188,7 @@ def test_pushes_multiple_collections_sequentially( assert mock_push.call_count == 3 - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_continues_on_individual_collection_failure( self, mock_push: MagicMock, tmp_path: Path ) -> None: @@ -237,7 +237,7 @@ def push_side_effect(**kwargs: Any) -> PushResult: assert mock_push.call_count == 3 - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_reports_all_errors_at_end(self, mock_push: MagicMock, tmp_path: Path) -> None: """push_all_collections collects and reports all errors.""" _setup_valid_catalog(tmp_path) @@ -279,7 +279,7 @@ def push_side_effect(**kwargs: Any) -> PushResult: assert "col1" in result.collection_errors assert "col2" in result.collection_errors - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_handles_empty_catalog(self, mock_push: MagicMock, tmp_path: Path) -> None: """push_all_collections handles empty catalog with warning.""" _setup_valid_catalog(tmp_path) @@ -300,7 +300,7 @@ def test_handles_empty_catalog(self, mock_push: MagicMock, tmp_path: Path) -> No mock_push.assert_not_called() - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_dry_run_mode(self, mock_push: MagicMock, tmp_path: Path) -> None: """push_all_collections passes dry_run flag to individual pushes.""" _setup_valid_catalog(tmp_path) diff --git a/tests/unit/test_push_default_remote.py b/tests/unit/test_push_default_remote.py index 80897e99..70ae5dd0 100644 --- a/tests/unit/test_push_default_remote.py +++ b/tests/unit/test_push_default_remote.py @@ -122,9 +122,9 @@ def test_push_uses_configured_remote_when_no_destination( self, runner: CliRunner, catalog_with_remote_config: Path ) -> None: """Push should use configured remote when no DESTINATION provided.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -155,9 +155,9 @@ def test_push_explicit_destination_overrides_config( self, runner: CliRunner, catalog_with_remote_config: Path ) -> None: """Explicit DESTINATION argument should override configured remote.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -222,11 +222,11 @@ def test_env_var_overrides_config_file( self, runner: CliRunner, catalog_with_remote_config: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """PORTOLAN_REMOTE env var should override config file.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult monkeypatch.setenv("PORTOLAN_REMOTE", "s3://env-bucket/catalog") - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -256,11 +256,11 @@ def test_explicit_arg_overrides_env_var( self, runner: CliRunner, catalog_with_remote_config: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Explicit DESTINATION should override PORTOLAN_REMOTE env var.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult monkeypatch.setenv("PORTOLAN_REMOTE", "s3://env-bucket/catalog") - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -295,9 +295,9 @@ def test_push_json_output_uses_configured_remote( self, runner: CliRunner, catalog_with_remote_config: Path ) -> None: """Push with --json should work with configured remote.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -360,9 +360,9 @@ def test_push_dry_run_with_configured_remote( self, runner: CliRunner, catalog_with_remote_config: Path ) -> None: """Push --dry-run should work with configured remote.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -405,7 +405,7 @@ class TestPushDefaultRemoteInvariants: @settings(max_examples=20, suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_configured_remote_used_when_no_arg(self, remote_url: str, tmp_path: Path) -> None: """Any valid remote URL via env var should be used when no arg provided.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult # Set up catalog (no sensitive settings in config.yaml per Issue #356) catalog_dir = tmp_path / "catalog" @@ -427,7 +427,7 @@ def test_configured_remote_used_when_no_arg(self, remote_url: str, tmp_path: Pat runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -460,7 +460,7 @@ def test_explicit_always_overrides_env( self, explicit_url: str, env_url: str, tmp_path: Path ) -> None: """Explicit destination should always override env var, regardless of values.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult # Set up catalog (no sensitive settings in config.yaml per Issue #356) catalog_dir = tmp_path / "catalog" @@ -482,7 +482,7 @@ def test_explicit_always_overrides_env( runner = CliRunner() - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -523,7 +523,7 @@ class TestPushDefaultRemoteIntegration: @pytest.mark.integration def test_push_workflow_with_env_var(self, runner: CliRunner, tmp_path: Path) -> None: """Full workflow: init -> set PORTOLAN_REMOTE env var -> push (no destination).""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult with runner.isolated_filesystem(temp_dir=tmp_path): # Initialize catalog @@ -543,7 +543,7 @@ def test_push_workflow_with_env_var(self, runner: CliRunner, tmp_path: Path) -> ) # Push without destination - should use PORTOLAN_REMOTE env var - with patch("portolan_cli.push.push_async", new_callable=AsyncMock) as mock_push: + with patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) as mock_push: mock_push.return_value = PushResult( success=True, files_uploaded=0, @@ -608,9 +608,9 @@ def test_sync_uses_configured_remote_when_no_destination( self, runner: CliRunner, catalog_with_remote_config: Path ) -> None: """Sync should use configured remote when no DESTINATION provided.""" - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = SyncResult( success=True, init_performed=False, @@ -642,9 +642,9 @@ def test_sync_explicit_destination_overrides_config( self, runner: CliRunner, catalog_with_remote_config: Path ) -> None: """Explicit DESTINATION argument should override configured remote.""" - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = SyncResult( success=True, init_performed=False, @@ -700,9 +700,9 @@ def test_sync_json_output_uses_configured_remote( self, runner: CliRunner, catalog_with_remote_config: Path ) -> None: """Sync with --json should work with configured remote.""" - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult - with patch("portolan_cli.sync.sync") as mock_sync: + with patch("portolan_cli.sync.core.sync") as mock_sync: mock_sync.return_value = SyncResult( success=True, init_performed=False, diff --git a/tests/unit/test_push_intermediate_catalogs.py b/tests/unit/test_push_intermediate_catalogs.py index 07a85783..98edef4b 100644 --- a/tests/unit/test_push_intermediate_catalogs.py +++ b/tests/unit/test_push_intermediate_catalogs.py @@ -16,7 +16,7 @@ import pytest -from portolan_cli.push import ( +from portolan_cli.sync.push import ( PushResult, _discover_intermediate_catalog_files, _discover_stac_files, @@ -126,8 +126,8 @@ def test_single_level_collection_has_no_intermediates(self, tmp_path: Path) -> N class TestCatalogWidePushIntermediateCatalogs: """push_all_collections must upload intermediate catalog.json (Issue #547, #552).""" - @patch("portolan_cli.push.obs.put") - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.obs.put") + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_uploads_intermediate_catalogs( self, mock_push: MagicMock, mock_obs_put: MagicMock, nested_catalog: Path ) -> None: @@ -150,8 +150,8 @@ def test_uploads_intermediate_catalogs( assert "catalog/tst/latest/catalog.json" in uploaded_keys, uploaded_keys assert "catalog/tst/README.md" in uploaded_keys, uploaded_keys - @patch("portolan_cli.push.obs.put") - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.obs.put") + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_versions_json_uploaded_after_intermediate_catalogs( self, mock_push: MagicMock, mock_obs_put: MagicMock, nested_catalog: Path ) -> None: @@ -181,7 +181,7 @@ def test_versions_json_uploaded_after_intermediate_catalogs( class TestCatalogWideDryRun: """Dry-run must report the intermediate catalogs it would upload.""" - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_dry_run_lists_intermediate_catalogs( self, mock_push: MagicMock, nested_catalog: Path, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/unit/test_push_metadata_sync.py b/tests/unit/test_push_metadata_sync.py index 59c83ce3..97fbf98e 100644 --- a/tests/unit/test_push_metadata_sync.py +++ b/tests/unit/test_push_metadata_sync.py @@ -115,7 +115,7 @@ class TestDiscoverCatalogFiles: @pytest.mark.unit def test_discovers_style_json(self, catalog_with_metadata: Path) -> None: """style.json files should be discovered for sync.""" - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files files = _discover_catalog_files( catalog_with_metadata, @@ -129,7 +129,7 @@ def test_discovers_style_json(self, catalog_with_metadata: Path) -> None: @pytest.mark.unit def test_discovers_thumbnail_files(self, catalog_with_metadata: Path) -> None: """Thumbnail files (*.thumb.*) should be discovered for sync.""" - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files files = _discover_catalog_files( catalog_with_metadata, @@ -143,7 +143,7 @@ def test_discovers_thumbnail_files(self, catalog_with_metadata: Path) -> None: @pytest.mark.unit def test_excludes_portolan_directory(self, catalog_with_metadata: Path) -> None: """.portolan/ directories should be excluded by default.""" - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files files = _discover_catalog_files( catalog_with_metadata, @@ -156,7 +156,7 @@ def test_excludes_portolan_directory(self, catalog_with_metadata: Path) -> None: @pytest.mark.unit def test_excludes_env_file(self, catalog_with_metadata: Path) -> None: """.env files should be excluded by default.""" - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files files = _discover_catalog_files( catalog_with_metadata, @@ -170,7 +170,7 @@ def test_excludes_env_file(self, catalog_with_metadata: Path) -> None: @pytest.mark.unit def test_excludes_pycache(self, catalog_with_metadata: Path) -> None: """__pycache__/ directories should be excluded by default.""" - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files files = _discover_catalog_files( catalog_with_metadata, @@ -184,7 +184,7 @@ def test_excludes_pycache(self, catalog_with_metadata: Path) -> None: @pytest.mark.unit def test_excludes_versions_json_file(self, catalog_with_metadata: Path) -> None: """versions.json should NOT be in metadata files (uploaded separately).""" - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files files = _discover_catalog_files( catalog_with_metadata, @@ -201,7 +201,7 @@ def test_excludes_versioned_assets_from_versions_json(self, tmp_path: Path) -> N These are handled separately by the existing asset upload logic. """ - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files # Create catalog with versioned asset catalog_root = tmp_path / "catalog" @@ -258,7 +258,7 @@ def test_excludes_versioned_assets_from_versions_json(self, tmp_path: Path) -> N @pytest.mark.unit def test_additional_exclude_patterns(self, catalog_with_metadata: Path) -> None: """Additional exclude patterns should be merged with defaults.""" - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files # Add a file that matches a custom pattern (catalog_with_metadata / "collection1" / "debug.log").write_text("debug info") @@ -279,7 +279,7 @@ def test_security_patterns_always_enforced(self, catalog_with_metadata: Path) -> Even when additional_exclude_patterns is provided, security-critical patterns must still be applied to prevent accidental secret upload. """ - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files # Add a .git directory with a config file git_dir = catalog_with_metadata / "collection1" / ".git" @@ -309,7 +309,7 @@ def test_security_patterns_always_enforced(self, catalog_with_metadata: Path) -> @pytest.mark.unit def test_symlinks_excluded(self, catalog_with_metadata: Path) -> None: """Symlinks should be excluded for security (could point outside catalog).""" - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files # Create a symlink in the collection symlink_path = catalog_with_metadata / "collection1" / "link-to-passwd" @@ -330,7 +330,7 @@ def test_symlinks_excluded(self, catalog_with_metadata: Path) -> None: @pytest.mark.unit def test_returns_relative_paths_structure(self, catalog_with_metadata: Path) -> None: """Discovered files should be absolute paths for upload.""" - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files files = _discover_catalog_files( catalog_with_metadata, @@ -348,7 +348,7 @@ def test_include_catalog_root_files(self, catalog_with_metadata: Path) -> None: Note: catalog.json and README.md are NOT included because they're handled separately by _push_all_upload_root_files for atomicity. """ - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files files = _discover_catalog_files( catalog_with_metadata, @@ -427,14 +427,14 @@ def test_upload_metadata_files_async_uploads_style_json( """_upload_metadata_files_async should upload style.json files.""" import asyncio - from portolan_cli.push import _upload_metadata_files_async + from portolan_cli.sync.push import _upload_metadata_files_async uploaded_keys: list[str] = [] async def mock_put_async(store, key, content): uploaded_keys.append(key) - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put_async): + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_async): mock_store = MagicMock() count, errors = asyncio.run( @@ -457,7 +457,7 @@ async def mock_put_async(store, key, content): @pytest.mark.unit def test_push_dry_run_shows_metadata_files(self, catalog_with_metadata: Path, capsys) -> None: """Dry run should list metadata files that would be synced.""" - from portolan_cli.push import push + from portolan_cli.sync.push import push push( catalog_root=catalog_with_metadata, @@ -473,7 +473,7 @@ def test_push_dry_run_shows_metadata_files(self, catalog_with_metadata: Path, ca @pytest.mark.unit def test_discover_catalog_files_finds_metadata(self, catalog_with_metadata: Path) -> None: """_discover_catalog_files should find style.json and thumbnails.""" - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files files = _discover_catalog_files( catalog_with_metadata, @@ -496,7 +496,7 @@ class TestPushAllMetadataSync: @pytest.mark.unit def test_discover_root_metadata_files(self, catalog_with_metadata: Path) -> None: """_discover_root_metadata_files should find root-level metadata.""" - from portolan_cli.push import _discover_root_metadata_files + from portolan_cli.sync.push import _discover_root_metadata_files files = _discover_root_metadata_files(catalog_with_metadata) @@ -513,7 +513,7 @@ def test_push_all_root_files_dry_run_shows_metadata( self, catalog_with_metadata: Path, capsys ) -> None: """_push_all_upload_root_files dry run should show metadata files.""" - from portolan_cli.push import _push_all_upload_root_files + from portolan_cli.sync.push import _push_all_upload_root_files stats: dict[str, Any] = { "failed": 0, @@ -549,7 +549,7 @@ class TestSecurityExcludePatterns: @pytest.mark.unit def test_security_patterns_exist(self) -> None: """Security patterns constant should exist and include critical files.""" - from portolan_cli.push import _SECURITY_EXCLUDE_PATTERNS + from portolan_cli.sync.push import _SECURITY_EXCLUDE_PATTERNS # These patterns MUST be in the security set assert ".env" in _SECURITY_EXCLUDE_PATTERNS @@ -560,7 +560,7 @@ def test_security_patterns_exist(self) -> None: @pytest.mark.unit def test_effective_patterns_include_security(self, tmp_path: Path) -> None: """_get_effective_exclude_patterns must always include security patterns.""" - from portolan_cli.push import ( + from portolan_cli.sync.push import ( _SECURITY_EXCLUDE_PATTERNS, _get_effective_exclude_patterns, ) @@ -589,7 +589,7 @@ class TestPushResultMetadataErrors: @pytest.mark.unit def test_push_result_has_metadata_errors_field(self) -> None: """PushResult should have metadata_errors field.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult result = PushResult( success=True, @@ -604,7 +604,7 @@ def test_push_result_has_metadata_errors_field(self) -> None: @pytest.mark.unit def test_push_result_no_metadata_errors(self) -> None: """PushResult with no metadata errors should return empty list.""" - from portolan_cli.push import PushResult + from portolan_cli.sync.push import PushResult result = PushResult( success=True, @@ -636,14 +636,14 @@ def test_upload_metadata_files_uses_posix_paths(self, catalog_with_metadata: Pat """_upload_metadata_files_async should use POSIX paths for object keys.""" import asyncio - from portolan_cli.push import _upload_metadata_files_async + from portolan_cli.sync.push import _upload_metadata_files_async uploaded_keys: list[str] = [] async def mock_put_async(store, key, content): uploaded_keys.append(key) - with patch("portolan_cli.push.obs.put_async", side_effect=mock_put_async): + with patch("portolan_cli.sync.push.obs.put_async", side_effect=mock_put_async): mock_store = MagicMock() asyncio.run( @@ -674,7 +674,7 @@ class TestDiscoverRootOnlyFiles: @pytest.mark.unit def test_discover_root_only_no_collection(self, catalog_with_metadata: Path) -> None: """With collection=None and include_catalog_root=True, only root files.""" - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files files = _discover_catalog_files( catalog_with_metadata, @@ -693,7 +693,7 @@ def test_discover_root_only_no_collection(self, catalog_with_metadata: Path) -> @pytest.mark.unit def test_discover_nothing_when_no_collection_no_root(self, catalog_with_metadata: Path) -> None: """With collection=None and include_catalog_root=False, should find nothing.""" - from portolan_cli.push import _discover_catalog_files + from portolan_cli.sync.push import _discover_catalog_files files = _discover_catalog_files( catalog_with_metadata, diff --git a/tests/unit/test_push_root_files.py b/tests/unit/test_push_root_files.py index 9a574123..7265b446 100644 --- a/tests/unit/test_push_root_files.py +++ b/tests/unit/test_push_root_files.py @@ -13,7 +13,7 @@ import pytest -from portolan_cli.push import ( +from portolan_cli.sync.push import ( PushResult, push_all_collections, ) @@ -96,8 +96,8 @@ def _create_collection(catalog_root: Path, name: str, with_readme: bool = False) class TestPushAllCollectionsRootFiles: """Tests for root-level file uploads in push_all_collections (Issue #357).""" - @patch("portolan_cli.push.obs.put") - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.obs.put") + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_uploads_all_root_files_after_collections( self, mock_push: MagicMock, mock_obs_put: MagicMock, tmp_path: Path ) -> None: @@ -140,8 +140,8 @@ def test_uploads_all_root_files_after_collections( f"Root versions.json should be uploaded, got: {uploaded_keys}" ) - @patch("portolan_cli.push.obs.put") - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.obs.put") + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_versions_json_uploaded_last( self, mock_push: MagicMock, mock_obs_put: MagicMock, tmp_path: Path ) -> None: @@ -180,8 +180,8 @@ def test_versions_json_uploaded_last( f"versions.json should be uploaded after catalog.json. Order: {uploaded_keys}" ) - @patch("portolan_cli.push.obs.put") - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.obs.put") + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_skips_optional_files_when_not_present( self, mock_push: MagicMock, mock_obs_put: MagicMock, tmp_path: Path ) -> None: @@ -218,8 +218,8 @@ def test_skips_optional_files_when_not_present( assert not any(k.endswith("/README.md") or k == "README.md" for k in uploaded_keys) assert not any(k.endswith("/versions.json") or k == "versions.json" for k in uploaded_keys) - @patch("portolan_cli.push.obs.put") - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.obs.put") + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_skips_root_files_when_collection_fails( self, mock_push: MagicMock, mock_obs_put: MagicMock, tmp_path: Path ) -> None: @@ -251,8 +251,8 @@ def test_skips_root_files_when_collection_fails( # No root files should be uploaded when collections fail mock_obs_put.assert_not_called() - @patch("portolan_cli.push.obs.put") - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.obs.put") + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_skips_root_files_when_zero_successful( self, mock_push: MagicMock, mock_obs_put: MagicMock, tmp_path: Path ) -> None: @@ -280,8 +280,8 @@ def test_skips_root_files_when_zero_successful( assert result.total_files_uploaded == 0 mock_obs_put.assert_not_called() - @patch("portolan_cli.push.obs.put") - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.obs.put") + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_dry_run_shows_all_root_files( self, mock_push: MagicMock, mock_obs_put: MagicMock, tmp_path: Path ) -> None: @@ -314,8 +314,8 @@ def test_dry_run_shows_all_root_files( # In dry_run, obs.put should NOT be called mock_obs_put.assert_not_called() - @patch("portolan_cli.push.obs.put") - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.obs.put") + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_counts_all_root_files_in_total( self, mock_push: MagicMock, mock_obs_put: MagicMock, tmp_path: Path ) -> None: @@ -346,8 +346,8 @@ def test_counts_all_root_files_in_total( # 5 from collection + 3 root files (README.md + catalog.json + versions.json) assert result.total_files_uploaded == 8 - @patch("portolan_cli.push.setup_store") - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.setup_store") + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_handles_upload_error( self, mock_push: MagicMock, mock_setup_store: MagicMock, tmp_path: Path ) -> None: @@ -380,9 +380,9 @@ def test_handles_upload_error( assert result.success is False assert "root_files" in result.collection_errors - @patch("portolan_cli.push.obs.put") - @patch("portolan_cli.push.setup_store") - @patch("portolan_cli.push.push_async", new_callable=AsyncMock) + @patch("portolan_cli.sync.push.obs.put") + @patch("portolan_cli.sync.push.setup_store") + @patch("portolan_cli.sync.push.push_async", new_callable=AsyncMock) def test_handles_partial_upload_failure( self, mock_push: MagicMock, diff --git a/tests/unit/test_push_verbose.py b/tests/unit/test_push_verbose.py index 738ded9e..dc26d500 100644 --- a/tests/unit/test_push_verbose.py +++ b/tests/unit/test_push_verbose.py @@ -15,7 +15,12 @@ import pytest -from portolan_cli.push import UploadMetrics, _upload_assets_async, format_file_size, format_speed +from portolan_cli.sync.push import ( + UploadMetrics, + _upload_assets_async, + format_file_size, + format_speed, +) if TYPE_CHECKING: pass @@ -167,7 +172,7 @@ async def test_default_mode_suppresses_success_messages( mock_store = MagicMock() - with patch("portolan_cli.push.obs.put_async", new_callable=AsyncMock): + with patch("portolan_cli.sync.push.obs.put_async", new_callable=AsyncMock): await _upload_assets_async( store=mock_store, catalog_root=mock_catalog, @@ -198,7 +203,7 @@ async def test_json_mode_suppresses_progress_bar( mock_store = MagicMock() - with patch("portolan_cli.push.obs.put_async", new_callable=AsyncMock): + with patch("portolan_cli.sync.push.obs.put_async", new_callable=AsyncMock): files_uploaded, errors, uploaded_keys, metrics = await _upload_assets_async( store=mock_store, catalog_root=mock_catalog, @@ -228,7 +233,7 @@ async def test_failures_always_shown( mock_store = MagicMock() with patch( - "portolan_cli.push.obs.put_async", + "portolan_cli.sync.push.obs.put_async", new_callable=AsyncMock, side_effect=Exception("Network error"), ): @@ -257,7 +262,7 @@ async def test_returns_upload_metrics(self, mock_catalog: Path) -> None: mock_store = MagicMock() - with patch("portolan_cli.push.obs.put_async", new_callable=AsyncMock): + with patch("portolan_cli.sync.push.obs.put_async", new_callable=AsyncMock): files_uploaded, errors, uploaded_keys, metrics = await _upload_assets_async( store=mock_store, catalog_root=mock_catalog, @@ -283,7 +288,7 @@ async def test_summary_shows_total_size_and_speed( mock_store = MagicMock() - with patch("portolan_cli.push.obs.put_async", new_callable=AsyncMock): + with patch("portolan_cli.sync.push.obs.put_async", new_callable=AsyncMock): _files_uploaded, _errors, _uploaded_keys, metrics = await _upload_assets_async( store=mock_store, catalog_root=mock_catalog, diff --git a/tests/unit/test_scan.py b/tests/unit/test_scan.py index 8e36e05b..499daab4 100644 --- a/tests/unit/test_scan.py +++ b/tests/unit/test_scan.py @@ -45,7 +45,7 @@ class TestEnums: def test_severity_has_error_warning_info(self) -> None: """Severity enum has ERROR, WARNING, INFO values.""" - from portolan_cli.scan import Severity + from portolan_cli.scan.core import Severity assert Severity.ERROR.value == "error" assert Severity.WARNING.value == "warning" @@ -53,7 +53,7 @@ def test_severity_has_error_warning_info(self) -> None: def test_issue_type_has_all_types(self) -> None: """IssueType enum has all 20 issue types.""" - from portolan_cli.scan import IssueType + from portolan_cli.scan.core import IssueType # All expected issue type values expected_values = { @@ -84,7 +84,7 @@ def test_issue_type_has_all_types(self) -> None: def test_format_type_has_vector_and_raster(self) -> None: """FormatType enum has VECTOR and RASTER values.""" - from portolan_cli.scan import FormatType + from portolan_cli.scan.core import FormatType assert FormatType.VECTOR.value == "vector" assert FormatType.RASTER.value == "raster" @@ -106,7 +106,7 @@ def test_pmtiles_not_in_overview_extensions(self) -> None: an overview/derivative format in scan.py, causing it to be skipped during scanning. PMTiles is a primary cloud-native geospatial format. """ - from portolan_cli.scan import OVERVIEW_EXTENSIONS + from portolan_cli.scan.core import OVERVIEW_EXTENSIONS assert ".pmtiles" not in OVERVIEW_EXTENSIONS @@ -116,7 +116,7 @@ def test_pmtiles_scanned_as_ready_file(self, tmp_path: Path) -> None: Regression test for issue #198: PMTiles was skipped during scan, preventing 'portolan add' from tracking them as primary assets. """ - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory collection_dir = tmp_path / "my-collection" collection_dir.mkdir() @@ -139,7 +139,7 @@ class TestScanOptions: def test_default_values(self) -> None: """ScanOptions has correct default values.""" - from portolan_cli.scan import ScanOptions + from portolan_cli.scan.core import ScanOptions opts = ScanOptions() # Original defaults @@ -158,7 +158,7 @@ def test_default_values(self) -> None: def test_custom_values(self) -> None: """ScanOptions accepts custom values.""" - from portolan_cli.scan import ScanOptions + from portolan_cli.scan.core import ScanOptions opts = ScanOptions( recursive=False, @@ -173,7 +173,7 @@ def test_custom_values(self) -> None: def test_new_options(self) -> None: """ScanOptions accepts new option values.""" - from portolan_cli.scan import ScanOptions + from portolan_cli.scan.core import ScanOptions opts = ScanOptions( show_all=True, @@ -194,14 +194,14 @@ def test_new_options(self) -> None: def test_unsafe_fix_requires_fix(self) -> None: """ScanOptions raises ValueError if unsafe_fix=True but fix=False.""" - from portolan_cli.scan import ScanOptions + from portolan_cli.scan.core import ScanOptions with pytest.raises(ValueError, match="--unsafe-fix requires --fix"): ScanOptions(unsafe_fix=True, fix=False) def test_is_frozen(self) -> None: """ScanOptions is immutable (frozen dataclass).""" - from portolan_cli.scan import ScanOptions + from portolan_cli.scan.core import ScanOptions opts = ScanOptions() with pytest.raises(AttributeError): @@ -214,7 +214,7 @@ class TestScannedFile: def test_creation(self, tmp_path: Path) -> None: """ScannedFile can be created with required fields.""" - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile sf = ScannedFile( path=tmp_path / "data.parquet", @@ -231,7 +231,7 @@ def test_creation(self, tmp_path: Path) -> None: def test_basename_property(self, tmp_path: Path) -> None: """ScannedFile.basename returns filename without directory.""" - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile sf = ScannedFile( path=tmp_path / "subdir" / "data.parquet", @@ -244,7 +244,7 @@ def test_basename_property(self, tmp_path: Path) -> None: def test_is_frozen(self, tmp_path: Path) -> None: """ScannedFile is immutable (frozen dataclass).""" - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile sf = ScannedFile( path=tmp_path / "data.parquet", @@ -263,7 +263,7 @@ class TestScanIssue: def test_creation(self, tmp_path: Path) -> None: """ScanIssue can be created with required fields.""" - from portolan_cli.scan import IssueType, ScanIssue, Severity + from portolan_cli.scan.core import IssueType, ScanIssue, Severity issue = ScanIssue( path=tmp_path / "bad.shp", @@ -279,7 +279,7 @@ def test_creation(self, tmp_path: Path) -> None: def test_with_suggestion(self, tmp_path: Path) -> None: """ScanIssue can include optional suggestion.""" - from portolan_cli.scan import IssueType, ScanIssue, Severity + from portolan_cli.scan.core import IssueType, ScanIssue, Severity issue = ScanIssue( path=tmp_path / "data (copy).parquet", @@ -293,7 +293,7 @@ def test_with_suggestion(self, tmp_path: Path) -> None: def test_is_frozen(self, tmp_path: Path) -> None: """ScanIssue is immutable (frozen dataclass).""" - from portolan_cli.scan import IssueType, ScanIssue, Severity + from portolan_cli.scan.core import IssueType, ScanIssue, Severity issue = ScanIssue( path=tmp_path / "bad.shp", @@ -312,7 +312,7 @@ class TestScanResult: def test_creation(self, tmp_path: Path) -> None: """ScanResult can be created with required fields.""" - from portolan_cli.scan import ScanResult + from portolan_cli.scan.core import ScanResult result = ScanResult( root=tmp_path, @@ -329,7 +329,7 @@ def test_creation(self, tmp_path: Path) -> None: def test_has_errors_property_false(self, tmp_path: Path) -> None: """ScanResult.has_errors is False when no ERROR issues.""" - from portolan_cli.scan import IssueType, ScanIssue, ScanResult, Severity + from portolan_cli.scan.core import IssueType, ScanIssue, ScanResult, Severity result = ScanResult( root=tmp_path, @@ -350,7 +350,7 @@ def test_has_errors_property_false(self, tmp_path: Path) -> None: def test_has_errors_property_true(self, tmp_path: Path) -> None: """ScanResult.has_errors is True when ERROR issues exist.""" - from portolan_cli.scan import IssueType, ScanIssue, ScanResult, Severity + from portolan_cli.scan.core import IssueType, ScanIssue, ScanResult, Severity result = ScanResult( root=tmp_path, @@ -371,7 +371,7 @@ def test_has_errors_property_true(self, tmp_path: Path) -> None: def test_error_count_property(self, tmp_path: Path) -> None: """ScanResult.error_count returns count of ERROR issues.""" - from portolan_cli.scan import IssueType, ScanIssue, ScanResult, Severity + from portolan_cli.scan.core import IssueType, ScanIssue, ScanResult, Severity result = ScanResult( root=tmp_path, @@ -406,7 +406,7 @@ def test_error_count_property(self, tmp_path: Path) -> None: def test_warning_count_property(self, tmp_path: Path) -> None: """ScanResult.warning_count returns count of WARNING issues.""" - from portolan_cli.scan import IssueType, ScanIssue, ScanResult, Severity + from portolan_cli.scan.core import IssueType, ScanIssue, ScanResult, Severity result = ScanResult( root=tmp_path, @@ -434,7 +434,7 @@ def test_warning_count_property(self, tmp_path: Path) -> None: def test_info_count_property(self, tmp_path: Path) -> None: """ScanResult.info_count returns count of INFO issues.""" - from portolan_cli.scan import IssueType, ScanIssue, ScanResult, Severity + from portolan_cli.scan.core import IssueType, ScanIssue, ScanResult, Severity result = ScanResult( root=tmp_path, @@ -455,7 +455,7 @@ def test_info_count_property(self, tmp_path: Path) -> None: def test_classification_summary_empty(self, tmp_path: Path) -> None: """ScanResult.classification_summary returns empty dict for empty result.""" - from portolan_cli.scan import ScanResult + from portolan_cli.scan.core import ScanResult result = ScanResult( root=tmp_path, @@ -470,7 +470,7 @@ def test_classification_summary_empty(self, tmp_path: Path) -> None: def test_classification_summary_counts_ready_as_geo_asset(self, tmp_path: Path) -> None: """ScanResult.classification_summary counts ready files as geo_asset.""" - from portolan_cli.scan import FormatType, ScannedFile, ScanResult + from portolan_cli.scan.core import FormatType, ScannedFile, ScanResult result = ScanResult( root=tmp_path, @@ -499,12 +499,12 @@ def test_classification_summary_counts_ready_as_geo_asset(self, tmp_path: Path) def test_classification_summary_counts_skipped_by_category(self, tmp_path: Path) -> None: """ScanResult.classification_summary counts skipped files by category.""" - from portolan_cli.scan import ScanResult - from portolan_cli.scan_classify import ( + from portolan_cli.scan.classify import ( FileCategory, SkippedFile, SkipReasonType, ) + from portolan_cli.scan.core import ScanResult result = ScanResult( root=tmp_path, @@ -541,7 +541,7 @@ def test_classification_summary_counts_skipped_by_category(self, tmp_path: Path) def test_classification_summary_handles_legacy_paths(self, tmp_path: Path) -> None: """ScanResult.classification_summary handles legacy Path objects in skipped.""" - from portolan_cli.scan import ScanResult + from portolan_cli.scan.core import ScanResult result = ScanResult( root=tmp_path, @@ -558,7 +558,7 @@ def test_classification_summary_handles_legacy_paths(self, tmp_path: Path) -> No def test_to_dict_returns_json_serializable(self, tmp_path: Path) -> None: """ScanResult.to_dict returns JSON-serializable dictionary.""" - from portolan_cli.scan import ( + from portolan_cli.scan.core import ( FormatType, IssueType, ScanIssue, @@ -616,7 +616,7 @@ def test_to_dict_returns_json_serializable(self, tmp_path: Path) -> None: def test_to_dict_includes_proposed_fixes(self, tmp_path: Path) -> None: """ScanResult.to_dict includes proposed_fixes when present.""" - from portolan_cli.scan import ( + from portolan_cli.scan.core import ( FormatType, IssueType, ScanIssue, @@ -624,7 +624,7 @@ def test_to_dict_includes_proposed_fixes(self, tmp_path: Path) -> None: ScanResult, Severity, ) - from portolan_cli.scan_fix import FixCategory, ProposedFix + from portolan_cli.scan.fix import FixCategory, ProposedFix issue = ScanIssue( path=tmp_path / "bad name.geojson", @@ -669,7 +669,7 @@ def test_to_dict_includes_proposed_fixes(self, tmp_path: Path) -> None: def test_to_dict_includes_applied_fixes(self, tmp_path: Path) -> None: """ScanResult.to_dict includes applied_fixes when present.""" - from portolan_cli.scan import ( + from portolan_cli.scan.core import ( FormatType, IssueType, ScanIssue, @@ -677,7 +677,7 @@ def test_to_dict_includes_applied_fixes(self, tmp_path: Path) -> None: ScanResult, Severity, ) - from portolan_cli.scan_fix import FixCategory, ProposedFix + from portolan_cli.scan.fix import FixCategory, ProposedFix issue = ScanIssue( path=tmp_path / "bad name.geojson", @@ -722,7 +722,7 @@ def test_to_dict_includes_applied_fixes(self, tmp_path: Path) -> None: def test_to_dict_excludes_empty_fixes(self, tmp_path: Path) -> None: """ScanResult.to_dict excludes proposed_fixes/applied_fixes when empty.""" - from portolan_cli.scan import ScanResult + from portolan_cli.scan.core import ScanResult result = ScanResult( root=tmp_path, @@ -745,7 +745,7 @@ class TestScanResultIsRelativeTo: def test_is_relative_to_true(self, tmp_path: Path) -> None: """_is_relative_to returns True for child paths.""" - from portolan_cli.scan import ScanResult + from portolan_cli.scan.core import ScanResult child = tmp_path / "subdir" / "file.txt" result = ScanResult._is_relative_to(child, tmp_path) @@ -753,7 +753,7 @@ def test_is_relative_to_true(self, tmp_path: Path) -> None: def test_is_relative_to_false(self, tmp_path: Path) -> None: """_is_relative_to returns False for unrelated paths.""" - from portolan_cli.scan import ScanResult + from portolan_cli.scan.core import ScanResult other = Path("/completely/different/path") result = ScanResult._is_relative_to(other, tmp_path) @@ -761,7 +761,7 @@ def test_is_relative_to_false(self, tmp_path: Path) -> None: def test_is_relative_to_same_path(self, tmp_path: Path) -> None: """_is_relative_to returns True when path equals base.""" - from portolan_cli.scan import ScanResult + from portolan_cli.scan.core import ScanResult result = ScanResult._is_relative_to(tmp_path, tmp_path) assert result is True @@ -778,14 +778,14 @@ class TestBasicScan: def test_scan_nonexistent_path_raises_file_not_found(self) -> None: """scan_directory raises FileNotFoundError for non-existent path.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory with pytest.raises(FileNotFoundError): scan_directory(Path("/nonexistent/path/that/does/not/exist")) def test_scan_file_raises_not_a_directory(self, tmp_path: Path) -> None: """scan_directory raises NotADirectoryError when given a file.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory test_file = tmp_path / "test.txt" test_file.write_text("content") @@ -800,7 +800,7 @@ def test_scan_clean_flat_returns_three_files(self, fixtures_dir: Path) -> None: triggers a "multiple primaries" warning per FR-014. This is expected behavior, not an error in the scan. """ - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory result = scan_directory(fixtures_dir / "clean_flat") @@ -812,7 +812,7 @@ def test_scan_clean_flat_returns_three_files(self, fixtures_dir: Path) -> None: def test_scan_unsupported_returns_correct_counts(self, fixtures_dir: Path) -> None: """scan_directory on unsupported fixture returns correct ready/skipped.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory result = scan_directory(fixtures_dir / "unsupported") @@ -823,7 +823,7 @@ def test_scan_unsupported_returns_correct_counts(self, fixtures_dir: Path) -> No def test_scan_detects_vector_format(self, fixtures_dir: Path) -> None: """scan_directory correctly identifies vector formats.""" - from portolan_cli.scan import FormatType, scan_directory + from portolan_cli.scan.core import FormatType, scan_directory result = scan_directory(fixtures_dir / "clean_flat") @@ -833,7 +833,7 @@ def test_scan_detects_vector_format(self, fixtures_dir: Path) -> None: def test_scan_detects_raster_format(self, fixtures_dir: Path) -> None: """scan_directory correctly identifies raster formats.""" - from portolan_cli.scan import FormatType, scan_directory + from portolan_cli.scan.core import FormatType, scan_directory result = scan_directory(fixtures_dir / "mixed_formats") @@ -842,7 +842,7 @@ def test_scan_detects_raster_format(self, fixtures_dir: Path) -> None: def test_scan_complete_shapefile_counts_as_one(self, fixtures_dir: Path) -> None: """scan_directory on complete_shapefile counts .shp as one file, sidecars skipped.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory result = scan_directory(fixtures_dir / "complete_shapefile") @@ -864,7 +864,7 @@ class TestIssueDetection: def test_detect_incomplete_shapefile(self, fixtures_dir: Path) -> None: """scan_directory detects incomplete shapefile (missing .dbf).""" - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory result = scan_directory(fixtures_dir / "incomplete_shapefile") @@ -877,7 +877,7 @@ def test_detect_incomplete_shapefile(self, fixtures_dir: Path) -> None: def test_detect_zero_byte_file(self, tmp_path: Path) -> None: """scan_directory detects zero-byte files.""" - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory # Create a zero-byte geospatial file zero_file = tmp_path / "empty.geojson" @@ -891,7 +891,7 @@ def test_detect_zero_byte_file(self, tmp_path: Path) -> None: def test_detect_invalid_characters(self, fixtures_dir: Path) -> None: """scan_directory detects invalid characters in filenames.""" - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory result = scan_directory(fixtures_dir / "invalid_chars") @@ -901,7 +901,7 @@ def test_detect_invalid_characters(self, fixtures_dir: Path) -> None: def test_detect_multiple_primaries(self, fixtures_dir: Path) -> None: """scan_directory detects multiple primary assets in same directory.""" - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory result = scan_directory(fixtures_dir / "multiple_primaries") @@ -911,7 +911,7 @@ def test_detect_multiple_primaries(self, fixtures_dir: Path) -> None: def test_multiple_primaries_suggestion_no_bundle_flag(self, fixtures_dir: Path) -> None: """Multiple primaries suggestion should not mention --bundle flag.""" - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory result = scan_directory(fixtures_dir / "multiple_primaries") @@ -924,7 +924,7 @@ def test_multiple_primaries_suggestion_no_bundle_flag(self, fixtures_dir: Path) def test_detect_long_path(self, tmp_path: Path) -> None: """scan_directory detects very long paths (200+ chars).""" - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory # Create a deeply nested path to exceed 200 chars (use .geojson since .parquet needs metadata) long_name = "a" * 50 @@ -944,7 +944,7 @@ def test_sibling_directories_same_filename_no_warning(self, fixtures_dir: Path) This is intentional organization (e.g., 2010/radios.parquet and 2022/radios.parquet). Only duplicates within the SAME directory should warn. """ - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory result = scan_directory(fixtures_dir / "duplicate_basenames") @@ -960,7 +960,7 @@ def test_detect_duplicate_basenames_same_directory(self, tmp_path: Path) -> None the scan should detect them as duplicates. On case-insensitive filesystems (macOS, Windows), only one file can exist, so we skip the test there. """ - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory # Create two files with same basename (case-insensitive) in SAME directory (tmp_path / "data.geojson").write_text('{"type": "FeatureCollection", "features": []}') @@ -983,7 +983,7 @@ def test_detect_duplicate_basenames_same_directory(self, tmp_path: Path) -> None def test_detect_mixed_formats(self, fixtures_dir: Path) -> None: """scan_directory detects mixed raster/vector in same directory.""" - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory result = scan_directory(fixtures_dir / "mixed_formats") @@ -998,7 +998,7 @@ class TestStructureValidation: def test_orphan_sidecar_detected(self, tmp_path: Path) -> None: """Sidecar files without a primary (.shp) are flagged as orphan.""" - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory # Create sidecar files without matching .shp (tmp_path / "orphan.dbf").write_bytes(b"\x00" * 100) @@ -1014,7 +1014,7 @@ def test_orphan_sidecar_detected(self, tmp_path: Path) -> None: def test_multiple_primaries_detected(self, tmp_path: Path) -> None: """Multiple primary assets in one directory are flagged.""" - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory # Create multiple geospatial files in same directory (tmp_path / "dataset1.geojson").write_text('{"type": "FeatureCollection", "features": []}') @@ -1031,7 +1031,7 @@ def test_multiple_primaries_detected(self, tmp_path: Path) -> None: def test_incomplete_shapefile_detected(self, tmp_path: Path) -> None: """Shapefile missing required sidecars (.dbf, .shx) is flagged as incomplete.""" - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory # Create a .shp file without required sidecars (tmp_path / "incomplete.shp").write_bytes(b"\x00" * 100) @@ -1051,7 +1051,7 @@ def test_incomplete_shapefile_detected(self, tmp_path: Path) -> None: def test_mixed_formats_detected(self, tmp_path: Path) -> None: """Mixed raster/vector in same directory is flagged.""" - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory # Create both vector and raster files in same directory (tmp_path / "vector.geojson").write_text('{"type": "FeatureCollection", "features": []}') @@ -1069,7 +1069,7 @@ def test_mixed_flat_multiitem_detected(self, tmp_path: Path) -> None: This indicates an unclear catalog structure - is the root a single item with multiple files, or is each subdirectory a separate item? """ - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory # Create files at root level (tmp_path / "root_data.geojson").write_text('{"type": "FeatureCollection", "features": []}') @@ -1099,7 +1099,7 @@ class TestWindowsReservedNames: def test_con_geojson_detected(self, tmp_path: Path) -> None: """Windows reserved name CON.geojson is flagged.""" - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory (tmp_path / "CON.geojson").write_text('{"type": "FeatureCollection", "features": []}') @@ -1113,7 +1113,7 @@ def test_con_geojson_detected(self, tmp_path: Path) -> None: def test_prn_parquet_detected(self, tmp_path: Path) -> None: """Windows reserved name PRN.parquet is flagged.""" - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory (tmp_path / "PRN.parquet").write_bytes(b"\x00" * 100) @@ -1126,7 +1126,7 @@ def test_prn_parquet_detected(self, tmp_path: Path) -> None: def test_nul_data_not_flagged(self, tmp_path: Path) -> None: """File containing reserved name (nul_data.parquet) is NOT flagged.""" - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory # "nul_data" contains "nul" but is not exactly a reserved name (tmp_path / "nul_data.parquet").write_bytes(b"\x00" * 100) @@ -1140,7 +1140,7 @@ def test_nul_data_not_flagged(self, tmp_path: Path) -> None: def test_aux_directory_detected(self, tmp_path: Path) -> None: """Windows reserved name AUX as directory is flagged.""" - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory aux_dir = tmp_path / "AUX" aux_dir.mkdir() @@ -1166,7 +1166,7 @@ class TestJsonOutput: def test_to_dict_has_required_fields(self, fixtures_dir: Path) -> None: """ScanResult.to_dict contains all required fields.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory result = scan_directory(fixtures_dir / "clean_flat") d = result.to_dict() @@ -1184,7 +1184,7 @@ def test_to_dict_has_required_fields(self, fixtures_dir: Path) -> None: def test_to_dict_issues_have_required_fields(self, fixtures_dir: Path) -> None: """Each issue in to_dict has required fields.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory result = scan_directory(fixtures_dir / "incomplete_shapefile") d = result.to_dict() @@ -1208,7 +1208,7 @@ class TestDepthControl: def test_no_recursive_scans_only_immediate(self, fixtures_dir: Path) -> None: """--no-recursive scans only immediate directory.""" - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory opts = ScanOptions(recursive=False) result = scan_directory(fixtures_dir / "nested", opts) @@ -1220,7 +1220,7 @@ def test_no_recursive_scans_only_immediate(self, fixtures_dir: Path) -> None: def test_max_depth_zero_scans_only_target(self, fixtures_dir: Path) -> None: """--max-depth=0 scans only the target directory.""" - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory opts = ScanOptions(max_depth=0) result = scan_directory(fixtures_dir / "nested", opts) @@ -1230,7 +1230,7 @@ def test_max_depth_zero_scans_only_target(self, fixtures_dir: Path) -> None: def test_max_depth_limits_recursion(self, fixtures_dir: Path) -> None: """--max-depth=N limits recursion to N levels.""" - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory # nested/ structure: nested/census/2020/boundaries.geojson (depth 3 from nested/) opts = ScanOptions(max_depth=1) @@ -1242,7 +1242,7 @@ def test_max_depth_limits_recursion(self, fixtures_dir: Path) -> None: def test_full_recursion_finds_all_files(self, fixtures_dir: Path) -> None: """Default recursion finds all files in nested structure.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory result = scan_directory(fixtures_dir / "nested") @@ -1261,7 +1261,7 @@ class TestHiddenAndSymlinks: def test_hidden_files_skipped_by_default(self, tmp_path: Path) -> None: """Hidden files are skipped by default.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # Create hidden and visible files (use .geojson since .parquet needs geo metadata) (tmp_path / ".hidden.geojson").write_text('{"type": "FeatureCollection", "features": []}') @@ -1275,7 +1275,7 @@ def test_hidden_files_skipped_by_default(self, tmp_path: Path) -> None: def test_include_hidden_includes_hidden_files(self, tmp_path: Path) -> None: """--include-hidden includes hidden files.""" - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory # Create hidden and visible files (use .geojson since .parquet needs geo metadata) (tmp_path / ".hidden.geojson").write_text('{"type": "FeatureCollection", "features": []}') @@ -1289,7 +1289,7 @@ def test_include_hidden_includes_hidden_files(self, tmp_path: Path) -> None: def test_symlinks_skipped_by_default(self, tmp_path: Path) -> None: """Symlinks are skipped by default.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # Create a real file and a symlink (use .geojson since .parquet needs geo metadata) real_file = tmp_path / "real.geojson" @@ -1305,7 +1305,7 @@ def test_symlinks_skipped_by_default(self, tmp_path: Path) -> None: def test_follow_symlinks_includes_symlinks(self, tmp_path: Path) -> None: """--follow-symlinks includes symlinked files.""" - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory # Create a real file and a symlink (use .geojson since .parquet needs geo metadata) real_file = tmp_path / "real.geojson" @@ -1321,7 +1321,7 @@ def test_follow_symlinks_includes_symlinks(self, tmp_path: Path) -> None: def test_symlink_loop_detected(self, tmp_path: Path) -> None: """Symlink loops are detected and reported as errors.""" - from portolan_cli.scan import IssueType, ScanOptions, Severity, scan_directory + from portolan_cli.scan.core import IssueType, ScanOptions, Severity, scan_directory # Create a symlink loop: dir_a -> dir_b -> dir_a dir_a = tmp_path / "dir_a" @@ -1353,7 +1353,7 @@ def test_geoparquet_file_is_primary_asset(self, tmp_path: Path) -> None: import pyarrow as pa import pyarrow.parquet as pq - from portolan_cli.scan import FormatType, scan_directory + from portolan_cli.scan.core import FormatType, scan_directory # Create a minimal GeoParquet file with geo metadata table = pa.table({"name": ["test"], "value": [1]}) @@ -1377,7 +1377,7 @@ def test_regular_parquet_file_is_skipped(self, tmp_path: Path) -> None: import pyarrow as pa import pyarrow.parquet as pq - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # Create a regular Parquet file without geo metadata table = pa.table({"name": ["test"], "value": [1]}) @@ -1397,7 +1397,7 @@ def test_mixed_parquet_types_only_geoparquet_ready(self, tmp_path: Path) -> None import pyarrow as pa import pyarrow.parquet as pq - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # Create GeoParquet file geo_table = pa.table({"name": ["test"]}) @@ -1435,7 +1435,7 @@ def test_pmtiles_files_are_accepted_as_primary_assets(self, tmp_path: Path) -> N an overview/derivative and skipped. It is now treated as a primary GEO_ASSET alongside FlatGeobuf (.fgb). """ - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory # Create a PMTiles file (just needs to exist for extension detection) pmtiles_path = tmp_path / "data.pmtiles" @@ -1461,7 +1461,7 @@ def test_pmtiles_counts_as_primary_alongside_geoparquet(self, tmp_path: Path) -> import pyarrow as pa import pyarrow.parquet as pq - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory # Create one GeoParquet and one PMTiles in same directory geo_table = pa.table({"name": ["test"]}) @@ -1505,7 +1505,7 @@ def test_scan_no_execute_directory_emits_warning(self, tmp_path: Path) -> None: On Linux, os.scandir may succeed (listing entries) but stat() fails. This tests that stat() failures are properly reported. """ - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory # Create a subdirectory with a file, then remove execute permission subdir = tmp_path / "no_exec" @@ -1567,7 +1567,7 @@ def test_scan_broken_symlink_emits_warning(self, tmp_path: Path) -> None: returns False for them, so they never enter the file processing branch. Expected: Should emit BROKEN_SYMLINK issue. """ - from portolan_cli.scan import IssueType, ScanOptions, Severity, scan_directory + from portolan_cli.scan.core import IssueType, ScanOptions, Severity, scan_directory # Create a broken symlink (pointing to non-existent target) broken = tmp_path / "broken.geojson" @@ -1591,7 +1591,7 @@ def test_scan_broken_symlink_emits_warning(self, tmp_path: Path) -> None: def test_scan_broken_symlink_continues_scanning(self, tmp_path: Path) -> None: """Scan continues processing after encountering broken symlink.""" - from portolan_cli.scan import IssueType, ScanOptions, scan_directory + from portolan_cli.scan.core import IssueType, ScanOptions, scan_directory # Create broken symlink between valid files (tmp_path / "first.geojson").write_text('{"type": "FeatureCollection", "features": []}') @@ -1617,7 +1617,7 @@ def test_scan_valid_symlink_followed(self, tmp_path: Path) -> None: This is existing behavior - ensure it's preserved. """ - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory # Create a real file and a valid symlink real = tmp_path / "real.geojson" @@ -1637,7 +1637,7 @@ def test_scan_symlink_not_followed_by_default(self, tmp_path: Path) -> None: When follow_symlinks=False, symlinks are simply skipped. No warning is needed because this is intentional. """ - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory real = tmp_path / "real.geojson" real.write_text('{"type": "FeatureCollection", "features": []}') @@ -1656,7 +1656,7 @@ def test_scan_broken_symlink_ignored_when_not_following(self, tmp_path: Path) -> This is intentional - if user doesn't want to follow symlinks, we don't need to warn about broken ones. """ - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory # Create a broken symlink broken = tmp_path / "broken.geojson" @@ -1677,7 +1677,7 @@ def test_scan_broken_symlink_ignored_when_not_following(self, tmp_path: Path) -> def test_scan_broken_symlink_shows_target_in_message(self, tmp_path: Path) -> None: """Broken symlink warning message includes the target path.""" - from portolan_cli.scan import IssueType, ScanOptions, scan_directory + from portolan_cli.scan.core import IssueType, ScanOptions, scan_directory broken = tmp_path / "broken.geojson" target = tmp_path / "ghost_target.geojson" @@ -1696,7 +1696,7 @@ def test_scan_broken_symlink_shows_target_in_message(self, tmp_path: Path) -> No def test_scan_symlink_chain_resolved(self, tmp_path: Path) -> None: """Deep symlink chains are followed correctly.""" - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory # Create a chain: link_a -> link_b -> link_c -> actual.geojson actual = tmp_path / "actual.geojson" @@ -1719,7 +1719,7 @@ def test_scan_symlink_chain_resolved(self, tmp_path: Path) -> None: def test_scan_broken_symlink_in_report(self, tmp_path: Path) -> None: """Broken symlink issues appear in the final scan report.""" - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory broken = tmp_path / "broken.geojson" broken.symlink_to(tmp_path / "nonexistent.geojson") @@ -1740,7 +1740,7 @@ class TestCollectionIdValidation: def test_scan_detects_invalid_collection_id_uppercase(self, tmp_path: Path) -> None: """Scan should detect uppercase in collection directory names.""" - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory # Create directory structure: MyCollection/data.geojson collection_dir = tmp_path / "MyCollection" @@ -1761,7 +1761,7 @@ def test_scan_detects_invalid_collection_id_uppercase(self, tmp_path: Path) -> N def test_scan_detects_invalid_collection_id_spaces(self, tmp_path: Path) -> None: """Scan should detect spaces in collection directory names.""" - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory # Create directory structure: "My Data"/data.geojson collection_dir = tmp_path / "My Data" @@ -1780,7 +1780,7 @@ def test_scan_detects_invalid_collection_id_spaces(self, tmp_path: Path) -> None def test_scan_allows_collection_id_starting_with_number(self, tmp_path: Path) -> None: """Scan should NOT flag collection IDs starting with numbers (ADR-0032).""" - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory # Create directory structure: 2020-census/data.geojson # Per ADR-0032: numeric starts are valid for year-based organization @@ -1800,7 +1800,7 @@ def test_scan_allows_collection_id_starting_with_number(self, tmp_path: Path) -> def test_scan_valid_collection_id_no_issue(self, tmp_path: Path) -> None: """Scan should not flag valid collection IDs.""" - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory # Create directory structure: census-2020/data.geojson collection_dir = tmp_path / "census-2020" @@ -1818,7 +1818,7 @@ def test_scan_valid_collection_id_no_issue(self, tmp_path: Path) -> None: def test_scan_root_level_files_no_collection_check(self, tmp_path: Path) -> None: """Files at root level should not trigger collection ID check.""" - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory # Create file directly at root (no collection subdirectory) (tmp_path / "data.geojson").write_text('{"type": "FeatureCollection", "features": []}') diff --git a/tests/unit/test_scan_classify.py b/tests/unit/test_scan_classify.py index 89075a6d..421cd8db 100644 --- a/tests/unit/test_scan_classify.py +++ b/tests/unit/test_scan_classify.py @@ -9,7 +9,7 @@ import pytest -from portolan_cli.scan_classify import ( +from portolan_cli.scan.classify import ( GEO_ASSET_EXTENSIONS, FileCategory, SkippedFile, @@ -196,7 +196,7 @@ def test_pmtiles_in_geo_asset_extensions(self) -> None: GEO_ASSET_EXTENSIONS, causing scan to classify .pmtiles files as VISUALIZATION and skip them instead of treating them as primary assets. """ - from portolan_cli.scan_classify import GEO_ASSET_EXTENSIONS + from portolan_cli.scan.classify import GEO_ASSET_EXTENSIONS assert ".pmtiles" in GEO_ASSET_EXTENSIONS @@ -207,7 +207,7 @@ def test_pmtiles_not_in_viz_extensions(self) -> None: VIZ_EXTENSIONS alongside .mbtiles. PMTiles is a primary cloud-native geospatial format, not a visualization-only derivative. """ - from portolan_cli.scan_classify import VIZ_EXTENSIONS + from portolan_cli.scan.classify import VIZ_EXTENSIONS assert ".pmtiles" not in VIZ_EXTENSIONS diff --git a/tests/unit/test_scan_coverage.py b/tests/unit/test_scan_coverage.py index 7e555871..51da72c7 100644 --- a/tests/unit/test_scan_coverage.py +++ b/tests/unit/test_scan_coverage.py @@ -12,7 +12,7 @@ from hypothesis import assume, given, settings from hypothesis import strategies as st -from portolan_cli.scan import ( +from portolan_cli.scan.core import ( FormatType, IssueType, ScanIssue, @@ -30,7 +30,7 @@ is_windows_reserved_name, scan_directory, ) -from portolan_cli.scan_detect import ( +from portolan_cli.scan.detect import ( DualFormatPair, SpecialFormat, detect_hive_partitions, @@ -569,7 +569,7 @@ def test_get_dir_size_oserror_on_scandir( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """OSError during scandir should be caught and return 0.""" - from portolan_cli import scan as scan_module + from portolan_cli.scan import core as scan_module # Create a FileGDB structure gdb_dir = tmp_path / "test.gdb" @@ -592,7 +592,7 @@ def test_get_dir_size_oserror_on_stat( """OSError during stat should be caught and file skipped.""" import os - from portolan_cli import scan as scan_module + from portolan_cli.scan import core as scan_module # Create a FileGDB structure gdb_dir = tmp_path / "test.gdb" diff --git a/tests/unit/test_scan_detect.py b/tests/unit/test_scan_detect.py index 3c5348be..3447c7b4 100644 --- a/tests/unit/test_scan_detect.py +++ b/tests/unit/test_scan_detect.py @@ -9,7 +9,7 @@ import pytest -from portolan_cli.scan_detect import ( +from portolan_cli.scan.detect import ( detect_dual_formats, detect_filegdb, detect_hive_partitions, @@ -210,7 +210,7 @@ class TestDualFormat: def test_detect_dual_formats_finds_same_basename(self, tmp_path: Path) -> None: """detect_dual_formats finds files with same basename, different extensions.""" - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile files = [ ScannedFile( @@ -237,7 +237,7 @@ def test_detect_dual_formats_finds_same_basename(self, tmp_path: Path) -> None: def test_detect_dual_formats_ignores_different_basenames(self, tmp_path: Path) -> None: """detect_dual_formats ignores files with different basenames.""" - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile files = [ ScannedFile( @@ -261,7 +261,7 @@ def test_detect_dual_formats_ignores_different_basenames(self, tmp_path: Path) - def test_detect_dual_formats_ignores_cross_type_pairs(self, tmp_path: Path) -> None: """detect_dual_formats ignores vector/raster pairs (intentional pairing).""" - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile files = [ ScannedFile( diff --git a/tests/unit/test_scan_edge_cases.py b/tests/unit/test_scan_edge_cases.py index 49d8a016..1e765389 100644 --- a/tests/unit/test_scan_edge_cases.py +++ b/tests/unit/test_scan_edge_cases.py @@ -4,7 +4,7 @@ import pytest -from portolan_cli.scan_detect import detect_stac_catalogs +from portolan_cli.scan.detect import detect_stac_catalogs pytestmark = pytest.mark.unit diff --git a/tests/unit/test_scan_filegdb_discovery.py b/tests/unit/test_scan_filegdb_discovery.py index 8ab2eb78..22eb536f 100644 --- a/tests/unit/test_scan_filegdb_discovery.py +++ b/tests/unit/test_scan_filegdb_discovery.py @@ -12,8 +12,8 @@ from hypothesis import given, settings from hypothesis import strategies as st -from portolan_cli.scan import ScanOptions, scan_directory -from portolan_cli.scan_detect import is_filegdb +from portolan_cli.scan.core import ScanOptions, scan_directory +from portolan_cli.scan.detect import is_filegdb @pytest.mark.unit diff --git a/tests/unit/test_scan_fix.py b/tests/unit/test_scan_fix.py index 02f0f0db..bbad2b44 100644 --- a/tests/unit/test_scan_fix.py +++ b/tests/unit/test_scan_fix.py @@ -16,8 +16,8 @@ import pytest -from portolan_cli.scan import IssueType, ScanIssue, Severity -from portolan_cli.scan_fix import ( +from portolan_cli.scan.core import IssueType, ScanIssue, Severity +from portolan_cli.scan.fix import ( FixCategory, _compute_safe_rename, apply_safe_fixes, @@ -147,7 +147,7 @@ def test_curly_braces_sanitized(self, tmp_path: Path) -> None: def test_path_traversal_sanitized(self, tmp_path: Path) -> None: """Path separators should be sanitized for defense-in-depth.""" - from portolan_cli.scan_fix import _sanitize_filename + from portolan_cli.scan.fix import _sanitize_filename # Test sanitization directly - path separators should become underscores result = _sanitize_filename("test_data") @@ -306,7 +306,7 @@ def test_find_sidecars_for_shapefile(self, tmp_path: Path) -> None: (tmp_path / "data with spaces.prj").touch() # Import function being tested - from portolan_cli.scan_fix import _find_sidecars + from portolan_cli.scan.fix import _find_sidecars sidecars = _find_sidecars(tmp_path / "data with spaces.shp") @@ -332,7 +332,7 @@ def test_find_sidecars_includes_qix(self, tmp_path: Path) -> None: (tmp_path / "data.shp").touch() (tmp_path / "data.qix").touch() - from portolan_cli.scan_fix import _find_sidecars + from portolan_cli.scan.fix import _find_sidecars sidecars = _find_sidecars(tmp_path / "data.shp") @@ -344,7 +344,7 @@ def test_find_sidecars_includes_shp_xml(self, tmp_path: Path) -> None: (tmp_path / "data.shp").touch() (tmp_path / "data.shp.xml").touch() - from portolan_cli.scan_fix import _find_sidecars + from portolan_cli.scan.fix import _find_sidecars sidecars = _find_sidecars(tmp_path / "data.shp") @@ -357,7 +357,7 @@ def test_find_sidecars_includes_qmd(self, tmp_path: Path) -> None: (tmp_path / "data.shp").touch() (tmp_path / "data.qmd").touch() - from portolan_cli.scan_fix import _find_sidecars + from portolan_cli.scan.fix import _find_sidecars sidecars = _find_sidecars(tmp_path / "data.shp") @@ -770,7 +770,7 @@ class TestApplyRenameEdgeCases: def test_rename_success_uses_atomic_operation(self, tmp_path: Path) -> None: """Successful rename should use atomic os.rename.""" - from portolan_cli.scan_fix import _apply_rename + from portolan_cli.scan.fix import _apply_rename old_path = tmp_path / "old.geojson" new_path = tmp_path / "new.geojson" @@ -795,7 +795,7 @@ def test_rename_returns_false_when_target_exists_on_windows(self, tmp_path: Path """ import sys - from portolan_cli.scan_fix import _apply_rename + from portolan_cli.scan.fix import _apply_rename old_path = tmp_path / "old.geojson" new_path = tmp_path / "new.geojson" @@ -822,7 +822,7 @@ def test_rename_fallback_to_shutil_on_cross_fs( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Rename should fall back to shutil.move on cross-filesystem OSError.""" - import portolan_cli.scan_fix as scan_fix_module + import portolan_cli.scan.fix as scan_fix_module old_path = tmp_path / "old.geojson" new_path = tmp_path / "new.geojson" @@ -855,7 +855,7 @@ def test_rename_fallback_fails_on_shutil_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Rename should fail gracefully when rename fails.""" - import portolan_cli.scan_fix as scan_fix_module + import portolan_cli.scan.fix as scan_fix_module old_path = tmp_path / "old.geojson" new_path = tmp_path / "new.geojson" @@ -884,7 +884,7 @@ class TestApplyShapefileRenameEdgeCases: def test_shapefile_rename_success(self, tmp_path: Path) -> None: """Successful shapefile rename should rename all sidecars.""" - from portolan_cli.scan_fix import _apply_shapefile_rename + from portolan_cli.scan.fix import _apply_shapefile_rename # Create shapefile with sidecars shp = tmp_path / "old.shp" @@ -908,7 +908,7 @@ def test_shapefile_rename_success(self, tmp_path: Path) -> None: def test_shapefile_rename_collision_prevents_rename(self, tmp_path: Path) -> None: """Shapefile rename should fail if any target exists.""" - from portolan_cli.scan_fix import _apply_shapefile_rename + from portolan_cli.scan.fix import _apply_shapefile_rename # Create shapefile with sidecars shp = tmp_path / "old.shp" @@ -933,7 +933,7 @@ def test_shapefile_rename_collision_detected_preserves_originals(self, tmp_path: When a collision is detected during the pre-check phase, no rename is attempted and all original files remain in place. """ - from portolan_cli.scan_fix import _apply_shapefile_rename + from portolan_cli.scan.fix import _apply_shapefile_rename # Create shapefile with sidecars shp = tmp_path / "old.shp" @@ -977,14 +977,14 @@ class TestRollbackRenames: def test_rollback_empty_list(self) -> None: """Rollback with empty list should do nothing.""" - from portolan_cli.scan_fix import _rollback_renames + from portolan_cli.scan.fix import _rollback_renames # Should not raise _rollback_renames([]) def test_rollback_restores_original_names(self, tmp_path: Path) -> None: """Rollback should restore files to original names.""" - from portolan_cli.scan_fix import _rollback_renames + from portolan_cli.scan.fix import _rollback_renames # Simulate files that were renamed old1 = tmp_path / "old1.shp" @@ -1012,7 +1012,7 @@ def test_rollback_continues_on_error(self, tmp_path: Path) -> None: This tests that _rollback_renames handles errors gracefully by attempting to rollback files even when one rollback fails. """ - from portolan_cli.scan_fix import _rollback_renames + from portolan_cli.scan.fix import _rollback_renames # Create files - only new2 exists, old1/new1 simulate a missing file scenario old1 = tmp_path / "old1.shp" @@ -1060,7 +1060,7 @@ def test_non_shapefile_rename_failure_continues( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Non-shapefile rename failure should not apply the fix.""" - import portolan_cli.scan_fix as scan_fix_module + import portolan_cli.scan.fix as scan_fix_module path = tmp_path / "my file.geojson" path.write_text("content") @@ -1085,7 +1085,7 @@ def test_shapefile_rename_failure_does_not_apply( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Shapefile rename failure should not apply the fix.""" - import portolan_cli.scan_fix as scan_fix_module + import portolan_cli.scan.fix as scan_fix_module shp = tmp_path / "my file.shp" dbf = tmp_path / "my file.dbf" diff --git a/tests/unit/test_scan_fix_coverage.py b/tests/unit/test_scan_fix_coverage.py index a8e9d602..c508aa57 100644 --- a/tests/unit/test_scan_fix_coverage.py +++ b/tests/unit/test_scan_fix_coverage.py @@ -16,8 +16,8 @@ from hypothesis import given, settings from hypothesis import strategies as st -from portolan_cli.scan import IssueType, ScanIssue, Severity -from portolan_cli.scan_fix import ( +from portolan_cli.scan.core import IssueType, ScanIssue, Severity +from portolan_cli.scan.fix import ( FixCategory, ProposedFix, _compute_short_hash, diff --git a/tests/unit/test_scan_fix_normalization.py b/tests/unit/test_scan_fix_normalization.py index f0f4c3b6..e5a01d26 100644 --- a/tests/unit/test_scan_fix_normalization.py +++ b/tests/unit/test_scan_fix_normalization.py @@ -18,7 +18,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from portolan_cli.scan_fix import _compute_safe_rename, _sanitize_filename +from portolan_cli.scan.fix import _compute_safe_rename, _sanitize_filename @pytest.mark.unit diff --git a/tests/unit/test_scan_infer.py b/tests/unit/test_scan_infer.py index 02106443..f57f29bf 100644 --- a/tests/unit/test_scan_infer.py +++ b/tests/unit/test_scan_infer.py @@ -9,7 +9,7 @@ import pytest -from portolan_cli.scan_infer import ( +from portolan_cli.scan.infer import ( CollectionSuggestion, detect_pattern_marker, extract_numeric_groups, @@ -202,7 +202,7 @@ class TestInferCollections: def test_infers_from_numeric_pattern(self, tmp_path: Path) -> None: """infer_collections suggests groups from numeric patterns.""" - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile files = [ ScannedFile( @@ -235,7 +235,7 @@ def test_infers_from_numeric_pattern(self, tmp_path: Path) -> None: def test_no_suggestions_for_unrelated(self, tmp_path: Path) -> None: """infer_collections returns empty for unrelated files.""" - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile files = [ ScannedFile( @@ -260,7 +260,7 @@ def test_no_suggestions_for_unrelated(self, tmp_path: Path) -> None: def test_confidence_filtering(self, tmp_path: Path) -> None: """infer_collections respects min_confidence threshold.""" - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile files = [ ScannedFile( @@ -288,7 +288,7 @@ def test_confidence_filtering(self, tmp_path: Path) -> None: def test_sorted_by_confidence(self, tmp_path: Path) -> None: """infer_collections returns results sorted by confidence descending.""" - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile files = [ ScannedFile( @@ -328,7 +328,7 @@ def test_duplicate_filenames_across_directories(self, tmp_path: Path) -> None: filename appeared in different directories (e.g., 2020/rivers.geojson and 2021/rivers.geojson). Now uses multimap to track all paths. """ - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile # Create directory structure with duplicate filenames dir_2020 = tmp_path / "2020" @@ -388,7 +388,7 @@ def test_no_duplicate_paths_in_suggestions(self, tmp_path: Path) -> None: (e.g., ["rivers.geojson", "rivers.geojson"] from different dirs), paths_by_name[n] returns ALL paths for that name, causing duplicates. """ - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile # Create directory structure with duplicate filenames dir_2020 = tmp_path / "2020" diff --git a/tests/unit/test_scan_infer_coverage.py b/tests/unit/test_scan_infer_coverage.py index 514f4444..2d5b2619 100644 --- a/tests/unit/test_scan_infer_coverage.py +++ b/tests/unit/test_scan_infer_coverage.py @@ -15,7 +15,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from portolan_cli.scan_infer import ( +from portolan_cli.scan.infer import ( CollectionSuggestion, find_common_prefix, ) diff --git a/tests/unit/test_scan_nested.py b/tests/unit/test_scan_nested.py index a51dcb3a..a1c6d0ba 100644 --- a/tests/unit/test_scan_nested.py +++ b/tests/unit/test_scan_nested.py @@ -53,7 +53,7 @@ def test_single_level_collection_id(self, fixtures_dir: Path) -> None: Fixture: flat_collection/ Expected: Files in flat_collection/ have inferred_collection_id = "flat_collection" """ - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory result = scan_directory(fixtures_dir / "flat_collection", ScanOptions()) @@ -82,7 +82,7 @@ def test_two_level_collection_id(self, fixtures_dir: Path) -> None: When scanning nested/, files in census/2020/ should have inferred_collection_id = "census/2020" """ - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory result = scan_directory(fixtures_dir / "nested", ScanOptions()) @@ -111,7 +111,7 @@ def test_three_level_collection_id(self, fixtures_dir: Path) -> None: Files should have inferred_collection_id = "GAUL_L2/by_country/AFG" etc. """ - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory result = scan_directory(fixtures_dir / "three_level_nested", ScanOptions()) @@ -131,7 +131,7 @@ def test_mixed_depths_same_catalog(self, fixtures_dir: Path) -> None: Different depths should each get appropriate collection IDs. """ - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory result = scan_directory(fixtures_dir / "mixed_depths", ScanOptions()) @@ -161,7 +161,7 @@ def test_deep_nesting_five_plus_levels(self, fixtures_dir: Path) -> None: Tests that arbitrarily deep nesting doesn't break collection ID inference. """ - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory result = scan_directory(fixtures_dir / "deep_nested", ScanOptions()) @@ -195,7 +195,7 @@ def test_collection_id_uses_forward_slashes(self, fixtures_dir: Path) -> None: This ensures portable, consistent IDs across Windows/Unix. """ - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory result = scan_directory(fixtures_dir / "nested", ScanOptions()) @@ -208,7 +208,7 @@ def test_collection_id_uses_forward_slashes(self, fixtures_dir: Path) -> None: def test_collection_id_no_trailing_slash(self, fixtures_dir: Path) -> None: """Collection IDs do not have trailing slashes.""" - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory result = scan_directory(fixtures_dir / "nested", ScanOptions()) @@ -221,7 +221,7 @@ def test_collection_id_no_trailing_slash(self, fixtures_dir: Path) -> None: def test_collection_id_no_leading_slash(self, fixtures_dir: Path) -> None: """Collection IDs do not have leading slashes (they're relative).""" - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory result = scan_directory(fixtures_dir / "nested", ScanOptions()) @@ -262,7 +262,7 @@ class TestCollectionIdInvariantsHypothesis: ) def test_collection_id_invariants_parametrized(self, relative_path: str) -> None: """Collection IDs satisfy invariants for various path patterns.""" - from portolan_cli.scan import _infer_collection_id_from_relative_path + from portolan_cli.scan.core import _infer_collection_id_from_relative_path collection_id = _infer_collection_id_from_relative_path(relative_path) @@ -277,14 +277,14 @@ def test_collection_id_invariants_parametrized(self, relative_path: str) -> None def test_collection_id_from_root_file_is_empty(self) -> None: """Files at root level have empty collection ID.""" - from portolan_cli.scan import _infer_collection_id_from_relative_path + from portolan_cli.scan.core import _infer_collection_id_from_relative_path assert _infer_collection_id_from_relative_path("data.parquet") == "" assert _infer_collection_id_from_relative_path("file.geojson") == "" def test_collection_id_strips_filename_correctly(self) -> None: """Collection ID is parent path without filename.""" - from portolan_cli.scan import _infer_collection_id_from_relative_path + from portolan_cli.scan.core import _infer_collection_id_from_relative_path # Single level assert _infer_collection_id_from_relative_path("foo/data.parquet") == "foo" @@ -323,7 +323,7 @@ class TestCollectionIdHypothesis: @settings(max_examples=100) def test_collection_id_invariants_hypothesis(self, path_segments: list[str]) -> None: """Property: Collection IDs never have backslashes or leading/trailing slashes.""" - from portolan_cli.scan import _infer_collection_id_from_relative_path + from portolan_cli.scan.core import _infer_collection_id_from_relative_path # Filter out empty segments segments = [s for s in path_segments if s] @@ -350,7 +350,7 @@ def test_collection_id_invariants_hypothesis(self, path_segments: list[str]) -> @settings(max_examples=50) def test_deep_nesting_preserves_structure(self, depth: int, segment_name: str) -> None: """Property: Deep nesting produces correct collection ID length.""" - from portolan_cli.scan import _infer_collection_id_from_relative_path + from portolan_cli.scan.core import _infer_collection_id_from_relative_path if not segment_name: return @@ -377,7 +377,7 @@ class TestInferredCollectionIdField: def test_scanned_file_has_inferred_collection_id_attribute(self, tmp_path: Path) -> None: """ScannedFile dataclass has inferred_collection_id field.""" - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile # TDD: This will fail until the field is added to ScannedFile sf = ScannedFile( @@ -392,7 +392,7 @@ def test_scanned_file_has_inferred_collection_id_attribute(self, tmp_path: Path) def test_inferred_collection_id_defaults_to_empty_string(self, tmp_path: Path) -> None: """ScannedFile.inferred_collection_id defaults to empty string for root files.""" - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile # TDD: The default value should be empty string sf = ScannedFile( @@ -407,7 +407,7 @@ def test_inferred_collection_id_defaults_to_empty_string(self, tmp_path: Path) - def test_inferred_collection_id_is_derived_from_relative_path(self, tmp_path: Path) -> None: """Collection ID is the directory portion of relative_path.""" - from portolan_cli.scan import FormatType, ScannedFile + from portolan_cli.scan.core import FormatType, ScannedFile sf = ScannedFile( path=tmp_path / "theme" / "subtheme" / "data.parquet", diff --git a/tests/unit/test_scan_output.py b/tests/unit/test_scan_output.py index 1683bfcf..f3b1f231 100644 --- a/tests/unit/test_scan_output.py +++ b/tests/unit/test_scan_output.py @@ -15,7 +15,8 @@ import pytest -from portolan_cli.scan import ( +from portolan_cli.scan.classify import FileCategory, SkippedFile, SkipReasonType +from portolan_cli.scan.core import ( FormatType, IssueType, ScanIssue, @@ -23,8 +24,7 @@ ScanResult, Severity, ) -from portolan_cli.scan_classify import FileCategory, SkippedFile, SkipReasonType -from portolan_cli.scan_infer import CollectionSuggestion +from portolan_cli.scan.infer import CollectionSuggestion # ============================================================================= # Test Data Factories @@ -103,26 +103,26 @@ class TestFixabilityLabels: def test_get_fixability_auto_fix(self) -> None: """Issues like missing catalog.json are auto-fix (generated on import).""" - from portolan_cli.scan_output import Fixability, get_fixability + from portolan_cli.scan.output import Fixability, get_fixability # Missing catalog.json is auto-generated on import assert get_fixability(IssueType.EXISTING_CATALOG) == Fixability.AUTO_FIX def test_get_fixability_fix_flag(self) -> None: """Issues like invalid_characters are fixable with --fix.""" - from portolan_cli.scan_output import Fixability, get_fixability + from portolan_cli.scan.output import Fixability, get_fixability assert get_fixability(IssueType.INVALID_CHARACTERS) == Fixability.FIX_FLAG def test_get_fixability_manual(self) -> None: """Issues like multiple_primaries require manual resolution.""" - from portolan_cli.scan_output import Fixability, get_fixability + from portolan_cli.scan.output import Fixability, get_fixability assert get_fixability(IssueType.MULTIPLE_PRIMARIES) == Fixability.MANUAL def test_fixability_label_text(self) -> None: """Fixability enum provides correct label text.""" - from portolan_cli.scan_output import Fixability + from portolan_cli.scan.output import Fixability assert Fixability.AUTO_FIX.label == "[auto-fix]" assert Fixability.FIX_FLAG.label == "[--fix]" @@ -140,7 +140,7 @@ class TestStructureChecklist: def test_generate_checklist_items(self, tmp_path: Path) -> None: """generate_structure_checklist returns expected items.""" - from portolan_cli.scan_output import generate_structure_checklist + from portolan_cli.scan.output import generate_structure_checklist result = ScanResult( root=tmp_path, @@ -160,7 +160,7 @@ def test_generate_checklist_items(self, tmp_path: Path) -> None: def test_checklist_item_passed(self, tmp_path: Path) -> None: """Checklist item shows passed when condition met.""" - from portolan_cli.scan_output import generate_structure_checklist + from portolan_cli.scan.output import generate_structure_checklist # Create a result with a valid geo-asset at proper location result = ScanResult( @@ -178,7 +178,7 @@ def test_checklist_item_passed(self, tmp_path: Path) -> None: def test_checklist_item_failed(self, tmp_path: Path) -> None: """Checklist item shows failed when condition not met.""" - from portolan_cli.scan_output import generate_structure_checklist + from portolan_cli.scan.output import generate_structure_checklist # Result with geo-asset at root (violates structure rule) result = ScanResult( @@ -214,7 +214,7 @@ class TestSkipReasonDisplay: def test_group_skipped_by_category(self, tmp_path: Path) -> None: """group_skipped_files groups by category.""" - from portolan_cli.scan_output import group_skipped_files + from portolan_cli.scan.output import group_skipped_files skipped = [ make_skipped_file( @@ -248,7 +248,7 @@ def test_group_skipped_by_category(self, tmp_path: Path) -> None: def test_category_display_name(self) -> None: """FileCategory has human-readable display names.""" - from portolan_cli.scan_output import get_category_display_name + from portolan_cli.scan.output import get_category_display_name assert get_category_display_name(FileCategory.KNOWN_SIDECAR) == "sidecar" assert get_category_display_name(FileCategory.TABULAR_DATA) == "tabular" @@ -270,7 +270,7 @@ class TestCollectionInferenceOutput: def test_format_collection_suggestion(self, tmp_path: Path) -> None: """format_collection_suggestion formats suggestion nicely.""" - from portolan_cli.scan_output import format_collection_suggestion + from portolan_cli.scan.output import format_collection_suggestion suggestion = CollectionSuggestion( suggested_name="flood-depth", @@ -293,7 +293,7 @@ def test_format_collection_suggestion(self, tmp_path: Path) -> None: def test_format_collection_suggestion_truncates_files(self, tmp_path: Path) -> None: """format_collection_suggestion truncates long file lists.""" - from portolan_cli.scan_output import format_collection_suggestion + from portolan_cli.scan.output import format_collection_suggestion # Create suggestion with many files files = tuple(tmp_path / f"file_{i}.tif" for i in range(20)) @@ -322,7 +322,7 @@ class TestNextStepsSummary: def test_generate_next_steps_with_fixable(self, tmp_path: Path) -> None: """generate_next_steps includes --fix suggestion when fixable issues exist.""" - from portolan_cli.scan_output import generate_next_steps + from portolan_cli.scan.output import generate_next_steps result = ScanResult( root=tmp_path, @@ -345,7 +345,7 @@ def test_generate_next_steps_with_fixable(self, tmp_path: Path) -> None: def test_generate_next_steps_with_manual(self, tmp_path: Path) -> None: """generate_next_steps includes manual resolution guidance.""" - from portolan_cli.scan_output import generate_next_steps + from portolan_cli.scan.output import generate_next_steps result = ScanResult( root=tmp_path, @@ -372,7 +372,7 @@ def test_generate_next_steps_with_manual(self, tmp_path: Path) -> None: def test_generate_next_steps_ready_for_import(self, tmp_path: Path) -> None: """generate_next_steps shows ready state when no issues.""" - from portolan_cli.scan_output import generate_next_steps + from portolan_cli.scan.output import generate_next_steps result = ScanResult( root=tmp_path, @@ -389,7 +389,7 @@ def test_generate_next_steps_ready_for_import(self, tmp_path: Path) -> None: def test_generate_next_steps_no_files(self, tmp_path: Path) -> None: """generate_next_steps handles empty scan result.""" - from portolan_cli.scan_output import generate_next_steps + from portolan_cli.scan.output import generate_next_steps result = ScanResult( root=tmp_path, @@ -416,7 +416,7 @@ class TestTreeViewOutput: def test_build_tree_structure(self, tmp_path: Path) -> None: """build_tree_structure creates nested dict from paths.""" - from portolan_cli.scan_output import build_tree_structure + from portolan_cli.scan.output import build_tree_structure result = ScanResult( root=tmp_path, @@ -443,7 +443,7 @@ def test_build_tree_structure(self, tmp_path: Path) -> None: def test_render_tree_view(self, tmp_path: Path) -> None: """render_tree_view produces expected tree characters.""" - from portolan_cli.scan_output import render_tree_view + from portolan_cli.scan.output import render_tree_view result = ScanResult( root=tmp_path, @@ -462,7 +462,7 @@ def test_render_tree_view(self, tmp_path: Path) -> None: def test_render_tree_view_with_status_markers(self, tmp_path: Path) -> None: """render_tree_view shows status markers for each file.""" - from portolan_cli.scan_output import render_tree_view + from portolan_cli.scan.output import render_tree_view result = ScanResult( root=tmp_path, @@ -491,7 +491,7 @@ def test_render_tree_view_with_status_markers(self, tmp_path: Path) -> None: def test_tree_view_missing_files_marker(self, tmp_path: Path) -> None: """render_tree_view marks missing expected files.""" - from portolan_cli.scan_output import render_tree_view + from portolan_cli.scan.output import render_tree_view # Result missing catalog.json result = ScanResult( @@ -520,13 +520,13 @@ class TestFormatSize: def test_format_size_none(self) -> None: """_format_size returns empty string for None.""" - from portolan_cli.scan_output import _format_size + from portolan_cli.scan.output import _format_size assert _format_size(None) == "" def test_format_size_bytes(self) -> None: """_format_size returns bytes format for small sizes.""" - from portolan_cli.scan_output import _format_size + from portolan_cli.scan.output import _format_size assert _format_size(500) == "500 B" assert _format_size(0) == "0 B" @@ -534,7 +534,7 @@ def test_format_size_bytes(self) -> None: def test_format_size_kilobytes(self) -> None: """_format_size returns KB format for kilobyte range.""" - from portolan_cli.scan_output import _format_size + from portolan_cli.scan.output import _format_size # 1024 bytes = 1 KB assert _format_size(1024) == "1.0 KB" @@ -545,7 +545,7 @@ def test_format_size_kilobytes(self) -> None: def test_format_size_megabytes(self) -> None: """_format_size returns MB format for megabyte range.""" - from portolan_cli.scan_output import _format_size + from portolan_cli.scan.output import _format_size # 1 MB assert _format_size(1024 * 1024) == "1.0 MB" @@ -556,7 +556,7 @@ def test_format_size_megabytes(self) -> None: def test_format_size_gigabytes(self) -> None: """_format_size returns GB format for gigabyte range.""" - from portolan_cli.scan_output import _format_size + from portolan_cli.scan.output import _format_size # 1 GB assert _format_size(1024 * 1024 * 1024) == "1.0 GB" @@ -575,7 +575,7 @@ class TestEmptySectionFormatting: def test_format_header_no_geo_assets(self, tmp_path: Path) -> None: """_format_header shows 'No geo-assets found' when none exist.""" - from portolan_cli.scan_output import _format_header + from portolan_cli.scan.output import _format_header result = ScanResult( root=tmp_path, @@ -593,7 +593,7 @@ def test_format_header_no_geo_assets(self, tmp_path: Path) -> None: def test_format_breakdown_empty(self, tmp_path: Path) -> None: """_format_breakdown returns empty list when no ready files.""" - from portolan_cli.scan_output import _format_breakdown + from portolan_cli.scan.output import _format_breakdown result = ScanResult( root=tmp_path, @@ -608,7 +608,7 @@ def test_format_breakdown_empty(self, tmp_path: Path) -> None: def test_format_issues_empty(self, tmp_path: Path) -> None: """_format_issues returns empty list when no issues.""" - from portolan_cli.scan_output import _format_issues + from portolan_cli.scan.output import _format_issues result = ScanResult( root=tmp_path, @@ -623,7 +623,7 @@ def test_format_issues_empty(self, tmp_path: Path) -> None: def test_format_skipped_empty(self, tmp_path: Path) -> None: """_format_skipped returns empty list when no skipped files.""" - from portolan_cli.scan_output import _format_skipped + from portolan_cli.scan.output import _format_skipped result = ScanResult( root=tmp_path, @@ -638,7 +638,7 @@ def test_format_skipped_empty(self, tmp_path: Path) -> None: def test_format_skipped_with_empty_grouped(self, tmp_path: Path) -> None: """_format_skipped handles legacy Path objects in skipped list.""" - from portolan_cli.scan_output import _format_skipped + from portolan_cli.scan.output import _format_skipped # Only legacy Path objects (no SkippedFile instances) will result in # group_skipped_files returning an empty dict @@ -661,7 +661,7 @@ class TestIssuesFormattingTruncation: def test_format_issues_truncates_at_10(self, tmp_path: Path) -> None: """_format_issues truncates each severity group at 10.""" - from portolan_cli.scan_output import _format_issues + from portolan_cli.scan.output import _format_issues # Create 15 warnings issues = [ @@ -695,7 +695,7 @@ class TestTreeBuildingEdgeCases: def test_tree_with_path_outside_root(self, tmp_path: Path) -> None: """build_tree_structure handles paths not relative to root gracefully.""" - from portolan_cli.scan_output import build_tree_structure + from portolan_cli.scan.output import build_tree_structure # Create a result where issue path is outside the root (edge case) external_path = Path("/completely/different/path/file.geojson") @@ -722,7 +722,7 @@ def test_tree_with_path_outside_root(self, tmp_path: Path) -> None: def test_tree_with_skipped_legacy_path(self, tmp_path: Path) -> None: """build_tree_structure handles legacy Path in skipped list.""" - from portolan_cli.scan_output import build_tree_structure + from portolan_cli.scan.output import build_tree_structure result = ScanResult( root=tmp_path, @@ -743,7 +743,7 @@ class TestFormatScanOutputWithCollections: def test_format_scan_output_with_collection_suggestions(self, tmp_path: Path) -> None: """format_scan_output includes collection suggestions section.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -772,7 +772,7 @@ def test_format_scan_output_with_collection_suggestions(self, tmp_path: Path) -> def test_format_scan_output_with_tree(self, tmp_path: Path) -> None: """format_scan_output includes tree view when show_tree=True.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -799,7 +799,7 @@ class TestFullScanOutputFormat: def test_format_scan_output_sections(self, tmp_path: Path) -> None: """format_scan_output includes all expected sections.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -841,7 +841,7 @@ class TestManualOnlyOutput: def test_manual_only_shows_tree_structure(self, tmp_path: Path) -> None: """manual_only=True shows issues in tree structure.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -868,7 +868,7 @@ def test_manual_only_shows_tree_structure(self, tmp_path: Path) -> None: def test_manual_only_groups_by_directory(self, tmp_path: Path) -> None: """manual_only=True groups multiple issues under same directory.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -903,7 +903,7 @@ def test_manual_only_groups_by_directory(self, tmp_path: Path) -> None: def test_manual_only_hides_ready_files(self, tmp_path: Path) -> None: """manual_only=True hides the 'ready to import' section.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -931,7 +931,7 @@ def test_manual_only_hides_ready_files(self, tmp_path: Path) -> None: def test_manual_only_hides_fixable_issues(self, tmp_path: Path) -> None: """manual_only=True hides issues fixable with --fix.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -963,7 +963,7 @@ def test_manual_only_hides_fixable_issues(self, tmp_path: Path) -> None: def test_manual_only_shows_count_header(self, tmp_path: Path) -> None: """manual_only=True shows count of manual resolution items in header.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -992,7 +992,7 @@ def test_manual_only_shows_count_header(self, tmp_path: Path) -> None: def test_manual_only_shows_short_descriptions(self, tmp_path: Path) -> None: """manual_only=True shows short inline descriptions, not full sentences.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -1018,7 +1018,7 @@ def test_manual_only_shows_short_descriptions(self, tmp_path: Path) -> None: def test_manual_only_no_errors_message(self, tmp_path: Path) -> None: """manual_only=True with no manual issues shows success message.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -1045,7 +1045,7 @@ def test_manual_only_no_errors_message(self, tmp_path: Path) -> None: def test_manual_only_completely_clean(self, tmp_path: Path) -> None: """manual_only=True with no issues at all shows success message.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -1062,7 +1062,7 @@ def test_manual_only_completely_clean(self, tmp_path: Path) -> None: def test_manual_only_uses_severity_markers(self, tmp_path: Path) -> None: """manual_only=True uses ✗ for errors and ⚠ for warnings.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -1093,7 +1093,7 @@ def test_manual_only_uses_severity_markers(self, tmp_path: Path) -> None: def test_manual_only_handles_root_directory_issues(self, tmp_path: Path) -> None: """manual_only=True handles issues on the root directory itself.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -1121,7 +1121,7 @@ def test_manual_only_handles_root_directory_issues(self, tmp_path: Path) -> None def test_manual_only_singular_grammar(self, tmp_path: Path) -> None: """manual_only=True uses correct grammar for singular case.""" - from portolan_cli.scan_output import format_scan_output + from portolan_cli.scan.output import format_scan_output result = ScanResult( root=tmp_path, @@ -1155,7 +1155,7 @@ class TestNestedCollectionIdDisplay: def test_format_file_with_nested_collection_id(self, tmp_path: Path) -> None: """format_file_entry shows nested collection ID when present.""" - from portolan_cli.scan_output import format_file_entry + from portolan_cli.scan.output import format_file_entry file = ScannedFile( path=tmp_path / "climate" / "hittekaart" / "data.parquet", @@ -1179,7 +1179,7 @@ def test_format_file_with_nested_collection_id(self, tmp_path: Path) -> None: def test_format_file_with_simple_collection_id(self, tmp_path: Path) -> None: """format_file_entry shows simple collection ID for flat structures.""" - from portolan_cli.scan_output import format_file_entry + from portolan_cli.scan.output import format_file_entry file = ScannedFile( path=tmp_path / "census" / "data.parquet", @@ -1200,7 +1200,7 @@ def test_format_file_with_simple_collection_id(self, tmp_path: Path) -> None: def test_format_file_without_collection_id(self, tmp_path: Path) -> None: """format_file_entry handles files without inferred_collection_id.""" - from portolan_cli.scan_output import format_file_entry + from portolan_cli.scan.output import format_file_entry # Legacy ScannedFile without metadata file = make_scanned_file(tmp_path / "data.geojson") @@ -1217,7 +1217,7 @@ class TestFormatStatusDisplay: def test_format_status_cloud_native(self, tmp_path: Path) -> None: """Cloud-native formats show positive status.""" - from portolan_cli.scan_output import format_file_entry + from portolan_cli.scan.output import format_file_entry file = ScannedFile( path=tmp_path / "data.parquet", @@ -1237,7 +1237,7 @@ def test_format_status_cloud_native(self, tmp_path: Path) -> None: def test_format_status_parquet_no_geometry(self, tmp_path: Path) -> None: """Parquet without geometry shows different status.""" - from portolan_cli.scan_output import format_file_entry + from portolan_cli.scan.output import format_file_entry file = ScannedFile( path=tmp_path / "lookup.parquet", @@ -1257,7 +1257,7 @@ def test_format_status_parquet_no_geometry(self, tmp_path: Path) -> None: def test_format_status_convertible(self, tmp_path: Path) -> None: """Convertible formats show target format.""" - from portolan_cli.scan_output import format_file_entry + from portolan_cli.scan.output import format_file_entry file = ScannedFile( path=tmp_path / "data.geojson", @@ -1284,7 +1284,7 @@ class TestGroupFilesByCollection: def test_group_files_by_collection(self, tmp_path: Path) -> None: """group_files_by_collection groups files correctly.""" - from portolan_cli.scan_output import group_files_by_collection + from portolan_cli.scan.output import group_files_by_collection files = [ ScannedFile( @@ -1322,7 +1322,7 @@ def test_group_files_by_collection(self, tmp_path: Path) -> None: def test_group_files_without_collection_id(self, tmp_path: Path) -> None: """Files without collection_id go to 'uncategorized' group.""" - from portolan_cli.scan_output import group_files_by_collection + from portolan_cli.scan.output import group_files_by_collection files = [ make_scanned_file(tmp_path / "data.geojson"), # No metadata @@ -1340,7 +1340,7 @@ class TestStructureRecommendations: def test_detect_vector_collection_pattern(self, tmp_path: Path) -> None: """detect_structure_pattern identifies vector collection.""" - from portolan_cli.scan_output import detect_structure_pattern + from portolan_cli.scan.output import detect_structure_pattern result = ScanResult( root=tmp_path, @@ -1365,7 +1365,7 @@ def test_detect_vector_collection_pattern(self, tmp_path: Path) -> None: def test_detect_raster_items_pattern(self, tmp_path: Path) -> None: """detect_structure_pattern identifies raster items.""" - from portolan_cli.scan_output import detect_structure_pattern + from portolan_cli.scan.output import detect_structure_pattern result = ScanResult( root=tmp_path, @@ -1398,7 +1398,7 @@ def test_detect_raster_items_pattern(self, tmp_path: Path) -> None: def test_generate_structure_recommendation(self, tmp_path: Path) -> None: """generate_structure_recommendation produces actionable output.""" - from portolan_cli.scan_output import generate_structure_recommendation + from portolan_cli.scan.output import generate_structure_recommendation result = ScanResult( root=tmp_path, @@ -1425,7 +1425,7 @@ def test_generate_structure_recommendation(self, tmp_path: Path) -> None: def test_generate_ascii_tree_recommendation(self, tmp_path: Path) -> None: """generate_ascii_tree_recommendation shows suggested structure.""" - from portolan_cli.scan_output import generate_ascii_tree_recommendation + from portolan_cli.scan.output import generate_ascii_tree_recommendation collections = ["climate/hittekaart", "census"] @@ -1444,7 +1444,7 @@ class TestEnhancedVerboseOutput: def test_format_issue_verbose(self, tmp_path: Path) -> None: """format_issue_verbose includes extra details.""" - from portolan_cli.scan_output import format_issue_verbose + from portolan_cli.scan.output import format_issue_verbose issue = make_scan_issue( tmp_path / "collection", @@ -1463,7 +1463,7 @@ def test_format_issue_verbose(self, tmp_path: Path) -> None: def test_format_issue_basic(self, tmp_path: Path) -> None: """format_issue_basic shows minimal info.""" - from portolan_cli.scan_output import format_issue_basic + from portolan_cli.scan.output import format_issue_basic issue = make_scan_issue( tmp_path / "collection", @@ -1484,7 +1484,7 @@ class TestEnhancedJsonOutput: def test_format_ready_file_json(self, tmp_path: Path) -> None: """format_ready_file_json includes new fields.""" - from portolan_cli.scan_output import format_ready_file_json + from portolan_cli.scan.output import format_ready_file_json file = ScannedFile( path=tmp_path / "climate" / "hittekaart" / "data.parquet", @@ -1507,7 +1507,7 @@ def test_format_ready_file_json(self, tmp_path: Path) -> None: def test_format_recommended_structure_json(self, tmp_path: Path) -> None: """format_recommended_structure_json includes pattern and commands.""" - from portolan_cli.scan_output import format_recommended_structure_json + from portolan_cli.scan.output import format_recommended_structure_json result = ScanResult( root=tmp_path, @@ -1534,7 +1534,7 @@ def test_format_recommended_structure_json(self, tmp_path: Path) -> None: def test_format_fix_commands_json(self, tmp_path: Path) -> None: """format_fix_commands_json returns structured commands.""" - from portolan_cli.scan_output import format_fix_commands_json + from portolan_cli.scan.output import format_fix_commands_json result = ScanResult( root=tmp_path, @@ -1569,7 +1569,7 @@ class TestFormatEnhancedSummary: def test_format_enhanced_summary_with_nested_collections(self, tmp_path: Path) -> None: """format_enhanced_summary shows nested collection grouping.""" - from portolan_cli.scan_output import format_enhanced_summary + from portolan_cli.scan.output import format_enhanced_summary result = ScanResult( root=tmp_path, @@ -1612,7 +1612,7 @@ def test_format_enhanced_summary_with_nested_collections(self, tmp_path: Path) - def test_format_enhanced_summary_verbose(self, tmp_path: Path) -> None: """format_enhanced_summary includes extra info in verbose mode.""" - from portolan_cli.scan_output import format_enhanced_summary + from portolan_cli.scan.output import format_enhanced_summary result = ScanResult( root=tmp_path, @@ -1676,8 +1676,8 @@ def test_group_files_by_collection_with_real_scan(self, fixtures_dir: Path) -> N This verifies _get_collection_id reads from inferred_collection_id field, not metadata dict. """ - from portolan_cli.scan import ScanOptions, scan_directory - from portolan_cli.scan_output import group_files_by_collection + from portolan_cli.scan.core import ScanOptions, scan_directory + from portolan_cli.scan.output import group_files_by_collection result = scan_directory(fixtures_dir / "nested", ScanOptions()) @@ -1698,8 +1698,8 @@ def test_format_fix_commands_json_with_real_scan(self, fixtures_dir: Path) -> No This verifies the fix_commands generation uses collection IDs from the inferred_collection_id field, not metadata. """ - from portolan_cli.scan import ScanOptions, scan_directory - from portolan_cli.scan_output import detect_structure_pattern, format_fix_commands_json + from portolan_cli.scan.core import ScanOptions, scan_directory + from portolan_cli.scan.output import detect_structure_pattern, format_fix_commands_json result = scan_directory(fixtures_dir / "nested", ScanOptions()) @@ -1722,8 +1722,8 @@ def test_format_enhanced_summary_with_real_scan(self, fixtures_dir: Path) -> Non This verifies the enhanced summary shows proper collection grouping from the inferred_collection_id field. """ - from portolan_cli.scan import ScanOptions, scan_directory - from portolan_cli.scan_output import format_enhanced_summary + from portolan_cli.scan.core import ScanOptions, scan_directory + from portolan_cli.scan.output import format_enhanced_summary result = scan_directory(fixtures_dir / "nested", ScanOptions()) @@ -1740,7 +1740,7 @@ def test_to_dict_includes_new_fields(self, fixtures_dir: Path) -> None: This verifies the JSON output includes the new fields from the PR. """ - from portolan_cli.scan import ScanOptions, scan_directory + from portolan_cli.scan.core import ScanOptions, scan_directory result = scan_directory(fixtures_dir / "nested", ScanOptions()) data = result.to_dict() @@ -1770,8 +1770,8 @@ def test_format_special_formats_shows_hive_partition_with_metadata( self, tmp_path: Path ) -> None: """_format_special_formats displays Hive partitions with rich metadata from details.""" - from portolan_cli.scan_detect import SpecialFormat - from portolan_cli.scan_output import _format_special_formats + from portolan_cli.scan.detect import SpecialFormat + from portolan_cli.scan.output import _format_special_formats data_dir = tmp_path / "data" data_dir.mkdir() @@ -1809,8 +1809,8 @@ def test_format_special_formats_shows_hive_partition_with_metadata( @pytest.mark.unit def test_format_special_formats_fallback_without_rich_metadata(self) -> None: """_format_special_formats falls back to basic info from partition_keys.""" - from portolan_cli.scan_detect import SpecialFormat - from portolan_cli.scan_output import _format_special_formats + from portolan_cli.scan.detect import SpecialFormat + from portolan_cli.scan.output import _format_special_formats # Provide minimal details with just partition_keys (fallback format) sf = SpecialFormat( @@ -1838,7 +1838,7 @@ def test_format_special_formats_fallback_without_rich_metadata(self) -> None: @pytest.mark.unit def test_format_special_formats_empty_returns_empty(self) -> None: """_format_special_formats returns empty list when no special formats.""" - from portolan_cli.scan_output import _format_special_formats + from portolan_cli.scan.output import _format_special_formats result = ScanResult( root=Path("/tmp"), diff --git a/tests/unit/test_scan_output_batching.py b/tests/unit/test_scan_output_batching.py index b602e267..1f630ac2 100644 --- a/tests/unit/test_scan_output_batching.py +++ b/tests/unit/test_scan_output_batching.py @@ -21,8 +21,8 @@ from hypothesis import given, settings from hypothesis import strategies as st -from portolan_cli.scan import IssueType, ScanIssue, ScanResult -from portolan_cli.scan import Severity as ScanSeverity +from portolan_cli.scan.core import IssueType, ScanIssue, ScanResult +from portolan_cli.scan.core import Severity as ScanSeverity # ============================================================================= # Helpers diff --git a/tests/unit/test_scan_progress.py b/tests/unit/test_scan_progress.py index 85e74e36..b392ab1a 100644 --- a/tests/unit/test_scan_progress.py +++ b/tests/unit/test_scan_progress.py @@ -17,8 +17,8 @@ import pytest from click.testing import CliRunner -from portolan_cli.scan import ScanOptions, scan_directory -from portolan_cli.scan_progress import ScanProgressReporter, count_directories +from portolan_cli.scan.core import ScanOptions, scan_directory +from portolan_cli.scan.progress import ScanProgressReporter, count_directories if TYPE_CHECKING: pass diff --git a/tests/unit/test_scan_structure.py b/tests/unit/test_scan_structure.py index 5632e278..e573af72 100644 --- a/tests/unit/test_scan_structure.py +++ b/tests/unit/test_scan_structure.py @@ -46,7 +46,7 @@ def test_nested_structure_with_leaf_data_only_is_valid(self, fixtures_dir: Path) Uses nested/ fixture: census/2020/boundaries.geojson, census/2022/boundaries.geojson This represents intentional year-based organization. """ - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory result = scan_directory(fixtures_dir / "nested") @@ -62,7 +62,7 @@ def test_three_level_nested_no_structure_issues(self, fixtures_dir: Path) -> Non Uses three_level_nested/ fixture: organized by country code at leaf level. """ - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory result = scan_directory(fixtures_dir / "three_level_nested") @@ -90,7 +90,7 @@ def test_files_at_root_and_in_subdirs_warns(self, tmp_path: Path) -> None: This is the classic MIXED_FLAT_MULTIITEM case: unclear whether the directory represents a single collection (flat) or multiple items (nested). """ - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory # Create file at root level root_file = tmp_path / "root_data.geojson" @@ -110,7 +110,7 @@ def test_files_at_root_and_in_subdirs_warns(self, tmp_path: Path) -> None: def test_mixed_structure_suggests_reorganization(self, tmp_path: Path) -> None: """MIXED_FLAT_MULTIITEM issues include a suggestion to reorganize.""" - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory # Create mixed structure (tmp_path / "root.geojson").write_text('{"type": "FeatureCollection", "features": []}') @@ -142,7 +142,7 @@ def test_mixed_structure_deep_nesting_flags_correct_directory(self, tmp_path: Pa Should flag root (has files AND descendant with files), not level3. """ - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory # Create file at root (tmp_path / "data.geojson").write_text('{"type": "FeatureCollection", "features": []}') @@ -177,7 +177,7 @@ def test_mixed_structure_multiple_levels_with_files(self, tmp_path: Path) -> Non Should flag mid (has files + descendant deep has files) Should NOT flag deep (no descendants with files) """ - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory # Level 0: root (tmp_path / "data.geojson").write_text('{"type": "FeatureCollection", "features": []}') @@ -230,7 +230,7 @@ def test_one_geoparquet_with_plain_parquet_companion_is_valid(self, fixtures_dir - data.parquet: GeoParquet (has geo metadata, geometry column) - lookup.parquet: Plain Parquet (no geo metadata) """ - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory result = scan_directory(fixtures_dir / "geoparquet_with_companions") @@ -247,7 +247,7 @@ def test_geoparquet_companions_no_geo_primary_warning(self, fixtures_dir: Path) is specifically for multiple GeoParquet files in the same directory. One geo + companions should not trigger this. """ - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory result = scan_directory(fixtures_dir / "geoparquet_with_companions") @@ -259,7 +259,7 @@ def test_geoparquet_companions_no_geo_primary_warning(self, fixtures_dir: Path) def test_geoparquet_identified_as_ready(self, fixtures_dir: Path) -> None: """The GeoParquet file in the companion fixture is identified as ready.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory result = scan_directory(fixtures_dir / "geoparquet_with_companions") @@ -293,7 +293,7 @@ def test_multiple_geoparquet_triggers_warning(self, fixtures_dir: Path) -> None: Both are primary geo-assets, so this is ambiguous for catalog structure. """ - from portolan_cli.scan import IssueType, Severity, scan_directory + from portolan_cli.scan.core import IssueType, Severity, scan_directory result = scan_directory(fixtures_dir / "multiple_geoparquet") @@ -312,7 +312,7 @@ def test_multiple_geoparquet_distinct_from_multiple_primaries(self, fixtures_dir - MULTIPLE_PRIMARIES: Generic warning for any multiple primaries - MULTIPLE_GEO_PRIMARIES: Specific to GeoParquet, enables targeted guidance """ - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory result = scan_directory(fixtures_dir / "multiple_geoparquet") @@ -323,7 +323,7 @@ def test_multiple_geoparquet_distinct_from_multiple_primaries(self, fixtures_dir def test_multiple_geoparquet_message_references_geoparquet(self, fixtures_dir: Path) -> None: """MULTIPLE_GEO_PRIMARIES message should reference GeoParquet specifically.""" - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory result = scan_directory(fixtures_dir / "multiple_geoparquet") @@ -357,7 +357,7 @@ def test_deep_nesting_no_intermediate_data_is_valid(self, fixtures_dir: Path) -> Intermediate directories (level1, level2, level3, level4) have no data. """ - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory result = scan_directory(fixtures_dir / "deep_nested") @@ -367,7 +367,7 @@ def test_deep_nesting_no_intermediate_data_is_valid(self, fixtures_dir: Path) -> def test_deep_nesting_finds_all_leaf_files(self, fixtures_dir: Path) -> None: """Deeply nested scan finds all files at different nesting depths.""" - from portolan_cli.scan import scan_directory + from portolan_cli.scan.core import scan_directory result = scan_directory(fixtures_dir / "deep_nested") @@ -388,7 +388,7 @@ def test_deep_nesting_mixed_depths_valid(self, fixtures_dir: Path) -> None: Different depths are fine as long as no intermediate has data. """ - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory result = scan_directory(fixtures_dir / "mixed_depths") @@ -412,7 +412,7 @@ def test_flat_collection_no_subdirs_is_valid(self, fixtures_dir: Path) -> None: Uses flat_collection/ fixture: 3 parquet files, no subdirectories. This is a valid "collection-level assets" pattern per ADR-0031. """ - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory result = scan_directory(fixtures_dir / "flat_collection") @@ -422,7 +422,7 @@ def test_flat_collection_no_subdirs_is_valid(self, fixtures_dir: Path) -> None: def test_empty_intermediate_directories_not_flagged(self, tmp_path: Path) -> None: """Empty intermediate directories don't trigger structure warnings.""" - from portolan_cli.scan import IssueType, scan_directory + from portolan_cli.scan.core import IssueType, scan_directory # Create nested structure with empty intermediate leaf = tmp_path / "empty_parent" / "empty_middle" / "leaf" @@ -441,8 +441,8 @@ class TestCorruptedParquetScan: @pytest.mark.unit def test_corrupted_parquet_skipped_with_error(self, tmp_path: Path) -> None: """Corrupted .parquet file is skipped with INVALID_FORMAT reason.""" - from portolan_cli.scan import ScanOptions, scan_directory - from portolan_cli.scan_classify import FileCategory, SkipReasonType + from portolan_cli.scan.classify import FileCategory, SkipReasonType + from portolan_cli.scan.core import ScanOptions, scan_directory # Create a fake parquet file data_dir = tmp_path / "collection" @@ -465,8 +465,8 @@ def test_corrupted_parquet_skipped_with_error(self, tmp_path: Path) -> None: @pytest.mark.unit def test_valid_non_geo_parquet_is_tabular(self, tmp_path: Path, fixtures_dir: Path) -> None: """Valid Parquet without geo metadata is skipped as TABULAR_DATA.""" - from portolan_cli.scan import ScanOptions, scan_directory - from portolan_cli.scan_classify import FileCategory, SkipReasonType + from portolan_cli.scan.classify import FileCategory, SkipReasonType + from portolan_cli.scan.core import ScanOptions, scan_directory # Use one of our companion fixtures (plain Parquet) src = fixtures_dir / "scan" / "geoparquet_with_companions" / "lookup.parquet" diff --git a/tests/unit/test_scan_unrecognized_files_property.py b/tests/unit/test_scan_unrecognized_files_property.py index 4b184d1a..f29af3ce 100644 --- a/tests/unit/test_scan_unrecognized_files_property.py +++ b/tests/unit/test_scan_unrecognized_files_property.py @@ -13,7 +13,7 @@ from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st -from portolan_cli.scan_classify import FileCategory, SkipReasonType, classify_file +from portolan_cli.scan.classify import FileCategory, SkipReasonType, classify_file # Strategy for generating unknown file extensions # Use ASCII alphanumeric only - cross-platform safe for filenames @@ -40,7 +40,7 @@ def test_truly_unknown_extensions_are_classified_as_unknown(self, ext: str) -> N # Skip common extensions that might be generated # These are either known formats or known non-data files # Import GEO_ASSET_EXTENSIONS to ensure we skip all valid geo formats - from portolan_cli.scan_classify import GEO_ASSET_EXTENSIONS + from portolan_cli.scan.classify import GEO_ASSET_EXTENSIONS skip_exts = { ".md", diff --git a/tests/unit/test_style.py b/tests/unit/test_style.py index c15f14b2..35fc7748 100644 --- a/tests/unit/test_style.py +++ b/tests/unit/test_style.py @@ -11,7 +11,7 @@ import pytest if TYPE_CHECKING: - from portolan_cli.style import LegendInfo, StyleInfo + from portolan_cli.viz.style import LegendInfo, StyleInfo # ============================================================================= # Phase 1: VectorStyleConfig Tests @@ -24,7 +24,7 @@ class TestVectorStyleConfig: @pytest.mark.unit def test_default_values(self) -> None: """VectorStyleConfig has sensible defaults per geometry type.""" - from portolan_cli.style import VectorStyleConfig + from portolan_cli.viz.style import VectorStyleConfig config = VectorStyleConfig() assert config.point_color == "#3388ff" @@ -40,7 +40,7 @@ def test_default_values(self) -> None: @pytest.mark.unit def test_custom_values(self) -> None: """VectorStyleConfig accepts custom values.""" - from portolan_cli.style import VectorStyleConfig + from portolan_cli.viz.style import VectorStyleConfig config = VectorStyleConfig( point_color="#ff0000", @@ -54,7 +54,7 @@ def test_custom_values(self) -> None: @pytest.mark.unit def test_frozen_dataclass(self) -> None: """VectorStyleConfig is immutable (frozen).""" - from portolan_cli.style import VectorStyleConfig + from portolan_cli.viz.style import VectorStyleConfig config = VectorStyleConfig() with pytest.raises(AttributeError): @@ -77,7 +77,7 @@ class TestBuildRasterStyle: @pytest.mark.unit def test_default_colormap(self) -> None: """Default colormap is viridis.""" - from portolan_cli.style import RasterStyleConfig, build_raster_style + from portolan_cli.viz.style import RasterStyleConfig, build_raster_style config = RasterStyleConfig() style = build_raster_style(config) @@ -87,7 +87,7 @@ def test_default_colormap(self) -> None: @pytest.mark.unit def test_auto_rescale(self) -> None: """Auto rescale uses None (viewer determines).""" - from portolan_cli.style import RasterStyleConfig, build_raster_style + from portolan_cli.viz.style import RasterStyleConfig, build_raster_style config = RasterStyleConfig() style = build_raster_style(config) @@ -97,7 +97,7 @@ def test_auto_rescale(self) -> None: @pytest.mark.unit def test_explicit_rescale(self) -> None: """Explicit rescale is included in style.""" - from portolan_cli.style import RasterStyleConfig, build_raster_style + from portolan_cli.viz.style import RasterStyleConfig, build_raster_style config = RasterStyleConfig(rescale_min=0, rescale_max=255) style = build_raster_style(config) @@ -107,7 +107,7 @@ def test_explicit_rescale(self) -> None: @pytest.mark.unit def test_custom_colormap(self) -> None: """Custom colormap is applied.""" - from portolan_cli.style import RasterStyleConfig, build_raster_style + from portolan_cli.viz.style import RasterStyleConfig, build_raster_style config = RasterStyleConfig(colormap="terrain") style = build_raster_style(config) @@ -126,7 +126,7 @@ class TestGetStyleConfig: @pytest.mark.unit def test_returns_defaults_when_no_config(self, tmp_path: Path) -> None: """Returns default config when no styles section exists.""" - from portolan_cli.style import VectorStyleConfig, get_vector_style_config + from portolan_cli.viz.style import VectorStyleConfig, get_vector_style_config portolan_dir = tmp_path / ".portolan" portolan_dir.mkdir() @@ -139,7 +139,7 @@ def test_returns_defaults_when_no_config(self, tmp_path: Path) -> None: @pytest.mark.unit def test_loads_custom_vector_config(self, tmp_path: Path) -> None: """Loads custom vector style config from YAML.""" - from portolan_cli.style import get_vector_style_config + from portolan_cli.viz.style import get_vector_style_config portolan_dir = tmp_path / ".portolan" portolan_dir.mkdir() @@ -164,7 +164,7 @@ def test_loads_custom_vector_config(self, tmp_path: Path) -> None: @pytest.mark.unit def test_loads_raster_config(self, tmp_path: Path) -> None: """Loads raster style config from YAML.""" - from portolan_cli.style import get_raster_style_config + from portolan_cli.viz.style import get_raster_style_config portolan_dir = tmp_path / ".portolan" portolan_dir.mkdir() @@ -200,7 +200,7 @@ def test_discovers_style_files(self, tmp_path: Path) -> None: """Creates 2 style files and verifies both found with correct keys.""" import json - from portolan_cli.style import discover_styles + from portolan_cli.viz.style import discover_styles styles_dir = tmp_path / "styles" styles_dir.mkdir() @@ -220,7 +220,7 @@ def test_extracts_name_as_title(self, tmp_path: Path) -> None: """Style with 'name' field returns that as title.""" import json - from portolan_cli.style import discover_styles + from portolan_cli.viz.style import discover_styles styles_dir = tmp_path / "styles" styles_dir.mkdir() @@ -237,7 +237,7 @@ def test_extracts_name_as_title(self, tmp_path: Path) -> None: @pytest.mark.unit def test_returns_empty_when_no_styles_dir(self, tmp_path: Path) -> None: """No styles/ directory returns empty list.""" - from portolan_cli.style import discover_styles + from portolan_cli.viz.style import discover_styles styles = discover_styles(tmp_path) @@ -248,7 +248,7 @@ def test_skips_non_json_files(self, tmp_path: Path) -> None: """Non-JSON files in styles/ directory are ignored.""" import json - from portolan_cli.style import discover_styles + from portolan_cli.viz.style import discover_styles styles_dir = tmp_path / "styles" styles_dir.mkdir() @@ -267,7 +267,7 @@ def test_skips_invalid_json(self, tmp_path: Path) -> None: """Malformed JSON is skipped, valid files still returned.""" import json - from portolan_cli.style import discover_styles + from portolan_cli.viz.style import discover_styles styles_dir = tmp_path / "styles" styles_dir.mkdir() @@ -289,7 +289,7 @@ def test_skips_non_dict_json(self, tmp_path: Path) -> None: """JSON files that parse to non-dict values are skipped.""" import json - from portolan_cli.style import discover_styles + from portolan_cli.viz.style import discover_styles styles_dir = tmp_path / "styles" styles_dir.mkdir() @@ -308,7 +308,7 @@ def test_fallback_title_from_filename(self, tmp_path: Path) -> None: """Style without 'name' field uses filename stem as title.""" import json - from portolan_cli.style import discover_styles + from portolan_cli.viz.style import discover_styles styles_dir = tmp_path / "styles" styles_dir.mkdir() @@ -327,14 +327,14 @@ class TestBuildStylesManifest: @staticmethod def _style_info(key: str) -> StyleInfo: - from portolan_cli.style import StyleInfo + from portolan_cli.viz.style import StyleInfo return StyleInfo(key=key, href="", title="", description="", path=Path()) @pytest.mark.unit def test_default_first(self) -> None: """Default style always first regardless of input order.""" - from portolan_cli.style import build_styles_manifest + from portolan_cli.viz.style import build_styles_manifest styles = [ self._style_info("styles/zebra"), @@ -350,7 +350,7 @@ def test_default_first(self) -> None: @pytest.mark.unit def test_alphabetical_after_default(self) -> None: """Non-default styles sorted alphabetically.""" - from portolan_cli.style import build_styles_manifest + from portolan_cli.viz.style import build_styles_manifest styles = [ self._style_info("styles/zebra"), @@ -366,7 +366,7 @@ def test_alphabetical_after_default(self) -> None: @pytest.mark.unit def test_no_default(self) -> None: """Works without a style named 'default'.""" - from portolan_cli.style import build_styles_manifest + from portolan_cli.viz.style import build_styles_manifest styles = [ self._style_info("styles/zebra"), @@ -380,7 +380,7 @@ def test_no_default(self) -> None: @pytest.mark.unit def test_empty_list(self) -> None: """Empty input returns empty output.""" - from portolan_cli.style import build_styles_manifest + from portolan_cli.viz.style import build_styles_manifest manifest = build_styles_manifest([]) @@ -389,7 +389,7 @@ def test_empty_list(self) -> None: @pytest.mark.unit def test_single_style(self) -> None: """Single style returns single-element list.""" - from portolan_cli.style import build_styles_manifest + from portolan_cli.viz.style import build_styles_manifest styles = [self._style_info("styles/custom")] @@ -532,7 +532,7 @@ class TestBuildFullStyle: @pytest.mark.unit def test_polygon_full_style(self) -> None: """Builds complete Mapbox GL style for Polygon geometry.""" - from portolan_cli.style import VectorStyleConfig, build_full_style + from portolan_cli.viz.style import VectorStyleConfig, build_full_style config = VectorStyleConfig() style = build_full_style( @@ -567,7 +567,7 @@ def test_polygon_full_style(self) -> None: @pytest.mark.unit def test_linestring_full_style(self) -> None: """Builds complete Mapbox GL style for LineString geometry.""" - from portolan_cli.style import VectorStyleConfig, build_full_style + from portolan_cli.viz.style import VectorStyleConfig, build_full_style config = VectorStyleConfig() style = build_full_style( @@ -586,7 +586,7 @@ def test_linestring_full_style(self) -> None: @pytest.mark.unit def test_point_full_style(self) -> None: """Builds complete Mapbox GL style for Point geometry.""" - from portolan_cli.style import VectorStyleConfig, build_full_style + from portolan_cli.viz.style import VectorStyleConfig, build_full_style config = VectorStyleConfig() style = build_full_style( @@ -605,7 +605,7 @@ def test_point_full_style(self) -> None: @pytest.mark.unit def test_custom_config_applied(self) -> None: """Custom VectorStyleConfig values appear in paint properties.""" - from portolan_cli.style import VectorStyleConfig, build_full_style + from portolan_cli.viz.style import VectorStyleConfig, build_full_style config = VectorStyleConfig( polygon_fill_color="#ff0000", @@ -630,7 +630,7 @@ def test_full_style_is_json_serializable(self) -> None: """Full style dict is JSON-serializable.""" import json - from portolan_cli.style import VectorStyleConfig, build_full_style + from portolan_cli.viz.style import VectorStyleConfig, build_full_style config = VectorStyleConfig() style = build_full_style( @@ -658,7 +658,7 @@ class TestWriteStyleFile: @pytest.mark.unit def test_writes_style_to_disk(self, tmp_path: Path) -> None: """Writes style dict to disk as JSON.""" - from portolan_cli.style import write_style_file + from portolan_cli.viz.style import write_style_file style_dir = tmp_path / "styles" style_dict = { @@ -682,7 +682,7 @@ def test_writes_style_to_disk(self, tmp_path: Path) -> None: @pytest.mark.unit def test_creates_styles_directory(self, tmp_path: Path) -> None: """Creates styles directory if it doesn't exist.""" - from portolan_cli.style import write_style_file + from portolan_cli.viz.style import write_style_file style_dir = tmp_path / "styles" assert not style_dir.exists() @@ -696,7 +696,7 @@ def test_creates_styles_directory(self, tmp_path: Path) -> None: @pytest.mark.unit def test_overwrites_existing_file(self, tmp_path: Path) -> None: """Overwrites existing style file.""" - from portolan_cli.style import write_style_file + from portolan_cli.viz.style import write_style_file style_dir = tmp_path / "styles" style_dir.mkdir() @@ -718,7 +718,7 @@ def test_overwrites_existing_file(self, tmp_path: Path) -> None: @pytest.mark.unit def test_rejects_path_traversal_slash(self, tmp_path: Path) -> None: """Rejects style names containing forward slash.""" - from portolan_cli.style import write_style_file + from portolan_cli.viz.style import write_style_file with pytest.raises(ValueError, match="path separators"): write_style_file(tmp_path / "styles", "../evil", {"version": 8}) @@ -726,7 +726,7 @@ def test_rejects_path_traversal_slash(self, tmp_path: Path) -> None: @pytest.mark.unit def test_rejects_path_traversal_backslash(self, tmp_path: Path) -> None: """Rejects style names containing backslash.""" - from portolan_cli.style import write_style_file + from portolan_cli.viz.style import write_style_file with pytest.raises(ValueError, match="path separators"): write_style_file(tmp_path / "styles", "..\\evil", {"version": 8}) @@ -734,7 +734,7 @@ def test_rejects_path_traversal_backslash(self, tmp_path: Path) -> None: @pytest.mark.unit def test_rejects_path_traversal_dotdot(self, tmp_path: Path) -> None: """Rejects style names containing '..'.""" - from portolan_cli.style import write_style_file + from portolan_cli.viz.style import write_style_file with pytest.raises(ValueError, match="path separators"): write_style_file(tmp_path / "styles", "..", {"version": 8}) @@ -751,7 +751,7 @@ class TestWriteDefaultStyle: @pytest.mark.unit def test_writes_default_style_file(self, tmp_path: Path) -> None: """Creates styles/default.json with correct content.""" - from portolan_cli.style import VectorStyleConfig, write_default_style + from portolan_cli.viz.style import VectorStyleConfig, write_default_style config = VectorStyleConfig() result_path = write_default_style( @@ -780,7 +780,7 @@ def test_writes_default_style_file(self, tmp_path: Path) -> None: @pytest.mark.unit def test_uses_custom_config(self, tmp_path: Path) -> None: """Custom VectorStyleConfig affects output.""" - from portolan_cli.style import VectorStyleConfig, write_default_style + from portolan_cli.viz.style import VectorStyleConfig, write_default_style config = VectorStyleConfig( polygon_fill_color="#ff0000", @@ -804,7 +804,7 @@ def test_uses_custom_config(self, tmp_path: Path) -> None: @pytest.mark.unit def test_does_not_overwrite_existing(self, tmp_path: Path) -> None: """Returns None if default.json already exists, doesn't overwrite.""" - from portolan_cli.style import write_default_style + from portolan_cli.viz.style import write_default_style # Create existing default.json styles_dir = tmp_path / "styles" @@ -845,7 +845,7 @@ def test_registers_style_assets_in_collection(self, tmp_path: Path) -> None: """Discovered styles are added as assets in collection.json.""" import json - from portolan_cli.style import discover_styles, register_style_assets + from portolan_cli.viz.style import discover_styles, register_style_assets collection_data = { "type": "Collection", @@ -889,7 +889,7 @@ def test_no_styles_no_manifest(self, tmp_path: Path) -> None: """No portolan:styles property when no styles exist.""" import json - from portolan_cli.style import register_style_assets + from portolan_cli.viz.style import register_style_assets collection_data = {"type": "Collection", "id": "test", "assets": {}} (tmp_path / "collection.json").write_text(json.dumps(collection_data)) @@ -902,7 +902,7 @@ def test_no_styles_no_manifest(self, tmp_path: Path) -> None: @pytest.mark.unit def test_no_op_without_collection_json(self, tmp_path: Path) -> None: """Does nothing when collection.json doesn't exist.""" - from portolan_cli.style import StyleInfo, register_style_assets + from portolan_cli.viz.style import StyleInfo, register_style_assets styles = [ StyleInfo( @@ -922,7 +922,7 @@ def test_removes_stale_style_assets(self, tmp_path: Path) -> None: """Removes style assets that no longer have files on disk.""" import json - from portolan_cli.style import register_style_assets + from portolan_cli.viz.style import register_style_assets collection_data = { "type": "Collection", @@ -943,7 +943,7 @@ def test_removes_stale_style_assets(self, tmp_path: Path) -> None: } (tmp_path / "collection.json").write_text(json.dumps(collection_data)) - from portolan_cli.style import StyleInfo + from portolan_cli.viz.style import StyleInfo current_styles = [ StyleInfo( @@ -972,7 +972,7 @@ class TestLegendInfo: @pytest.mark.unit def test_legend_info_creation(self) -> None: """LegendInfo can be created with all fields.""" - from portolan_cli.style import LegendInfo + from portolan_cli.viz.style import LegendInfo legend = LegendInfo( key="legends/source", @@ -995,7 +995,7 @@ class TestDiscoverLegends: @pytest.mark.unit def test_discovers_legend_files(self, tmp_path: Path) -> None: """Creates 2 legend files and verifies both found with correct keys.""" - from portolan_cli.style import discover_legends + from portolan_cli.viz.style import discover_legends legends_dir = tmp_path / "legends" legends_dir.mkdir() @@ -1014,7 +1014,7 @@ def test_discovers_legend_files(self, tmp_path: Path) -> None: @pytest.mark.unit def test_returns_empty_when_no_legends_dir(self, tmp_path: Path) -> None: """No legends/ directory returns empty list.""" - from portolan_cli.style import discover_legends + from portolan_cli.viz.style import discover_legends legends = discover_legends(tmp_path) @@ -1023,7 +1023,7 @@ def test_returns_empty_when_no_legends_dir(self, tmp_path: Path) -> None: @pytest.mark.unit def test_skips_non_png_files(self, tmp_path: Path) -> None: """Non-PNG files in legends/ directory are ignored.""" - from portolan_cli.style import discover_legends + from portolan_cli.viz.style import discover_legends legends_dir = tmp_path / "legends" legends_dir.mkdir() @@ -1042,7 +1042,7 @@ def test_skips_non_png_files(self, tmp_path: Path) -> None: @pytest.mark.unit def test_legend_title_from_filename(self, tmp_path: Path) -> None: """Legend title is derived from filename.""" - from portolan_cli.style import discover_legends + from portolan_cli.viz.style import discover_legends legends_dir = tmp_path / "legends" legends_dir.mkdir() @@ -1058,7 +1058,7 @@ def test_legend_title_from_filename(self, tmp_path: Path) -> None: @pytest.mark.unit def test_legend_media_type_is_png(self, tmp_path: Path) -> None: """Legend media type is always image/png.""" - from portolan_cli.style import discover_legends + from portolan_cli.viz.style import discover_legends legends_dir = tmp_path / "legends" legends_dir.mkdir() @@ -1077,14 +1077,14 @@ class TestBuildLegendsManifest: @staticmethod def _legend_info(key: str) -> LegendInfo: - from portolan_cli.style import LegendInfo + from portolan_cli.viz.style import LegendInfo return LegendInfo(key=key, href="", title="", media_type="image/png", path=Path()) @pytest.mark.unit def test_source_first(self) -> None: """Source legend always first regardless of input order.""" - from portolan_cli.style import build_legends_manifest + from portolan_cli.viz.style import build_legends_manifest legends = [ self._legend_info("legends/zebra"), @@ -1100,7 +1100,7 @@ def test_source_first(self) -> None: @pytest.mark.unit def test_alphabetical_after_source(self) -> None: """Non-source legends sorted alphabetically.""" - from portolan_cli.style import build_legends_manifest + from portolan_cli.viz.style import build_legends_manifest legends = [ self._legend_info("legends/zebra"), @@ -1116,7 +1116,7 @@ def test_alphabetical_after_source(self) -> None: @pytest.mark.unit def test_no_source(self) -> None: """Works without a legend named 'source'.""" - from portolan_cli.style import build_legends_manifest + from portolan_cli.viz.style import build_legends_manifest legends = [ self._legend_info("legends/zebra"), @@ -1130,7 +1130,7 @@ def test_no_source(self) -> None: @pytest.mark.unit def test_empty_list(self) -> None: """Empty input returns empty output.""" - from portolan_cli.style import build_legends_manifest + from portolan_cli.viz.style import build_legends_manifest manifest = build_legends_manifest([]) @@ -1145,7 +1145,7 @@ def test_registers_legend_assets_in_collection(self, tmp_path: Path) -> None: """Discovered legends are added as assets in collection.json.""" import json - from portolan_cli.style import discover_legends, register_legend_assets + from portolan_cli.viz.style import discover_legends, register_legend_assets collection_data = { "type": "Collection", @@ -1183,7 +1183,7 @@ def test_no_legends_no_manifest(self, tmp_path: Path) -> None: """No portolan:legends property when no legends exist.""" import json - from portolan_cli.style import register_legend_assets + from portolan_cli.viz.style import register_legend_assets collection_data = {"type": "Collection", "id": "test", "assets": {}} (tmp_path / "collection.json").write_text(json.dumps(collection_data)) @@ -1196,7 +1196,7 @@ def test_no_legends_no_manifest(self, tmp_path: Path) -> None: @pytest.mark.unit def test_no_op_without_collection_json(self, tmp_path: Path) -> None: """Does nothing when collection.json doesn't exist.""" - from portolan_cli.style import LegendInfo, register_legend_assets + from portolan_cli.viz.style import LegendInfo, register_legend_assets legends = [ LegendInfo( @@ -1216,7 +1216,7 @@ def test_removes_stale_legend_assets(self, tmp_path: Path) -> None: """Removes legend assets that no longer have files on disk.""" import json - from portolan_cli.style import LegendInfo, register_legend_assets + from portolan_cli.viz.style import LegendInfo, register_legend_assets collection_data = { "type": "Collection", diff --git a/tests/unit/test_sync.py b/tests/unit/test_sync.py index 8ae0cf8d..8eeee6d1 100644 --- a/tests/unit/test_sync.py +++ b/tests/unit/test_sync.py @@ -104,7 +104,7 @@ class TestSyncResult: @pytest.mark.unit def test_sync_result_success(self) -> None: """SyncResult should capture successful sync stats.""" - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult result = SyncResult( success=True, @@ -122,7 +122,7 @@ def test_sync_result_success(self) -> None: @pytest.mark.unit def test_sync_result_with_errors(self) -> None: """SyncResult should capture errors from any step.""" - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult result = SyncResult( success=False, @@ -140,9 +140,9 @@ def test_sync_result_with_errors(self) -> None: @pytest.mark.unit def test_sync_result_with_all_steps(self) -> None: """SyncResult should aggregate results from all steps.""" - from portolan_cli.pull import PullResult - from portolan_cli.push import PushResult - from portolan_cli.sync import SyncResult + from portolan_cli.sync.core import SyncResult + from portolan_cli.sync.pull import PullResult + from portolan_cli.sync.push import PushResult pull_result = PullResult( success=True, @@ -186,7 +186,7 @@ class TestOrchestrationSequence: @pytest.mark.unit def test_sync_calls_all_steps_in_order(self, managed_catalog: Path) -> None: """Sync should call pull, init, scan, check, push in that order.""" - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync call_order: list[str] = [] @@ -226,10 +226,12 @@ def track_push(*args: Any, **kwargs: Any) -> MagicMock: with ( patch("portolan_cli.sync.pull", side_effect=track_pull), - patch("portolan_cli.sync.init_catalog", side_effect=track_init), - patch("portolan_cli.sync.scan_directory", side_effect=track_scan), - patch("portolan_cli.sync.check_directory", side_effect=track_check), - patch("portolan_cli.sync.push_async", new_callable=AsyncMock, side_effect=track_push), + patch("portolan_cli.sync.core.init_catalog", side_effect=track_init), + patch("portolan_cli.sync.core.scan_directory", side_effect=track_scan), + patch("portolan_cli.sync.core.check_directory", side_effect=track_check), + patch( + "portolan_cli.sync.core.push_async", new_callable=AsyncMock, side_effect=track_push + ), ): sync( catalog_root=managed_catalog, @@ -266,7 +268,7 @@ class TestEarlyExit: @pytest.mark.unit def test_sync_exits_early_on_pull_failure(self, managed_catalog: Path) -> None: """Sync should exit early if pull fails.""" - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync def failing_pull(*args: Any, **kwargs: Any) -> MagicMock: result = MagicMock() @@ -276,10 +278,10 @@ def failing_pull(*args: Any, **kwargs: Any) -> MagicMock: with ( patch("portolan_cli.sync.pull", side_effect=failing_pull), - patch("portolan_cli.sync.init_catalog"), - patch("portolan_cli.sync.scan_directory") as mock_scan, - patch("portolan_cli.sync.check_directory") as mock_check, - patch("portolan_cli.sync.push_async", new_callable=AsyncMock) as mock_push, + patch("portolan_cli.sync.core.init_catalog"), + patch("portolan_cli.sync.core.scan_directory") as mock_scan, + patch("portolan_cli.sync.core.check_directory") as mock_check, + patch("portolan_cli.sync.core.push_async", new_callable=AsyncMock) as mock_push, ): result = sync( catalog_root=managed_catalog, @@ -296,7 +298,7 @@ def failing_pull(*args: Any, **kwargs: Any) -> MagicMock: @pytest.mark.unit def test_sync_continues_when_pull_up_to_date(self, managed_catalog: Path) -> None: """Sync should continue when pull indicates already up to date.""" - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync def up_to_date_pull(*args: Any, **kwargs: Any) -> MagicMock: result = MagicMock() @@ -314,11 +316,13 @@ def successful_push(*args: Any, **kwargs: Any) -> MagicMock: with ( patch("portolan_cli.sync.pull", side_effect=up_to_date_pull), - patch("portolan_cli.sync.init_catalog"), - patch("portolan_cli.sync.scan_directory") as mock_scan, - patch("portolan_cli.sync.check_directory") as mock_check, + patch("portolan_cli.sync.core.init_catalog"), + patch("portolan_cli.sync.core.scan_directory") as mock_scan, + patch("portolan_cli.sync.core.check_directory") as mock_check, patch( - "portolan_cli.sync.push_async", new_callable=AsyncMock, side_effect=successful_push + "portolan_cli.sync.core.push_async", + new_callable=AsyncMock, + side_effect=successful_push, ), ): # Set up mock returns @@ -348,7 +352,7 @@ class TestInitSkip: @pytest.mark.unit def test_sync_skips_init_if_managed(self, managed_catalog: Path) -> None: """Sync should skip init if catalog is already in MANAGED state.""" - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: result = MagicMock() @@ -365,11 +369,13 @@ def successful_push(*args: Any, **kwargs: Any) -> MagicMock: with ( patch("portolan_cli.sync.pull", side_effect=successful_pull), - patch("portolan_cli.sync.init_catalog") as mock_init, - patch("portolan_cli.sync.scan_directory") as mock_scan, - patch("portolan_cli.sync.check_directory") as mock_check, + patch("portolan_cli.sync.core.init_catalog") as mock_init, + patch("portolan_cli.sync.core.scan_directory") as mock_scan, + patch("portolan_cli.sync.core.check_directory") as mock_check, patch( - "portolan_cli.sync.push_async", new_callable=AsyncMock, side_effect=successful_push + "portolan_cli.sync.core.push_async", + new_callable=AsyncMock, + side_effect=successful_push, ), ): mock_scan.return_value = MagicMock(ready=[], has_errors=False) @@ -388,7 +394,7 @@ def successful_push(*args: Any, **kwargs: Any) -> MagicMock: @pytest.mark.unit def test_sync_calls_init_if_fresh(self, fresh_directory: Path) -> None: """Sync should call init if directory is in FRESH state.""" - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: result = MagicMock() @@ -412,11 +418,15 @@ def mock_init_catalog(path: Path, **kwargs: Any) -> tuple[Path, list[str]]: with ( patch("portolan_cli.sync.pull", side_effect=successful_pull), - patch("portolan_cli.sync.init_catalog", side_effect=mock_init_catalog) as mock_init, - patch("portolan_cli.sync.scan_directory") as mock_scan, - patch("portolan_cli.sync.check_directory") as mock_check, patch( - "portolan_cli.sync.push_async", new_callable=AsyncMock, side_effect=successful_push + "portolan_cli.sync.core.init_catalog", side_effect=mock_init_catalog + ) as mock_init, + patch("portolan_cli.sync.core.scan_directory") as mock_scan, + patch("portolan_cli.sync.core.check_directory") as mock_check, + patch( + "portolan_cli.sync.core.push_async", + new_callable=AsyncMock, + side_effect=successful_push, ), ): mock_scan.return_value = MagicMock(ready=[], has_errors=False) @@ -444,7 +454,7 @@ class TestDryRunMode: @pytest.mark.unit def test_sync_dry_run_passes_to_push(self, managed_catalog: Path) -> None: """Sync --dry-run should pass dry_run=True to push.""" - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: result = MagicMock() @@ -454,10 +464,10 @@ def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: with ( patch("portolan_cli.sync.pull", side_effect=successful_pull), - patch("portolan_cli.sync.init_catalog"), - patch("portolan_cli.sync.scan_directory") as mock_scan, - patch("portolan_cli.sync.check_directory") as mock_check, - patch("portolan_cli.sync.push_async", new_callable=AsyncMock) as mock_push, + patch("portolan_cli.sync.core.init_catalog"), + patch("portolan_cli.sync.core.scan_directory") as mock_scan, + patch("portolan_cli.sync.core.check_directory") as mock_check, + patch("portolan_cli.sync.core.push_async", new_callable=AsyncMock) as mock_push, ): mock_scan.return_value = MagicMock(ready=[], has_errors=False) mock_check.return_value = MagicMock(convertible_count=0, unsupported_count=0) @@ -478,14 +488,14 @@ def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: @pytest.mark.unit def test_sync_dry_run_passes_to_pull(self, managed_catalog: Path) -> None: """Sync --dry-run should pass dry_run=True to pull.""" - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync with ( patch("portolan_cli.sync.pull") as mock_pull, - patch("portolan_cli.sync.init_catalog"), - patch("portolan_cli.sync.scan_directory") as mock_scan, - patch("portolan_cli.sync.check_directory") as mock_check, - patch("portolan_cli.sync.push_async", new_callable=AsyncMock) as mock_push, + patch("portolan_cli.sync.core.init_catalog"), + patch("portolan_cli.sync.core.scan_directory") as mock_scan, + patch("portolan_cli.sync.core.check_directory") as mock_check, + patch("portolan_cli.sync.core.push_async", new_callable=AsyncMock) as mock_push, ): mock_pull.return_value = MagicMock(success=True, up_to_date=True) mock_scan.return_value = MagicMock(ready=[], has_errors=False) @@ -516,7 +526,7 @@ class TestForceMode: @pytest.mark.unit def test_sync_force_passes_to_push(self, managed_catalog: Path) -> None: """Sync --force should pass force=True to push.""" - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: result = MagicMock() @@ -526,10 +536,10 @@ def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: with ( patch("portolan_cli.sync.pull", side_effect=successful_pull), - patch("portolan_cli.sync.init_catalog"), - patch("portolan_cli.sync.scan_directory") as mock_scan, - patch("portolan_cli.sync.check_directory") as mock_check, - patch("portolan_cli.sync.push_async", new_callable=AsyncMock) as mock_push, + patch("portolan_cli.sync.core.init_catalog"), + patch("portolan_cli.sync.core.scan_directory") as mock_scan, + patch("portolan_cli.sync.core.check_directory") as mock_check, + patch("portolan_cli.sync.core.push_async", new_callable=AsyncMock) as mock_push, ): mock_scan.return_value = MagicMock(ready=[], has_errors=False) mock_check.return_value = MagicMock(convertible_count=0, unsupported_count=0) @@ -550,14 +560,14 @@ def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: @pytest.mark.unit def test_sync_force_passes_to_pull(self, managed_catalog: Path) -> None: """Sync --force should pass force=True to pull.""" - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync with ( patch("portolan_cli.sync.pull") as mock_pull, - patch("portolan_cli.sync.init_catalog"), - patch("portolan_cli.sync.scan_directory") as mock_scan, - patch("portolan_cli.sync.check_directory") as mock_check, - patch("portolan_cli.sync.push_async", new_callable=AsyncMock) as mock_push, + patch("portolan_cli.sync.core.init_catalog"), + patch("portolan_cli.sync.core.scan_directory") as mock_scan, + patch("portolan_cli.sync.core.check_directory") as mock_check, + patch("portolan_cli.sync.core.push_async", new_callable=AsyncMock) as mock_push, ): mock_pull.return_value = MagicMock(success=True, up_to_date=True) mock_scan.return_value = MagicMock(ready=[], has_errors=False) @@ -588,7 +598,7 @@ class TestFixMode: @pytest.mark.unit def test_sync_fix_passes_to_check(self, managed_catalog: Path) -> None: """Sync --fix should pass fix=True to check_directory.""" - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: result = MagicMock() @@ -598,10 +608,10 @@ def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: with ( patch("portolan_cli.sync.pull", side_effect=successful_pull), - patch("portolan_cli.sync.init_catalog"), - patch("portolan_cli.sync.scan_directory") as mock_scan, - patch("portolan_cli.sync.check_directory") as mock_check, - patch("portolan_cli.sync.push_async", new_callable=AsyncMock) as mock_push, + patch("portolan_cli.sync.core.init_catalog"), + patch("portolan_cli.sync.core.scan_directory") as mock_scan, + patch("portolan_cli.sync.core.check_directory") as mock_check, + patch("portolan_cli.sync.core.push_async", new_callable=AsyncMock) as mock_push, ): mock_scan.return_value = MagicMock(ready=[], has_errors=False) mock_check.return_value = MagicMock(convertible_count=0, unsupported_count=0) @@ -631,14 +641,14 @@ class TestProfilePassthrough: @pytest.mark.unit def test_sync_profile_passes_to_pull_and_push(self, managed_catalog: Path) -> None: """Sync --profile should pass profile to both pull and push.""" - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync with ( patch("portolan_cli.sync.pull") as mock_pull, - patch("portolan_cli.sync.init_catalog"), - patch("portolan_cli.sync.scan_directory") as mock_scan, - patch("portolan_cli.sync.check_directory") as mock_check, - patch("portolan_cli.sync.push_async", new_callable=AsyncMock) as mock_push, + patch("portolan_cli.sync.core.init_catalog"), + patch("portolan_cli.sync.core.scan_directory") as mock_scan, + patch("portolan_cli.sync.core.check_directory") as mock_check, + patch("portolan_cli.sync.core.push_async", new_callable=AsyncMock) as mock_push, ): mock_pull.return_value = MagicMock(success=True, up_to_date=True) mock_scan.return_value = MagicMock(ready=[], has_errors=False) @@ -672,8 +682,8 @@ class TestErrorHandling: @pytest.mark.unit def test_sync_handles_pull_error_gracefully(self, managed_catalog: Path) -> None: """Sync should handle pull errors and not crash.""" - from portolan_cli.pull import PullError - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync + from portolan_cli.sync.pull import PullError with patch("portolan_cli.sync.pull") as mock_pull: mock_pull.side_effect = PullError("Network timeout") @@ -691,8 +701,8 @@ def test_sync_handles_pull_error_gracefully(self, managed_catalog: Path) -> None @pytest.mark.unit def test_sync_handles_push_error_gracefully(self, managed_catalog: Path) -> None: """Sync should handle push errors and not crash.""" - from portolan_cli.push import PushConflictError - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync + from portolan_cli.sync.push import PushConflictError def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: result = MagicMock() @@ -702,10 +712,10 @@ def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: with ( patch("portolan_cli.sync.pull", side_effect=successful_pull), - patch("portolan_cli.sync.init_catalog"), - patch("portolan_cli.sync.scan_directory") as mock_scan, - patch("portolan_cli.sync.check_directory") as mock_check, - patch("portolan_cli.sync.push_async", new_callable=AsyncMock) as mock_push, + patch("portolan_cli.sync.core.init_catalog"), + patch("portolan_cli.sync.core.scan_directory") as mock_scan, + patch("portolan_cli.sync.core.check_directory") as mock_check, + patch("portolan_cli.sync.core.push_async", new_callable=AsyncMock) as mock_push, ): mock_scan.return_value = MagicMock(ready=[], has_errors=False) mock_check.return_value = MagicMock(convertible_count=0, unsupported_count=0) @@ -724,7 +734,7 @@ def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: @pytest.mark.unit def test_sync_handles_missing_catalog_root(self, tmp_path: Path) -> None: """Sync should handle missing catalog root directory.""" - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync non_existent = tmp_path / "does_not_exist" @@ -749,7 +759,7 @@ class TestCloneResult: @pytest.mark.unit def test_clone_result_stores_success(self, tmp_path: Path) -> None: """CloneResult should store success status.""" - from portolan_cli.sync import CloneResult + from portolan_cli.sync.core import CloneResult result = CloneResult( success=True, @@ -763,7 +773,7 @@ def test_clone_result_stores_success(self, tmp_path: Path) -> None: @pytest.mark.unit def test_clone_result_stores_errors(self, tmp_path: Path) -> None: """CloneResult should store error messages.""" - from portolan_cli.sync import CloneResult + from portolan_cli.sync.core import CloneResult result = CloneResult( success=False, @@ -782,7 +792,7 @@ class TestCloneFunction: @pytest.mark.unit def test_clone_fails_on_non_empty_directory(self, tmp_path: Path) -> None: """Clone should fail if target directory is not empty.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone # Create non-empty directory target = tmp_path / "target" @@ -801,12 +811,12 @@ def test_clone_fails_on_non_empty_directory(self, tmp_path: Path) -> None: @pytest.mark.unit def test_clone_creates_target_directory(self, tmp_path: Path) -> None: """Clone should create target directory if it doesn't exist.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone target = tmp_path / "new_catalog" with ( - patch("portolan_cli.sync.init_catalog") as mock_init, + patch("portolan_cli.sync.core.init_catalog") as mock_init, patch("portolan_cli.sync.pull") as mock_pull, ): # Mock successful operations @@ -829,12 +839,12 @@ def test_clone_creates_target_directory(self, tmp_path: Path) -> None: @pytest.mark.unit def test_clone_calls_init_catalog(self, tmp_path: Path) -> None: """Clone should initialize the catalog.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone target = tmp_path / "new_catalog" with ( - patch("portolan_cli.sync.init_catalog") as mock_init, + patch("portolan_cli.sync.core.init_catalog") as mock_init, patch("portolan_cli.sync.pull") as mock_pull, ): mock_pull.return_value = MagicMock( @@ -856,12 +866,12 @@ def test_clone_calls_init_catalog(self, tmp_path: Path) -> None: @pytest.mark.unit def test_clone_calls_pull_with_correct_args(self, tmp_path: Path) -> None: """Clone should call pull with the correct arguments.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone target = tmp_path / "new_catalog" with ( - patch("portolan_cli.sync.init_catalog"), + patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.pull") as mock_pull, ): mock_pull.return_value = MagicMock( @@ -889,12 +899,12 @@ def test_clone_calls_pull_with_correct_args(self, tmp_path: Path) -> None: @pytest.mark.unit def test_clone_fails_when_pull_fails(self, tmp_path: Path) -> None: """Clone should fail if pull fails.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone target = tmp_path / "new_catalog" with ( - patch("portolan_cli.sync.init_catalog"), + patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.pull") as mock_pull, ): mock_pull.return_value = MagicMock( @@ -914,12 +924,12 @@ def test_clone_fails_when_pull_fails(self, tmp_path: Path) -> None: @pytest.mark.unit def test_clone_fails_when_remote_not_found(self, tmp_path: Path) -> None: """Clone should fail with helpful message when remote doesn't exist.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone target = tmp_path / "new_catalog" with ( - patch("portolan_cli.sync.init_catalog"), + patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.pull") as mock_pull, ): mock_pull.return_value = MagicMock( @@ -940,12 +950,12 @@ def test_clone_fails_when_remote_not_found(self, tmp_path: Path) -> None: @pytest.mark.unit def test_clone_returns_pull_result(self, tmp_path: Path) -> None: """Clone should return the pull result.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone target = tmp_path / "new_catalog" with ( - patch("portolan_cli.sync.init_catalog"), + patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.pull") as mock_pull, ): mock_pull_result = MagicMock( @@ -1041,15 +1051,15 @@ def test_sync_dry_run_pull_step_makes_no_network_calls( Regression test for bug #137: pull() called _fetch_remote_versions unconditionally even when dry_run=True. """ - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync with ( - patch("portolan_cli.pull._fetch_remote_versions") as mock_pull_fetch, - patch("portolan_cli.push.setup_store") as mock_push_setup, - patch("portolan_cli.push._fetch_remote_versions") as mock_push_fetch, - patch("portolan_cli.sync.init_catalog"), - patch("portolan_cli.sync.scan_directory") as mock_scan, - patch("portolan_cli.sync.check_directory") as mock_check, + patch("portolan_cli.sync.pull._fetch_remote_versions") as mock_pull_fetch, + patch("portolan_cli.sync.push.setup_store") as mock_push_setup, + patch("portolan_cli.sync.push._fetch_remote_versions") as mock_push_fetch, + patch("portolan_cli.sync.core.init_catalog"), + patch("portolan_cli.sync.core.scan_directory") as mock_scan, + patch("portolan_cli.sync.core.check_directory") as mock_check, ): mock_scan.return_value = MagicMock(ready=[], has_errors=False) mock_check.return_value = MagicMock(convertible_count=0, unsupported_count=0) @@ -1072,15 +1082,15 @@ def test_sync_dry_run_push_step_makes_no_network_calls( self, catalog_with_versions: Path ) -> None: """sync(dry_run=True) must not make network calls in the push step.""" - from portolan_cli.sync import sync + from portolan_cli.sync.core import sync with ( - patch("portolan_cli.pull._fetch_remote_versions") as mock_pull_fetch, - patch("portolan_cli.push.setup_store") as mock_setup, - patch("portolan_cli.push._fetch_remote_versions") as mock_push_fetch, - patch("portolan_cli.sync.init_catalog"), - patch("portolan_cli.sync.scan_directory") as mock_scan, - patch("portolan_cli.sync.check_directory") as mock_check, + patch("portolan_cli.sync.pull._fetch_remote_versions") as mock_pull_fetch, + patch("portolan_cli.sync.push.setup_store") as mock_setup, + patch("portolan_cli.sync.push._fetch_remote_versions") as mock_push_fetch, + patch("portolan_cli.sync.core.init_catalog"), + patch("portolan_cli.sync.core.scan_directory") as mock_scan, + patch("portolan_cli.sync.core.check_directory") as mock_check, ): mock_scan.return_value = MagicMock(ready=[], has_errors=False) mock_check.return_value = MagicMock(convertible_count=0, unsupported_count=0) @@ -1114,7 +1124,7 @@ class TestListRemoteCollectionsNestedCatalogs: @pytest.mark.unit def test_list_remote_collections_simple_flat_structure(self, tmp_path: Path) -> None: """list_remote_collections should work with flat catalog structure.""" - from portolan_cli.sync import list_remote_collections + from portolan_cli.sync.core import list_remote_collections # Mock catalog with direct child collections (no nesting) catalog_data = { @@ -1126,7 +1136,7 @@ def test_list_remote_collections_simple_flat_structure(self, tmp_path: Path) -> ], } - with patch("portolan_cli.sync._fetch_remote_catalog_json") as mock_fetch: + with patch("portolan_cli.sync.core._fetch_remote_catalog_json") as mock_fetch: mock_fetch.return_value = catalog_data collections = list_remote_collections("s3://bucket/catalog") @@ -1144,7 +1154,7 @@ def test_list_remote_collections_nested_subcatalogs(self, tmp_path: Path) -> Non └── hittekaart/ └── collection.json (actual collection) """ - from portolan_cli.sync import list_remote_collections + from portolan_cli.sync.core import list_remote_collections # Root catalog points to subcatalog (NOT a collection) root_catalog = { @@ -1171,7 +1181,7 @@ def mock_fetch(remote_url: str, **kwargs: Any) -> dict[str, Any]: return climate_catalog raise ValueError(f"Unexpected URL: {remote_url}") - with patch("portolan_cli.sync._fetch_remote_catalog_json", side_effect=mock_fetch): + with patch("portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch): collections = list_remote_collections("s3://bucket/catalog") # Full path includes the subcatalog directory for proper clone/pull @@ -1187,7 +1197,7 @@ def test_list_remote_collections_deeply_nested(self) -> None: └── netherlands/catalog.json └── amsterdam/collection.json """ - from portolan_cli.sync import list_remote_collections + from portolan_cli.sync.core import list_remote_collections root_catalog = { "type": "Catalog", @@ -1218,7 +1228,7 @@ def mock_fetch(remote_url: str, **kwargs: Any) -> dict[str, Any]: else: return root_catalog - with patch("portolan_cli.sync._fetch_remote_catalog_json", side_effect=mock_fetch): + with patch("portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch): collections = list_remote_collections("s3://bucket/catalog") # Full path includes all subcatalog directories @@ -1235,7 +1245,7 @@ def test_list_remote_collections_mixed_structure(self) -> None: └── organized/catalog.json (subcatalog) └── nested-data/collection.json """ - from portolan_cli.sync import list_remote_collections + from portolan_cli.sync.core import list_remote_collections root_catalog = { "type": "Catalog", @@ -1257,7 +1267,7 @@ def mock_fetch(remote_url: str, **kwargs: Any) -> dict[str, Any]: return organized_catalog return root_catalog - with patch("portolan_cli.sync._fetch_remote_catalog_json", side_effect=mock_fetch): + with patch("portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch): collections = list_remote_collections("s3://bucket/catalog") # Full paths: direct collection keeps path, nested gets subcatalog prefix @@ -1269,7 +1279,7 @@ def test_list_remote_collections_max_depth_limit(self) -> None: Even without circular references, deeply nested structures should be bounded. """ - from portolan_cli.sync import list_remote_collections + from portolan_cli.sync.core import list_remote_collections # Create a deeply nested structure (more than reasonable depth) def make_catalog(level: int, max_level: int = 20) -> dict[str, Any]: @@ -1299,7 +1309,7 @@ def mock_fetch(remote_url: str, **kwargs: Any) -> dict[str, Any]: return make_catalog(level) return make_catalog(0) - with patch("portolan_cli.sync._fetch_remote_catalog_json", side_effect=mock_fetch): + with patch("portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch): # Should not raise, should stop at max depth _ = list_remote_collections("s3://bucket/catalog") @@ -1317,7 +1327,7 @@ def test_list_remote_collections_circular_reference_detection(self) -> None: └── s3://bucket/catalog/a/catalog.json (circular - absolute URL) └── ./real-data/collection.json """ - from portolan_cli.sync import list_remote_collections + from portolan_cli.sync.core import list_remote_collections root_catalog = { "type": "Catalog", @@ -1357,7 +1367,7 @@ def mock_fetch(remote_url: str, **kwargs: Any) -> dict[str, Any]: return a_catalog return root_catalog - with patch("portolan_cli.sync._fetch_remote_catalog_json", side_effect=mock_fetch): + with patch("portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch): collections = list_remote_collections("s3://bucket/catalog") # Should find the collection without infinite loop (full path includes subcatalogs) @@ -1368,7 +1378,7 @@ def mock_fetch(remote_url: str, **kwargs: Any) -> dict[str, Any]: @pytest.mark.unit def test_list_remote_collections_absolute_urls(self) -> None: """list_remote_collections should handle absolute URLs in child links.""" - from portolan_cli.sync import list_remote_collections + from portolan_cli.sync.core import list_remote_collections root_catalog = { "type": "Catalog", @@ -1397,7 +1407,7 @@ def mock_fetch(remote_url: str, **kwargs: Any) -> dict[str, Any]: return region_catalog return root_catalog - with patch("portolan_cli.sync._fetch_remote_catalog_json", side_effect=mock_fetch): + with patch("portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch): collections = list_remote_collections("s3://bucket/catalog") # Full path includes subcatalog directory @@ -1410,7 +1420,7 @@ def test_list_remote_collections_preserves_collection_path(self) -> None: When cloning nested catalogs, we need the full path to pull correctly: e.g., 'climate/hittekaart' not just 'hittekaart' """ - from portolan_cli.sync import list_remote_collections + from portolan_cli.sync.core import list_remote_collections root_catalog = { "type": "Catalog", @@ -1429,7 +1439,7 @@ def mock_fetch(remote_url: str, **kwargs: Any) -> dict[str, Any]: return climate_catalog return root_catalog - with patch("portolan_cli.sync._fetch_remote_catalog_json", side_effect=mock_fetch): + with patch("portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch): collections = list_remote_collections("s3://bucket/catalog") # Full path is required for clone/pull to work correctly @@ -1442,7 +1452,7 @@ class TestCloneNestedCatalogs: @pytest.mark.unit def test_clone_discovers_nested_collections(self, tmp_path: Path) -> None: """clone() without collection arg should discover nested collections.""" - from portolan_cli.sync import clone + from portolan_cli.sync.core import clone root_catalog = { "type": "Catalog", @@ -1464,8 +1474,8 @@ def mock_fetch(remote_url: str, **kwargs: Any) -> dict[str, Any]: target = tmp_path / "cloned" with ( - patch("portolan_cli.sync._fetch_remote_catalog_json", side_effect=mock_fetch), - patch("portolan_cli.sync.init_catalog"), + patch("portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch), + patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.pull") as mock_pull, ): mock_pull.return_value = MagicMock( diff --git a/tests/unit/test_tabular_support.py b/tests/unit/test_tabular_support.py index 8389f288..edfd5220 100644 --- a/tests/unit/test_tabular_support.py +++ b/tests/unit/test_tabular_support.py @@ -21,7 +21,7 @@ import pyarrow.parquet as pq import pytest -from portolan_cli.scan_classify import ( +from portolan_cli.scan.classify import ( FileCategory, classify_file, ) @@ -159,7 +159,7 @@ def test_constants_tabular_includes_excel(self) -> None: def test_scan_classify_tabular_matches_constants(self) -> None: """scan_classify.py TABULAR_EXTENSIONS should exactly match constants.py.""" from portolan_cli.constants import TABULAR_EXTENSIONS as CONST_TABULAR - from portolan_cli.scan_classify import TABULAR_EXTENSIONS as SCAN_TABULAR + from portolan_cli.scan.classify import TABULAR_EXTENSIONS as SCAN_TABULAR # Exact equality required — any drift (in either direction) should fail # This prevents constants.py and scan_classify.py from diverging silently @@ -167,7 +167,7 @@ def test_scan_classify_tabular_matches_constants(self) -> None: def test_parquet_not_in_geo_asset_extensions(self) -> None: """Parquet should NOT be in GEO_ASSET_EXTENSIONS (peeking required).""" - from portolan_cli.scan_classify import GEO_ASSET_EXTENSIONS + from portolan_cli.scan.classify import GEO_ASSET_EXTENSIONS # .parquet requires metadata peeking, so it shouldn't be in the simple # extension-based GEO_ASSET list @@ -180,7 +180,7 @@ class TestIsGeoParquet: def test_is_geoparquet_with_geo_metadata(self, tmp_path: Path) -> None: """Files with geo metadata should return True.""" - from portolan_cli.scan_classify import is_geoparquet + from portolan_cli.scan.classify import is_geoparquet parquet_file = tmp_path / "geo.parquet" table = pa.table({"id": [1], "geometry": [b"wkb"]}) @@ -198,7 +198,7 @@ def test_is_geoparquet_with_geo_metadata(self, tmp_path: Path) -> None: def test_is_geoparquet_without_geo_metadata(self, tmp_path: Path) -> None: """Files without geo metadata should return False.""" - from portolan_cli.scan_classify import is_geoparquet + from portolan_cli.scan.classify import is_geoparquet parquet_file = tmp_path / "plain.parquet" table = pa.table({"id": [1], "value": [100]}) @@ -208,7 +208,7 @@ def test_is_geoparquet_without_geo_metadata(self, tmp_path: Path) -> None: def test_is_geoparquet_handles_read_error(self, tmp_path: Path) -> None: """Should return False on read errors (not crash).""" - from portolan_cli.scan_classify import is_geoparquet + from portolan_cli.scan.classify import is_geoparquet bad_file = tmp_path / "not_parquet.parquet" bad_file.write_text("this is not a parquet file") @@ -218,7 +218,7 @@ def test_is_geoparquet_handles_read_error(self, tmp_path: Path) -> None: def test_is_geoparquet_nonexistent_file(self, tmp_path: Path) -> None: """Should return False for nonexistent files.""" - from portolan_cli.scan_classify import is_geoparquet + from portolan_cli.scan.classify import is_geoparquet missing = tmp_path / "missing.parquet" @@ -1038,7 +1038,7 @@ def test_csv_with_header_only_handled(self, tmp_path: Path) -> None: def test_invalid_parquet_returns_false_for_is_geoparquet(self, tmp_path: Path) -> None: """Invalid Parquet file should return False for is_geoparquet, not crash.""" - from portolan_cli.scan_classify import is_geoparquet + from portolan_cli.scan.classify import is_geoparquet # Create a file that's not valid Parquet bad_parquet = tmp_path / "bad.parquet" diff --git a/tests/unit/test_thumbnail.py b/tests/unit/test_thumbnail.py index fcf9c565..12531ac0 100644 --- a/tests/unit/test_thumbnail.py +++ b/tests/unit/test_thumbnail.py @@ -26,7 +26,7 @@ class TestThumbnailConfig: @pytest.mark.unit def test_default_values(self) -> None: """ThumbnailConfig has sensible defaults.""" - from portolan_cli.thumbnail import ThumbnailConfig + from portolan_cli.viz.thumbnail import ThumbnailConfig config = ThumbnailConfig() assert config.enabled is True @@ -39,7 +39,7 @@ def test_default_values(self) -> None: @pytest.mark.unit def test_custom_values(self) -> None: """ThumbnailConfig accepts custom values.""" - from portolan_cli.thumbnail import ThumbnailConfig + from portolan_cli.viz.thumbnail import ThumbnailConfig config = ThumbnailConfig( enabled=False, @@ -59,7 +59,7 @@ def test_custom_values(self) -> None: @pytest.mark.unit def test_basemap_none_disables(self) -> None: """Setting basemap_provider to 'none' disables basemap.""" - from portolan_cli.thumbnail import ThumbnailConfig + from portolan_cli.viz.thumbnail import ThumbnailConfig config = ThumbnailConfig(basemap_provider="none") assert config.basemap_provider == "none" @@ -67,7 +67,7 @@ def test_basemap_none_disables(self) -> None: @pytest.mark.unit def test_frozen_dataclass(self) -> None: """ThumbnailConfig is immutable (frozen).""" - from portolan_cli.thumbnail import ThumbnailConfig + from portolan_cli.viz.thumbnail import ThumbnailConfig config = ThumbnailConfig() with pytest.raises(AttributeError): @@ -85,15 +85,15 @@ class TestGenerateThumbnailFromPmtiles: @pytest.mark.unit def test_returns_path_on_success(self, tmp_path: Path) -> None: """Returns Path to generated thumbnail on success.""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_thumbnail_from_pmtiles + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_thumbnail_from_pmtiles pmtiles_path = tmp_path / "data.pmtiles" pmtiles_path.touch() # Mock the PMTiles reading to return fake geometries and bounds with ( - patch("portolan_cli.thumbnail._read_pmtiles_geometries") as mock_read, - patch("portolan_cli.thumbnail._render_geometries") as mock_render, + patch("portolan_cli.viz.thumbnail._read_pmtiles_geometries") as mock_read, + patch("portolan_cli.viz.thumbnail._render_geometries") as mock_render, ): mock_read.return_value = ( [{"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}], @@ -111,12 +111,12 @@ def test_returns_path_on_success(self, tmp_path: Path) -> None: @pytest.mark.unit def test_returns_none_when_no_geometries(self, tmp_path: Path) -> None: """Returns None when PMTiles has no extractable geometries.""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_thumbnail_from_pmtiles + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_thumbnail_from_pmtiles pmtiles_path = tmp_path / "empty.pmtiles" pmtiles_path.touch() - with patch("portolan_cli.thumbnail._read_pmtiles_geometries") as mock_read: + with patch("portolan_cli.viz.thumbnail._read_pmtiles_geometries") as mock_read: mock_read.return_value = ([], None) # empty geometries, no bounds config = ThumbnailConfig() @@ -127,12 +127,12 @@ def test_returns_none_when_no_geometries(self, tmp_path: Path) -> None: @pytest.mark.unit def test_returns_none_on_read_error(self, tmp_path: Path) -> None: """Returns None when PMTiles file cannot be read.""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_thumbnail_from_pmtiles + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_thumbnail_from_pmtiles pmtiles_path = tmp_path / "corrupt.pmtiles" pmtiles_path.touch() - with patch("portolan_cli.thumbnail._read_pmtiles_geometries") as mock_read: + with patch("portolan_cli.viz.thumbnail._read_pmtiles_geometries") as mock_read: mock_read.side_effect = Exception("Corrupt file") config = ThumbnailConfig() @@ -143,14 +143,14 @@ def test_returns_none_on_read_error(self, tmp_path: Path) -> None: @pytest.mark.unit def test_output_path_convention(self, tmp_path: Path) -> None: """Output uses .thumb.jpg naming convention to avoid clobbering user files.""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_thumbnail_from_pmtiles + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_thumbnail_from_pmtiles pmtiles_path = tmp_path / "my-data.pmtiles" pmtiles_path.touch() with ( - patch("portolan_cli.thumbnail._read_pmtiles_geometries") as mock_read, - patch("portolan_cli.thumbnail._render_geometries") as mock_render, + patch("portolan_cli.viz.thumbnail._read_pmtiles_geometries") as mock_read, + patch("portolan_cli.viz.thumbnail._render_geometries") as mock_render, ): mock_read.return_value = ( [{"type": "Point", "coordinates": [0, 0]}], @@ -189,7 +189,7 @@ def test_render_geometries_sets_limits_before_basemap(self, tmp_path: Path) -> N """ pytest.importorskip("matplotlib") - from portolan_cli.thumbnail import ThumbnailConfig, _render_geometries + from portolan_cli.viz.thumbnail import ThumbnailConfig, _render_geometries output_path = tmp_path / "test.jpg" bounds = (-122.5, 37.5, -122.0, 38.0) # San Francisco area @@ -215,7 +215,7 @@ def capture_axis_state( captured_xlim = ax.get_xlim() captured_ylim = ax.get_ylim() - with patch("portolan_cli.thumbnail.add_basemap", side_effect=capture_axis_state): + with patch("portolan_cli.viz.thumbnail.add_basemap", side_effect=capture_axis_state): _render_geometries(geometries, output_path, config, bounds=bounds) # Verify axis limits were set BEFORE add_basemap was called @@ -235,13 +235,13 @@ def test_render_geometries_no_basemap_when_no_bounds(self, tmp_path: Path) -> No """_render_geometries skips basemap when bounds is None.""" pytest.importorskip("matplotlib") - from portolan_cli.thumbnail import ThumbnailConfig, _render_geometries + from portolan_cli.viz.thumbnail import ThumbnailConfig, _render_geometries output_path = tmp_path / "test.jpg" geometries = [{"type": "Point", "coordinates": [0, 0]}] config = ThumbnailConfig(basemap_provider="CartoDB.Positron") - with patch("portolan_cli.thumbnail.add_basemap") as mock_basemap: + with patch("portolan_cli.viz.thumbnail.add_basemap") as mock_basemap: _render_geometries(geometries, output_path, config, bounds=None) # add_basemap should NOT be called when bounds is None @@ -259,14 +259,14 @@ class TestGenerateThumbnailFromGeoparquet: @pytest.mark.unit def test_returns_path_on_success(self, tmp_path: Path) -> None: """Returns Path to generated thumbnail on success.""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet gpq_path = tmp_path / "data.parquet" gpq_path.touch() with ( - patch("portolan_cli.thumbnail._read_geoparquet_bounds") as mock_read, - patch("portolan_cli.thumbnail._render_geoparquet") as mock_render, + patch("portolan_cli.viz.thumbnail._read_geoparquet_bounds") as mock_read, + patch("portolan_cli.viz.thumbnail._render_geoparquet") as mock_render, ): mock_read.return_value = (0.0, 0.0, 1.0, 1.0) # minx, miny, maxx, maxy mock_render.return_value = True @@ -280,12 +280,12 @@ def test_returns_path_on_success(self, tmp_path: Path) -> None: @pytest.mark.unit def test_returns_none_when_empty(self, tmp_path: Path) -> None: """Returns None when GeoParquet has no geometries.""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet gpq_path = tmp_path / "empty.parquet" gpq_path.touch() - with patch("portolan_cli.thumbnail._read_geoparquet_bounds") as mock_read: + with patch("portolan_cli.viz.thumbnail._read_geoparquet_bounds") as mock_read: mock_read.return_value = None config = ThumbnailConfig() @@ -296,14 +296,14 @@ def test_returns_none_when_empty(self, tmp_path: Path) -> None: @pytest.mark.unit def test_output_path_convention(self, tmp_path: Path) -> None: """Output uses .thumb.jpg naming convention.""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet gpq_path = tmp_path / "census.parquet" gpq_path.touch() with ( - patch("portolan_cli.thumbnail._read_geoparquet_bounds") as mock_read, - patch("portolan_cli.thumbnail._render_geoparquet") as mock_render, + patch("portolan_cli.viz.thumbnail._read_geoparquet_bounds") as mock_read, + patch("portolan_cli.viz.thumbnail._render_geoparquet") as mock_render, ): mock_read.return_value = (0.0, 0.0, 1.0, 1.0) mock_render.return_value = True @@ -325,7 +325,7 @@ class TestGenerateVectorThumbnail: @pytest.mark.unit def test_prefers_pmtiles_when_available(self, tmp_path: Path) -> None: """Prefers PMTiles over GeoParquet when both available.""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_vector_thumbnail + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_vector_thumbnail pmtiles_path = tmp_path / "data.pmtiles" gpq_path = tmp_path / "data.parquet" @@ -333,8 +333,8 @@ def test_prefers_pmtiles_when_available(self, tmp_path: Path) -> None: gpq_path.touch() with ( - patch("portolan_cli.thumbnail.generate_thumbnail_from_pmtiles") as mock_pmtiles, - patch("portolan_cli.thumbnail.generate_thumbnail_from_geoparquet") as mock_gpq, + patch("portolan_cli.viz.thumbnail.generate_thumbnail_from_pmtiles") as mock_pmtiles, + patch("portolan_cli.viz.thumbnail.generate_thumbnail_from_geoparquet") as mock_gpq, ): mock_pmtiles.return_value = tmp_path / "data.thumb.jpg" @@ -352,7 +352,7 @@ def test_prefers_pmtiles_when_available(self, tmp_path: Path) -> None: @pytest.mark.unit def test_falls_back_to_geoparquet(self, tmp_path: Path) -> None: """Falls back to GeoParquet when PMTiles fails.""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_vector_thumbnail + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_vector_thumbnail pmtiles_path = tmp_path / "data.pmtiles" gpq_path = tmp_path / "data.parquet" @@ -360,8 +360,8 @@ def test_falls_back_to_geoparquet(self, tmp_path: Path) -> None: gpq_path.touch() with ( - patch("portolan_cli.thumbnail.generate_thumbnail_from_pmtiles") as mock_pmtiles, - patch("portolan_cli.thumbnail.generate_thumbnail_from_geoparquet") as mock_gpq, + patch("portolan_cli.viz.thumbnail.generate_thumbnail_from_pmtiles") as mock_pmtiles, + patch("portolan_cli.viz.thumbnail.generate_thumbnail_from_geoparquet") as mock_gpq, ): mock_pmtiles.return_value = None # PMTiles failed mock_gpq.return_value = tmp_path / "data.thumb.jpg" @@ -380,12 +380,12 @@ def test_falls_back_to_geoparquet(self, tmp_path: Path) -> None: @pytest.mark.unit def test_geoparquet_only(self, tmp_path: Path) -> None: """Works with GeoParquet only (no PMTiles).""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_vector_thumbnail + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_vector_thumbnail gpq_path = tmp_path / "data.parquet" gpq_path.touch() - with patch("portolan_cli.thumbnail.generate_thumbnail_from_geoparquet") as mock_gpq: + with patch("portolan_cli.viz.thumbnail.generate_thumbnail_from_geoparquet") as mock_gpq: mock_gpq.return_value = tmp_path / "data.thumb.jpg" config = ThumbnailConfig() @@ -401,7 +401,7 @@ def test_geoparquet_only(self, tmp_path: Path) -> None: @pytest.mark.unit def test_returns_none_when_disabled(self, tmp_path: Path) -> None: """Returns None when thumbnails are disabled in config.""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_vector_thumbnail + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_vector_thumbnail gpq_path = tmp_path / "data.parquet" gpq_path.touch() @@ -418,7 +418,7 @@ def test_returns_none_when_disabled(self, tmp_path: Path) -> None: @pytest.mark.unit def test_returns_none_when_no_sources(self) -> None: """Returns None when neither PMTiles nor GeoParquet provided.""" - from portolan_cli.thumbnail import ThumbnailConfig, generate_vector_thumbnail + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_vector_thumbnail config = ThumbnailConfig() result = generate_vector_thumbnail( @@ -441,13 +441,13 @@ class TestAddBasemap: @pytest.mark.unit def test_calls_contextily_with_provider(self) -> None: """Calls contextily.add_basemap with correct provider.""" - from portolan_cli.thumbnail import add_basemap + from portolan_cli.viz.thumbnail import add_basemap mock_ax = MagicMock() bounds = (-122.5, 37.5, -122.0, 38.0) # SF Bay area mock_ctx = MagicMock() - with patch("portolan_cli.thumbnail._ensure_contextily", return_value=mock_ctx): + with patch("portolan_cli.viz.thumbnail._ensure_contextily", return_value=mock_ctx): add_basemap(mock_ax, bounds, "CartoDB.Positron", opacity=1.0, zoom_adjust=0) mock_ctx.add_basemap.assert_called_once() @@ -457,13 +457,13 @@ def test_calls_contextily_with_provider(self) -> None: @pytest.mark.unit def test_skips_when_provider_none(self) -> None: """Does nothing when provider is 'none'.""" - from portolan_cli.thumbnail import add_basemap + from portolan_cli.viz.thumbnail import add_basemap mock_ax = MagicMock() bounds = (-122.5, 37.5, -122.0, 38.0) mock_ctx = MagicMock() - with patch("portolan_cli.thumbnail._ensure_contextily", return_value=mock_ctx): + with patch("portolan_cli.viz.thumbnail._ensure_contextily", return_value=mock_ctx): add_basemap(mock_ax, bounds, "none", opacity=1.0, zoom_adjust=0) mock_ctx.add_basemap.assert_not_called() @@ -471,12 +471,12 @@ def test_skips_when_provider_none(self) -> None: @pytest.mark.unit def test_handles_import_error(self) -> None: """Gracefully handles missing contextily.""" - from portolan_cli.thumbnail import add_basemap + from portolan_cli.viz.thumbnail import add_basemap mock_ax = MagicMock() bounds = (-122.5, 37.5, -122.0, 38.0) - with patch("portolan_cli.thumbnail._ensure_contextily", return_value=None): + with patch("portolan_cli.viz.thumbnail._ensure_contextily", return_value=None): # Should not raise, just skip basemap add_basemap(mock_ax, bounds, "CartoDB.Positron", opacity=1.0, zoom_adjust=0) @@ -503,7 +503,7 @@ def test_real_pmtiles_thumbnail(self, pmtiles_path: Path, tmp_path: Path) -> Non # Copy fixture to tmp_path so we can write output there import shutil - from portolan_cli.thumbnail import ThumbnailConfig, generate_thumbnail_from_pmtiles + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_thumbnail_from_pmtiles test_pmtiles = tmp_path / "sample.pmtiles" shutil.copy(pmtiles_path, test_pmtiles) @@ -529,7 +529,7 @@ class TestGetThumbnailConfig: @pytest.mark.unit def test_returns_defaults_when_no_config(self, tmp_path: Path) -> None: """Returns default config when no thumbnails section exists.""" - from portolan_cli.thumbnail import ThumbnailConfig, get_thumbnail_config + from portolan_cli.viz.thumbnail import ThumbnailConfig, get_thumbnail_config # Create minimal catalog structure portolan_dir = tmp_path / ".portolan" @@ -543,7 +543,7 @@ def test_returns_defaults_when_no_config(self, tmp_path: Path) -> None: @pytest.mark.unit def test_loads_custom_config(self, tmp_path: Path) -> None: """Loads custom thumbnail config from YAML.""" - from portolan_cli.thumbnail import get_thumbnail_config + from portolan_cli.viz.thumbnail import get_thumbnail_config portolan_dir = tmp_path / ".portolan" portolan_dir.mkdir() @@ -570,7 +570,7 @@ def test_loads_custom_config(self, tmp_path: Path) -> None: @pytest.mark.unit def test_disabled_config(self, tmp_path: Path) -> None: """Respects enabled: false.""" - from portolan_cli.thumbnail import get_thumbnail_config + from portolan_cli.viz.thumbnail import get_thumbnail_config portolan_dir = tmp_path / ".portolan" portolan_dir.mkdir() @@ -599,7 +599,7 @@ def test_process_tile_data_uses_geometry_bounds_not_tile_bounds(self) -> None: Bug: At z=0, tile (0,0) covers the entire world (-180 to 180, -85 to 85). Using tile bounds causes basemap to render globally while data is invisible. """ - from portolan_cli.thumbnail import _process_tile_data, _tile_bounds + from portolan_cli.viz.thumbnail import _process_tile_data, _tile_bounds mock_mvt_data = { "layer1": { @@ -654,7 +654,7 @@ def test_read_bounds_from_metadata(self, tmp_path: Path) -> None: import json from unittest.mock import MagicMock, patch - from portolan_cli.thumbnail import _read_geoparquet_bounds + from portolan_cli.viz.thumbnail import _read_geoparquet_bounds gpq_path = tmp_path / "test.parquet" @@ -674,7 +674,7 @@ def test_read_bounds_fallback_when_no_metadata(self, tmp_path: Path) -> None: import json from unittest.mock import MagicMock, patch - from portolan_cli.thumbnail import _read_geoparquet_bounds + from portolan_cli.viz.thumbnail import _read_geoparquet_bounds gpq_path = tmp_path / "test.parquet" @@ -711,7 +711,7 @@ def test_reads_all_features_without_sampling(self, tmp_path: Path) -> None: import json from unittest.mock import MagicMock, patch - from portolan_cli.thumbnail import _read_geoparquet_for_thumbnail + from portolan_cli.viz.thumbnail import _read_geoparquet_for_thumbnail gpq_path = tmp_path / "large.parquet" @@ -757,7 +757,7 @@ def test_render_does_not_reproject_data(self, tmp_path: Path) -> None: from unittest.mock import MagicMock, patch - from portolan_cli.thumbnail import ThumbnailConfig, _render_geoparquet + from portolan_cli.viz.thumbnail import ThumbnailConfig, _render_geoparquet gpq_path = tmp_path / "test.parquet" output_path = tmp_path / "test.thumb.jpg" @@ -771,13 +771,13 @@ def test_render_does_not_reproject_data(self, tmp_path: Path) -> None: with ( patch( - "portolan_cli.thumbnail._read_geoparquet_for_thumbnail", + "portolan_cli.viz.thumbnail._read_geoparquet_for_thumbnail", return_value=(mock_gdf, full_bbox, "EPSG:4326"), ), patch("matplotlib.pyplot.subplots") as mock_subplots, patch("matplotlib.pyplot.savefig"), patch("matplotlib.pyplot.close"), - patch("portolan_cli.thumbnail.add_basemap"), + patch("portolan_cli.viz.thumbnail.add_basemap"), ): mock_ax = MagicMock() mock_subplots.return_value = (MagicMock(), mock_ax) @@ -802,7 +802,7 @@ def test_render_passes_crs_to_basemap(self, tmp_path: Path) -> None: from unittest.mock import MagicMock, patch - from portolan_cli.thumbnail import ThumbnailConfig, _render_geoparquet + from portolan_cli.viz.thumbnail import ThumbnailConfig, _render_geoparquet gpq_path = tmp_path / "test.parquet" output_path = tmp_path / "test.thumb.jpg" @@ -816,13 +816,13 @@ def test_render_passes_crs_to_basemap(self, tmp_path: Path) -> None: with ( patch( - "portolan_cli.thumbnail._read_geoparquet_for_thumbnail", + "portolan_cli.viz.thumbnail._read_geoparquet_for_thumbnail", return_value=(mock_gdf, full_bbox, "EPSG:4326"), ), patch("matplotlib.pyplot.subplots") as mock_subplots, patch("matplotlib.pyplot.savefig"), patch("matplotlib.pyplot.close"), - patch("portolan_cli.thumbnail.add_basemap") as mock_add_basemap, + patch("portolan_cli.viz.thumbnail.add_basemap") as mock_add_basemap, ): mock_ax = MagicMock() mock_subplots.return_value = (MagicMock(), mock_ax) @@ -842,7 +842,7 @@ def test_render_uses_native_bounds(self, tmp_path: Path) -> None: from unittest.mock import MagicMock, patch - from portolan_cli.thumbnail import ThumbnailConfig, _render_geoparquet + from portolan_cli.viz.thumbnail import ThumbnailConfig, _render_geoparquet gpq_path = tmp_path / "test.parquet" output_path = tmp_path / "test.thumb.jpg" @@ -857,7 +857,7 @@ def test_render_uses_native_bounds(self, tmp_path: Path) -> None: with ( patch( - "portolan_cli.thumbnail._read_geoparquet_for_thumbnail", + "portolan_cli.viz.thumbnail._read_geoparquet_for_thumbnail", return_value=(mock_gdf, full_bbox, "EPSG:4326"), ), patch("matplotlib.pyplot.subplots") as mock_subplots, @@ -874,7 +874,7 @@ def test_render_uses_native_bounds(self, tmp_path: Path) -> None: # the aspect-cap + margin helper (#518). Values stay in native # degrees (~ -61), nowhere near EPSG:3857 metres, confirming the # data was not reprojected. - from portolan_cli.thumbnail import _frame_bounds + from portolan_cli.viz.thumbnail import _frame_bounds framed = _frame_bounds(full_bbox) mock_ax.set_xlim.assert_called_once_with(framed[0], framed[2]) @@ -894,7 +894,7 @@ def test_real_geoparquet_thumbnail_with_4326_data( import shutil - from portolan_cli.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet # Use simple.parquet which is in OGC:CRS84 (equivalent to EPSG:4326) src_path = fixtures_dir / "simple.parquet" @@ -923,7 +923,7 @@ class TestFrameBounds: @pytest.mark.unit def test_square_bounds_only_get_margin(self) -> None: """A square extent is not aspect-adjusted; only the margin expands it.""" - from portolan_cli.thumbnail import _frame_bounds + from portolan_cli.viz.thumbnail import _frame_bounds framed = _frame_bounds((0.0, 0.0, 10.0, 10.0), max_aspect=2.5, margin=0.05) # 5% of a 10-unit span = 0.5 on each side, no aspect change. @@ -935,7 +935,7 @@ def test_square_bounds_only_get_margin(self) -> None: @pytest.mark.unit def test_tall_sliver_capped_by_widening(self) -> None: """A 1:100 (w:h) sliver is widened so aspect is capped at max_aspect.""" - from portolan_cli.thumbnail import _frame_bounds + from portolan_cli.viz.thumbnail import _frame_bounds framed = _frame_bounds((0.0, 0.0, 1.0, 100.0), max_aspect=2.5, margin=0.0) width = framed[2] - framed[0] @@ -949,7 +949,7 @@ def test_tall_sliver_capped_by_widening(self) -> None: @pytest.mark.unit def test_wide_sliver_capped_by_heightening(self) -> None: """A 100:1 (w:h) sliver is heightened so aspect is capped at max_aspect.""" - from portolan_cli.thumbnail import _frame_bounds + from portolan_cli.viz.thumbnail import _frame_bounds framed = _frame_bounds((0.0, 0.0, 100.0, 1.0), max_aspect=2.5, margin=0.0) width = framed[2] - framed[0] @@ -961,7 +961,7 @@ def test_wide_sliver_capped_by_heightening(self) -> None: @pytest.mark.unit def test_degenerate_point_gets_finite_box(self) -> None: """A zero-area extent (single point) is expanded to a finite box.""" - from portolan_cli.thumbnail import _frame_bounds + from portolan_cli.viz.thumbnail import _frame_bounds framed = _frame_bounds((5.0, 5.0, 5.0, 5.0)) assert framed[2] > framed[0] @@ -974,7 +974,7 @@ class TestComputeRenderParams: @pytest.mark.unit def test_sparse_points_larger_than_dense(self) -> None: """Few points render with larger markers than a dense point cloud.""" - from portolan_cli.thumbnail import _compute_render_params + from portolan_cli.viz.thumbnail import _compute_render_params sparse = _compute_render_params("point", 5) dense = _compute_render_params("point", 50_000) @@ -983,7 +983,7 @@ def test_sparse_points_larger_than_dense(self) -> None: @pytest.mark.unit def test_dense_polygons_more_transparent(self) -> None: """Dense polygon layers get lower fill opacity so they don't blob solid.""" - from portolan_cli.thumbnail import _compute_render_params + from portolan_cli.viz.thumbnail import _compute_render_params sparse = _compute_render_params("polygon", 5) dense = _compute_render_params("polygon", 50_000) @@ -996,7 +996,7 @@ def test_opacity_never_washed_out(self) -> None: This is the anti-washout guarantee: the pale WFS default (0.2) must never reach the canvas via these params. """ - from portolan_cli.thumbnail import _compute_render_params + from portolan_cli.viz.thumbnail import _compute_render_params for geom in ("point", "line", "polygon"): for count in (1, 100, 1_000, 100_000): @@ -1005,7 +1005,7 @@ def test_opacity_never_washed_out(self) -> None: @pytest.mark.unit def test_polygon_has_visible_edge(self) -> None: """Polygons always get a non-hairline stroke width.""" - from portolan_cli.thumbnail import _compute_render_params + from portolan_cli.viz.thumbnail import _compute_render_params assert _compute_render_params("polygon", 100).stroke_width > 0 @@ -1019,7 +1019,7 @@ def test_off_axis_dimensions_are_floored_not_zeroed(self, geom: str) -> None: off-axis dimension were zero (points need marker_size, lines/edges need stroke_width). Regression for that: every type must keep both positive. """ - from portolan_cli.thumbnail import _compute_render_params + from portolan_cli.viz.thumbnail import _compute_render_params params = _compute_render_params(geom, 100) assert params.marker_size > 0, f"{geom}: points would be invisible" @@ -1060,7 +1060,7 @@ def test_geoparquet_uses_punchy_paint_not_style(self, tmp_path: Path) -> None: """_render_geoparquet ignores style opacity/edge, uses thumbnail preset.""" pytest.importorskip("matplotlib") - from portolan_cli.thumbnail import ( + from portolan_cli.viz.thumbnail import ( THUMB_EDGE_COLOR, ThumbnailConfig, _render_geoparquet, @@ -1078,7 +1078,7 @@ def test_geoparquet_uses_punchy_paint_not_style(self, tmp_path: Path) -> None: with ( patch( - "portolan_cli.thumbnail._read_geoparquet_for_thumbnail", + "portolan_cli.viz.thumbnail._read_geoparquet_for_thumbnail", return_value=(mock_gdf, full_bbox, "EPSG:4326"), ), patch("matplotlib.pyplot.subplots") as mock_subplots, @@ -1109,7 +1109,7 @@ def test_pmtiles_path_uses_punchy_fill_not_pale_style(self, tmp_path: Path) -> N np = pytest.importorskip("numpy") pil = pytest.importorskip("PIL.Image") - from portolan_cli.thumbnail import ThumbnailConfig, _render_geometries + from portolan_cli.viz.thumbnail import ThumbnailConfig, _render_geometries style_path = self._pale_style_file(tmp_path) output_path = tmp_path / "pmtiles.thumb.jpg" @@ -1162,7 +1162,7 @@ def test_render_is_not_blank(self, kind: str, tmp_path: Path) -> None: pil = pytest.importorskip("PIL.Image") from shapely.geometry import LineString, Point, Polygon - from portolan_cli.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet if kind == "sparse_points": geoms = [Point(x, x) for x in range(-50, 50, 5)] @@ -1203,7 +1203,7 @@ def test_mixed_geometry_minority_points_stay_visible(self, tmp_path: Path) -> No pil = pytest.importorskip("PIL.Image") from shapely.geometry import Point, Polygon - from portolan_cli.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet # 50 polygons (dominant) bottom-left, 16 points top-right (minority). polys = [ @@ -1237,7 +1237,7 @@ def test_renders_geographic_crs_with_projected_coords(self, tmp_path: Path) -> N pytest.importorskip("matplotlib") from shapely.geometry import Polygon - from portolan_cli.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet + from portolan_cli.viz.thumbnail import ThumbnailConfig, generate_thumbnail_from_geoparquet # CRS84 (geographic) declared, but coordinates are ~1.6e6 (projected). geoms = [ diff --git a/tests/unit/test_thumbnail_style.py b/tests/unit/test_thumbnail_style.py index 5d38193f..fa26d5ec 100644 --- a/tests/unit/test_thumbnail_style.py +++ b/tests/unit/test_thumbnail_style.py @@ -11,7 +11,7 @@ import pytest -from portolan_cli.thumbnail_style import ( +from portolan_cli.viz.thumbnail_style import ( ThumbnailStyle, load_thumbnail_style, parse_match_expression, diff --git a/tests/unit/test_upload.py b/tests/unit/test_upload.py index 6468c887..9a21d0c0 100644 --- a/tests/unit/test_upload.py +++ b/tests/unit/test_upload.py @@ -104,7 +104,7 @@ class TestParseObjectStoreUrl: @pytest.mark.unit def test_s3_url_simple(self) -> None: """S3 URL with bucket only.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("s3://mybucket") assert bucket_url == "s3://mybucket" @@ -113,7 +113,7 @@ def test_s3_url_simple(self) -> None: @pytest.mark.unit def test_s3_url_with_prefix(self) -> None: """S3 URL with bucket and prefix.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("s3://mybucket/data/output") assert bucket_url == "s3://mybucket" @@ -122,7 +122,7 @@ def test_s3_url_with_prefix(self) -> None: @pytest.mark.unit def test_gs_url_simple(self) -> None: """GCS URL with bucket only.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("gs://mybucket") assert bucket_url == "gs://mybucket" @@ -131,7 +131,7 @@ def test_gs_url_simple(self) -> None: @pytest.mark.unit def test_gs_url_with_prefix(self) -> None: """GCS URL with bucket and prefix.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("gs://mybucket/path/to/data") assert bucket_url == "gs://mybucket" @@ -140,7 +140,7 @@ def test_gs_url_with_prefix(self) -> None: @pytest.mark.unit def test_az_url_with_container(self) -> None: """Azure URL with account and container.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("az://myaccount/mycontainer") assert bucket_url == "az://myaccount/mycontainer" @@ -149,7 +149,7 @@ def test_az_url_with_container(self) -> None: @pytest.mark.unit def test_az_url_with_path(self) -> None: """Azure URL with account, container, and path.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("az://myaccount/mycontainer/data/path") assert bucket_url == "az://myaccount/mycontainer" @@ -158,7 +158,7 @@ def test_az_url_with_path(self) -> None: @pytest.mark.unit def test_az_url_missing_container_raises(self) -> None: """Azure URL with only account (no container) should raise ValueError.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url with pytest.raises(ValueError, match="Invalid Azure URL"): parse_object_store_url("az://myaccount") @@ -166,7 +166,7 @@ def test_az_url_missing_container_raises(self) -> None: @pytest.mark.unit def test_unsupported_scheme_raises(self) -> None: """Unsupported URL scheme should raise ValueError.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url with pytest.raises(ValueError, match="Unsupported URL scheme"): parse_object_store_url("ftp://server/path") @@ -183,7 +183,7 @@ def test_http_url_returned_unchanged(self) -> None: The full URL is returned as the bucket_url for obstore HTTPStore, and no prefix manipulation is performed. """ - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("http://example.com/data/file.parquet") assert bucket_url == "http://example.com/data/file.parquet" @@ -192,7 +192,7 @@ def test_http_url_returned_unchanged(self) -> None: @pytest.mark.unit def test_https_url_returned_unchanged(self) -> None: """HTTPS URLs are returned unchanged with empty prefix.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("https://example.com/path/to/data/") assert bucket_url == "https://example.com/path/to/data/" @@ -206,7 +206,7 @@ def test_http_url_no_trailing_slash_normalization(self) -> None: through without modification. This is intentional: HTTP URLs may point to specific resources where the trailing slash is meaningful. """ - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url # Trailing slash preserved (not normalized) bucket_url, prefix = parse_object_store_url("https://api.example.com/v1/") @@ -224,7 +224,7 @@ def test_s3_url_trailing_slash_normalized(self) -> None: Regression test for issue #144: trailing slash in destination URL causes double-slash in path construction, leading to parse errors. """ - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("s3://mybucket/prefix/") assert bucket_url == "s3://mybucket" @@ -233,7 +233,7 @@ def test_s3_url_trailing_slash_normalized(self) -> None: @pytest.mark.unit def test_s3_url_multiple_trailing_slashes_normalized(self) -> None: """S3 URL with multiple trailing slashes should have all stripped.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("s3://mybucket/prefix///") assert bucket_url == "s3://mybucket" @@ -242,7 +242,7 @@ def test_s3_url_multiple_trailing_slashes_normalized(self) -> None: @pytest.mark.unit def test_gs_url_trailing_slash_normalized(self) -> None: """GCS URL with trailing slash should have it stripped from prefix.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("gs://mybucket/path/to/data/") assert bucket_url == "gs://mybucket" @@ -251,7 +251,7 @@ def test_gs_url_trailing_slash_normalized(self) -> None: @pytest.mark.unit def test_az_url_trailing_slash_normalized(self) -> None: """Azure URL with trailing slash should have it stripped from prefix.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("az://myaccount/mycontainer/data/") assert bucket_url == "az://myaccount/mycontainer" @@ -263,7 +263,7 @@ def test_s3_bucket_only_with_trailing_slash(self) -> None: s3://bucket/ should result in empty prefix, not "/". """ - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("s3://mybucket/") assert bucket_url == "s3://mybucket" @@ -280,7 +280,7 @@ def test_s3_internal_double_slashes_preserved(self) -> None: Note: While unusual, some object storage systems may interpret double slashes differently. Portolan preserves user intent. """ - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("s3://mybucket/a//b/") assert bucket_url == "s3://mybucket" @@ -289,7 +289,7 @@ def test_s3_internal_double_slashes_preserved(self) -> None: @pytest.mark.unit def test_gs_internal_double_slashes_preserved(self) -> None: """GCS internal double slashes preserved.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("gs://mybucket/path//to//data/") assert bucket_url == "gs://mybucket" @@ -298,7 +298,7 @@ def test_gs_internal_double_slashes_preserved(self) -> None: @pytest.mark.unit def test_az_internal_double_slashes_preserved(self) -> None: """Azure internal double slashes preserved.""" - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url bucket_url, prefix = parse_object_store_url("az://account/container/a//b/") assert bucket_url == "az://account/container" @@ -343,7 +343,7 @@ def test_prefix_never_ends_with_slash( This is the core invariant that issue #144 violated. """ - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url # Build URL with optional trailing slashes # Azure requires account/container structure, so add a container for Azure @@ -403,7 +403,7 @@ def test_path_construction_no_double_slashes( Simulates the path construction in push.py: key = f"{prefix}/{collection}/versions.json" """ - from portolan_cli.upload import parse_object_store_url + from portolan_cli.sync.upload import parse_object_store_url # Build URL - Azure requires account/container structure if scheme == "az": @@ -436,7 +436,7 @@ class TestCheckCredentials: @pytest.mark.unit def test_s3_with_env_vars(self) -> None: """S3 credentials should be found from environment variables.""" - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials with patch.dict( os.environ, @@ -449,10 +449,10 @@ def test_s3_with_env_vars(self) -> None: @pytest.mark.unit def test_s3_missing_credentials_gives_hint(self) -> None: """Missing S3 credentials should return helpful hints.""" - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials with patch.dict(os.environ, {}, clear=True): - with patch("portolan_cli.upload._load_aws_credentials_from_profile") as mock_load: + with patch("portolan_cli.sync.upload._load_aws_credentials_from_profile") as mock_load: mock_load.return_value = (None, None, None, None) valid, hint = check_credentials("s3://mybucket/path") @@ -463,7 +463,7 @@ def test_s3_missing_credentials_gives_hint(self) -> None: @pytest.mark.unit def test_s3_with_profile(self, mock_aws_credentials: Path) -> None: """S3 credentials should be loaded from AWS profile.""" - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials # Fixture patches Path.home() - assert it exists to mark as used assert mock_aws_credentials.exists() @@ -475,7 +475,7 @@ def test_s3_with_profile(self, mock_aws_credentials: Path) -> None: @pytest.mark.unit def test_s3_with_missing_profile(self, mock_aws_credentials: Path) -> None: """Missing AWS profile should return error with hints.""" - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials # Fixture patches Path.home() - assert it exists to mark as used assert mock_aws_credentials.exists() @@ -487,7 +487,7 @@ def test_s3_with_missing_profile(self, mock_aws_credentials: Path) -> None: @pytest.mark.unit def test_gcs_with_credentials_file(self, tmp_path: Path) -> None: """GCS credentials should be found from GOOGLE_APPLICATION_CREDENTIALS.""" - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials creds_file = tmp_path / "service_account.json" creds_file.write_text("{}") # Minimal valid JSON @@ -502,7 +502,7 @@ def test_gcs_missing_credentials(self) -> None: """Missing GCS credentials should return helpful hints.""" from pathlib import Path - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials # Mock ADC file check - don't clear env vars completely as it breaks # Path.home() on Windows (needs HOME or USERPROFILE) @@ -525,7 +525,7 @@ def test_gcs_missing_credentials(self) -> None: @pytest.mark.unit def test_gcs_with_service_account_key_env(self) -> None: """GCS credentials should be found from inline GOOGLE_SERVICE_ACCOUNT_KEY.""" - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials with patch.dict(os.environ, {"GOOGLE_SERVICE_ACCOUNT_KEY": '{"type": "service_account"}'}): valid, hint = check_credentials("gs://mybucket/path") @@ -535,7 +535,7 @@ def test_gcs_with_service_account_key_env(self) -> None: @pytest.mark.unit def test_gcs_with_adc_file(self, tmp_path: Path) -> None: """GCS credentials should be found from ADC file in default location.""" - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials # Mock Path.home() to use tmp_path and create ADC file adc_dir = tmp_path / ".config" / "gcloud" @@ -554,7 +554,7 @@ def test_gcs_with_adc_file(self, tmp_path: Path) -> None: @pytest.mark.unit def test_azure_with_access_key_alias(self) -> None: """Azure credentials should be found from AZURE_STORAGE_ACCESS_KEY alias.""" - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials with patch.dict(os.environ, {"AZURE_STORAGE_ACCESS_KEY": "testkey"}): valid, hint = check_credentials("az://myaccount/container") @@ -564,7 +564,7 @@ def test_azure_with_access_key_alias(self) -> None: @pytest.mark.unit def test_azure_with_account_key(self) -> None: """Azure credentials should be found from account key env var.""" - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials with patch.dict(os.environ, {"AZURE_STORAGE_ACCOUNT_KEY": "testkey"}): valid, hint = check_credentials("az://myaccount/container") @@ -574,7 +574,7 @@ def test_azure_with_account_key(self) -> None: @pytest.mark.unit def test_azure_with_sas_token(self) -> None: """Azure credentials should be found from SAS token env var.""" - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials with patch.dict(os.environ, {"AZURE_STORAGE_SAS_TOKEN": "sastoken"}): valid, hint = check_credentials("az://myaccount/container") @@ -584,7 +584,7 @@ def test_azure_with_sas_token(self) -> None: @pytest.mark.unit def test_azure_missing_credentials(self) -> None: """Missing Azure credentials should return helpful hints.""" - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials with patch.dict(os.environ, {}, clear=True): valid, hint = check_credentials("az://myaccount/container") @@ -594,7 +594,7 @@ def test_azure_missing_credentials(self) -> None: @pytest.mark.unit def test_http_url_always_valid(self) -> None: """HTTP URLs should always return valid (no auth needed).""" - from portolan_cli.upload import check_credentials + from portolan_cli.sync.upload import check_credentials valid, hint = check_credentials("https://example.com/data.parquet") assert valid is True @@ -612,7 +612,7 @@ class TestUploadResult: @pytest.mark.unit def test_upload_result_success(self) -> None: """UploadResult should capture successful upload stats.""" - from portolan_cli.upload import UploadResult + from portolan_cli.sync.upload import UploadResult result = UploadResult( success=True, @@ -631,7 +631,7 @@ def test_upload_result_success(self) -> None: @pytest.mark.unit def test_upload_result_partial_failure(self) -> None: """UploadResult should capture partial failures.""" - from portolan_cli.upload import UploadResult + from portolan_cli.sync.upload import UploadResult fake_error = Exception("Upload failed") result = UploadResult( @@ -659,10 +659,10 @@ class TestUploadFile: @pytest.mark.unit def test_upload_file_success(self, temp_file: Path) -> None: """Single file upload should succeed and return result.""" - from portolan_cli.upload import upload_file + from portolan_cli.sync.upload import upload_file - with patch("portolan_cli.upload.obs") as mock_obs: - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.obs") as mock_obs: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -683,9 +683,9 @@ def test_upload_file_success(self, temp_file: Path) -> None: @pytest.mark.unit def test_upload_file_dry_run(self, temp_file: Path) -> None: """Dry-run should not perform actual upload.""" - from portolan_cli.upload import upload_file + from portolan_cli.sync.upload import upload_file - with patch("portolan_cli.upload.obs") as mock_obs: + with patch("portolan_cli.sync.upload.obs") as mock_obs: result = upload_file( source=temp_file, destination="s3://mybucket/data.parquet", @@ -699,7 +699,7 @@ def test_upload_file_dry_run(self, temp_file: Path) -> None: @pytest.mark.unit def test_upload_file_nonexistent_raises(self, tmp_path: Path) -> None: """Uploading nonexistent file should raise FileNotFoundError.""" - from portolan_cli.upload import upload_file + from portolan_cli.sync.upload import upload_file with pytest.raises(FileNotFoundError): upload_file( @@ -710,10 +710,10 @@ def test_upload_file_nonexistent_raises(self, tmp_path: Path) -> None: @pytest.mark.unit def test_upload_file_with_custom_endpoint(self, temp_file: Path) -> None: """Custom S3 endpoint (MinIO) should be passed to store.""" - from portolan_cli.upload import upload_file + from portolan_cli.sync.upload import upload_file - with patch("portolan_cli.upload.obs"): - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.obs"): + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -736,10 +736,10 @@ def test_upload_file_with_custom_endpoint(self, temp_file: Path) -> None: @pytest.mark.unit def test_upload_file_strips_existing_scheme_from_endpoint(self, temp_file: Path) -> None: """S3 endpoint with existing scheme should not double-prepend protocol.""" - from portolan_cli.upload import upload_file + from portolan_cli.sync.upload import upload_file - with patch("portolan_cli.upload.obs"): - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.obs"): + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -764,10 +764,10 @@ def test_upload_file_strips_existing_scheme_from_endpoint(self, temp_file: Path) @pytest.mark.unit def test_upload_file_preserves_target_key(self, temp_file: Path) -> None: """Upload should use exact destination key when not ending with /.""" - from portolan_cli.upload import upload_file + from portolan_cli.sync.upload import upload_file - with patch("portolan_cli.upload.obs") as mock_obs: - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.obs") as mock_obs: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -788,10 +788,10 @@ def test_upload_file_preserves_target_key(self, temp_file: Path) -> None: @pytest.mark.unit def test_upload_file_appends_filename_to_dir(self, temp_file: Path) -> None: """Upload to directory (ending with /) should append filename.""" - from portolan_cli.upload import upload_file + from portolan_cli.sync.upload import upload_file - with patch("portolan_cli.upload.obs") as mock_obs: - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.obs") as mock_obs: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -820,10 +820,10 @@ class TestUploadDirectory: @pytest.mark.unit def test_upload_directory_all_files(self, temp_dir_with_files: Path) -> None: """Directory upload should upload all files.""" - from portolan_cli.upload import upload_directory + from portolan_cli.sync.upload import upload_directory - with patch("portolan_cli.upload.obs") as mock_obs: - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.obs") as mock_obs: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -843,10 +843,10 @@ def test_upload_directory_all_files(self, temp_dir_with_files: Path) -> None: @pytest.mark.unit def test_upload_directory_with_pattern(self, temp_dir_with_files: Path) -> None: """Directory upload with pattern should filter files.""" - from portolan_cli.upload import upload_directory + from portolan_cli.sync.upload import upload_directory - with patch("portolan_cli.upload.obs") as mock_obs: - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.obs") as mock_obs: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store @@ -867,9 +867,9 @@ def test_upload_directory_with_pattern(self, temp_dir_with_files: Path) -> None: @pytest.mark.unit def test_upload_directory_dry_run(self, temp_dir_with_files: Path) -> None: """Dry-run should not perform actual uploads.""" - from portolan_cli.upload import upload_directory + from portolan_cli.sync.upload import upload_directory - with patch("portolan_cli.upload.obs") as mock_obs: + with patch("portolan_cli.sync.upload.obs") as mock_obs: result = upload_directory( source=temp_dir_with_files, destination="s3://mybucket/data/", @@ -883,7 +883,7 @@ def test_upload_directory_dry_run(self, temp_dir_with_files: Path) -> None: @pytest.mark.unit def test_upload_directory_empty(self, tmp_path: Path) -> None: """Empty directory should return appropriate result.""" - from portolan_cli.upload import upload_directory + from portolan_cli.sync.upload import upload_directory empty_dir = tmp_path / "empty" empty_dir.mkdir() @@ -900,7 +900,7 @@ def test_upload_directory_empty(self, tmp_path: Path) -> None: @pytest.mark.unit def test_upload_directory_fail_fast_true(self, temp_dir_with_files: Path) -> None: """fail_fast=True should stop on first error and report failure.""" - from portolan_cli.upload import upload_directory + from portolan_cli.sync.upload import upload_directory def mock_put_fails_first(*args: object, **kwargs: object) -> None: # Always fail to ensure we catch the fail_fast behavior @@ -908,8 +908,8 @@ def mock_put_fails_first(*args: object, **kwargs: object) -> None: _ = (args, kwargs) raise OSError("Upload failed") - with patch("portolan_cli.upload.obs") as mock_obs: - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.obs") as mock_obs: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store mock_obs.put.side_effect = mock_put_fails_first @@ -935,15 +935,15 @@ def mock_put_fails_first(*args: object, **kwargs: object) -> None: @pytest.mark.unit def test_upload_directory_fail_fast_false(self, temp_dir_with_files: Path) -> None: """fail_fast=False should continue and collect all errors.""" - from portolan_cli.upload import upload_directory + from portolan_cli.sync.upload import upload_directory def mock_put_fails_all(*args: object, **kwargs: object) -> None: # Mark args/kwargs as used (vulture) - they're passed by obstore.put() _ = (args, kwargs) raise OSError("Upload failed") - with patch("portolan_cli.upload.obs") as mock_obs: - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.obs") as mock_obs: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store mock_obs.put.side_effect = mock_put_fails_all @@ -965,15 +965,15 @@ def mock_put_fails_all(*args: object, **kwargs: object) -> None: @pytest.mark.unit def test_upload_directory_preserves_structure(self, temp_dir_with_files: Path) -> None: """Directory upload should preserve relative path structure.""" - from portolan_cli.upload import upload_directory + from portolan_cli.sync.upload import upload_directory target_keys: list[str] = [] def capture_target_key(store: object, key: str, source: object, **kwargs: object) -> None: target_keys.append(key) - with patch("portolan_cli.upload.obs") as mock_obs: - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.obs") as mock_obs: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: mock_store = MagicMock() mock_s3_store.return_value = mock_store mock_obs.put.side_effect = capture_target_key @@ -1004,7 +1004,7 @@ class TestLoadAwsCredentials: @pytest.mark.unit def test_load_default_profile(self, mock_aws_credentials: Path) -> None: """Should load credentials from default profile.""" - from portolan_cli.upload import _load_aws_credentials_from_profile + from portolan_cli.sync.upload import _load_aws_credentials_from_profile # Fixture patches Path.home() - assert it exists to mark as used assert mock_aws_credentials.exists() @@ -1020,7 +1020,7 @@ def test_load_default_profile(self, mock_aws_credentials: Path) -> None: @pytest.mark.unit def test_load_named_profile(self, mock_aws_credentials: Path) -> None: """Should load credentials from named profile.""" - from portolan_cli.upload import _load_aws_credentials_from_profile + from portolan_cli.sync.upload import _load_aws_credentials_from_profile # Fixture patches Path.home() - assert it exists to mark as used assert mock_aws_credentials.exists() @@ -1038,7 +1038,7 @@ def test_load_temporary_credentials_with_session_token( self, mock_aws_credentials: Path ) -> None: """Should load the session token for temporary (STS) credentials.""" - from portolan_cli.upload import _load_aws_credentials_from_profile + from portolan_cli.sync.upload import _load_aws_credentials_from_profile assert mock_aws_credentials.exists() access_key, secret_key, session_token, _region = _load_aws_credentials_from_profile( @@ -1052,7 +1052,7 @@ def test_load_temporary_credentials_with_session_token( @pytest.mark.unit def test_load_missing_profile(self, mock_aws_credentials: Path) -> None: """Missing profile should return None values.""" - from portolan_cli.upload import _load_aws_credentials_from_profile + from portolan_cli.sync.upload import _load_aws_credentials_from_profile # Fixture patches Path.home() - assert it exists to mark as used assert mock_aws_credentials.exists() @@ -1072,10 +1072,10 @@ class TestSetupStoreSessionToken: @pytest.mark.unit def test_session_token_forwarded_to_s3store(self, mock_aws_credentials: Path) -> None: """A profile with aws_session_token must pass it through to S3Store.""" - from portolan_cli.upload import _setup_store_and_kwargs + from portolan_cli.sync.upload import _setup_store_and_kwargs assert mock_aws_credentials.exists() - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: _setup_store_and_kwargs( "s3://us-west-2.opendata.source.coop", profile="temp-sts", @@ -1090,10 +1090,10 @@ def test_session_token_forwarded_to_s3store(self, mock_aws_credentials: Path) -> @pytest.mark.unit def test_no_session_token_for_long_term_credentials(self, mock_aws_credentials: Path) -> None: """Long-term credentials (no token) must not set aws_session_token.""" - from portolan_cli.upload import _setup_store_and_kwargs + from portolan_cli.sync.upload import _setup_store_and_kwargs assert mock_aws_credentials.exists() - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: _setup_store_and_kwargs( "s3://us-west-2.opendata.source.coop", profile="myprofile", @@ -1106,12 +1106,12 @@ def test_no_session_token_for_long_term_credentials(self, mock_aws_credentials: @pytest.mark.unit def test_session_token_from_environment(self, monkeypatch: pytest.MonkeyPatch) -> None: """AWS_SESSION_TOKEN env var is forwarded when no profile is given.""" - from portolan_cli.upload import _setup_store_and_kwargs + from portolan_cli.sync.upload import _setup_store_and_kwargs monkeypatch.setenv("AWS_ACCESS_KEY_ID", "ASIAENVKEY") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "envsecret") monkeypatch.setenv("AWS_SESSION_TOKEN", "env-session-token") - with patch("portolan_cli.upload.S3Store") as mock_s3_store: + with patch("portolan_cli.sync.upload.S3Store") as mock_s3_store: _setup_store_and_kwargs( "s3://us-west-2.opendata.source.coop", profile=None, @@ -1133,7 +1133,7 @@ class TestRegionInference: @pytest.mark.unit def test_infer_us_west_2(self) -> None: """Should infer us-west-2 from bucket name.""" - from portolan_cli.upload import _try_infer_region_from_bucket + from portolan_cli.sync.upload import _try_infer_region_from_bucket region = _try_infer_region_from_bucket("us-west-2.opendata.source.coop") assert region == "us-west-2" @@ -1141,7 +1141,7 @@ def test_infer_us_west_2(self) -> None: @pytest.mark.unit def test_infer_eu_central_1(self) -> None: """Should infer eu-central-1 from bucket name.""" - from portolan_cli.upload import _try_infer_region_from_bucket + from portolan_cli.sync.upload import _try_infer_region_from_bucket region = _try_infer_region_from_bucket("eu-central-1.example.com") assert region == "eu-central-1" @@ -1149,7 +1149,7 @@ def test_infer_eu_central_1(self) -> None: @pytest.mark.unit def test_infer_ap_northeast_1(self) -> None: """Should infer ap-northeast-1 from bucket name.""" - from portolan_cli.upload import _try_infer_region_from_bucket + from portolan_cli.sync.upload import _try_infer_region_from_bucket region = _try_infer_region_from_bucket("ap-northeast-1.data.example.com") assert region == "ap-northeast-1" @@ -1157,7 +1157,7 @@ def test_infer_ap_northeast_1(self) -> None: @pytest.mark.unit def test_no_region_in_bucket(self) -> None: """Should return None for bucket without region pattern.""" - from portolan_cli.upload import _try_infer_region_from_bucket + from portolan_cli.sync.upload import _try_infer_region_from_bucket region = _try_infer_region_from_bucket("mybucket") assert region is None @@ -1165,7 +1165,7 @@ def test_no_region_in_bucket(self) -> None: @pytest.mark.unit def test_partial_region_pattern(self) -> None: """Should return None for partial region pattern.""" - from portolan_cli.upload import _try_infer_region_from_bucket + from portolan_cli.sync.upload import _try_infer_region_from_bucket region = _try_infer_region_from_bucket("us-west.example.com") assert region is None @@ -1182,7 +1182,7 @@ class TestBuildTargetKey: @pytest.mark.unit def test_build_key_with_prefix(self, tmp_path: Path) -> None: """Should build target key with prefix.""" - from portolan_cli.upload import _build_target_key + from portolan_cli.sync.upload import _build_target_key source_dir = tmp_path / "data" source_dir.mkdir() @@ -1195,7 +1195,7 @@ def test_build_key_with_prefix(self, tmp_path: Path) -> None: @pytest.mark.unit def test_build_key_nested_file(self, tmp_path: Path) -> None: """Should preserve nested directory structure in key.""" - from portolan_cli.upload import _build_target_key + from portolan_cli.sync.upload import _build_target_key source_dir = tmp_path / "data" nested_dir = source_dir / "subdir" @@ -1209,7 +1209,7 @@ def test_build_key_nested_file(self, tmp_path: Path) -> None: @pytest.mark.unit def test_build_key_no_prefix(self, tmp_path: Path) -> None: """Should work without prefix.""" - from portolan_cli.upload import _build_target_key + from portolan_cli.sync.upload import _build_target_key source_dir = tmp_path / "data" source_dir.mkdir() @@ -1222,7 +1222,7 @@ def test_build_key_no_prefix(self, tmp_path: Path) -> None: @pytest.mark.unit def test_build_key_strips_trailing_slash(self, tmp_path: Path) -> None: """Should strip trailing slash from prefix.""" - from portolan_cli.upload import _build_target_key + from portolan_cli.sync.upload import _build_target_key source_dir = tmp_path / "data" source_dir.mkdir() @@ -1245,10 +1245,10 @@ class TestOutputIntegration: @pytest.mark.unit def test_upload_file_uses_output_functions(self, temp_file: Path, capsys: object) -> None: """Upload should use portolan_cli.output for logging.""" - from portolan_cli.upload import upload_file + from portolan_cli.sync.upload import upload_file - with patch("portolan_cli.upload.obs"): - with patch("portolan_cli.upload.S3Store"): + with patch("portolan_cli.sync.upload.obs"): + with patch("portolan_cli.sync.upload.S3Store"): with patch.dict( os.environ, {"AWS_ACCESS_KEY_ID": "test", "AWS_SECRET_ACCESS_KEY": "test"}, diff --git a/tests/unit/test_upload_progress.py b/tests/unit/test_upload_progress.py index ee01d247..d14f1686 100644 --- a/tests/unit/test_upload_progress.py +++ b/tests/unit/test_upload_progress.py @@ -15,7 +15,7 @@ import pytest -from portolan_cli.upload_progress import UploadProgressReporter +from portolan_cli.sync.upload_progress import UploadProgressReporter class TestUploadProgressReporter: diff --git a/tests/unit/test_validation_rules.py b/tests/unit/test_validation_rules.py index c90767d3..f9af7f1e 100644 --- a/tests/unit/test_validation_rules.py +++ b/tests/unit/test_validation_rules.py @@ -828,7 +828,7 @@ def _write_collection(tmp_path: Path, *, with_link: bool) -> Path: } ) - from portolan_cli.pmtiles import WEB_MAP_LINKS_EXTENSION + from portolan_cli.viz.pmtiles import WEB_MAP_LINKS_EXTENSION collection_json = { "type": "Collection", @@ -910,8 +910,8 @@ def test_fails_when_second_pmtiles_asset_lacks_link(self, tmp_path: Path) -> Non """ import json - from portolan_cli.pmtiles import WEB_MAP_LINKS_EXTENSION from portolan_cli.validation.rules import PMTilesLinkRule + from portolan_cli.viz.pmtiles import WEB_MAP_LINKS_EXTENSION (tmp_path / ".portolan").mkdir() collection_dir = tmp_path / "test-collection" @@ -2205,7 +2205,7 @@ def test_backfills_missing_link(self, tmp_path: Path) -> None: import json from portolan_cli.metadata.fix import repair_pmtiles_links - from portolan_cli.pmtiles import WEB_MAP_LINKS_EXTENSION + from portolan_cli.viz.pmtiles import WEB_MAP_LINKS_EXTENSION coll = _make_collection( tmp_path, @@ -2257,7 +2257,7 @@ def test_dry_run_reports_without_writing(self, tmp_path: Path) -> None: @pytest.mark.unit def test_noop_when_link_present(self, tmp_path: Path) -> None: from portolan_cli.metadata.fix import repair_pmtiles_links - from portolan_cli.pmtiles import WEB_MAP_LINKS_EXTENSION + from portolan_cli.viz.pmtiles import WEB_MAP_LINKS_EXTENSION _make_collection( tmp_path, @@ -2296,7 +2296,7 @@ def test_declares_extension_without_touching_custom_layers(self, tmp_path: Path) import json from portolan_cli.metadata.fix import repair_pmtiles_links - from portolan_cli.pmtiles import WEB_MAP_LINKS_EXTENSION + from portolan_cli.viz.pmtiles import WEB_MAP_LINKS_EXTENSION coll = _make_collection( tmp_path, From 13e50362a27a18ffb4f251dc00cc1bb50e88b646 Mon Sep 17 00:00:00 2001 From: nlebovits Date: Fri, 10 Jul 2026 17:09:44 +0200 Subject: [PATCH 2/2] test: point moved-orchestrator mocks at their real binding sites (#625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the scan/sync/viz grouping. Two categories of test reference broke because the sync orchestrator now lives in portolan_cli.sync.core: - 32 sites patched "portolan_cli.sync.pull" — the orchestrator's bound `pull` (clone() calls it). After the move that path resolves to the `pull` *submodule* instead of the name bound in the orchestrator, so the patch no longer intercepted the call. Redirect to "portolan_cli.sync.core.pull". `pull` was the only submodule name the orchestrator also re-binds, so it's the only collision the mechanical rewrite couldn't disambiguate. - test_extension_registry used `from portolan_cli import scan_classify` (a module-object import, not a dotted path), which the dotted-path rewrite didn't catch. Point it at `from portolan_cli.scan import classify`. Verified: full unit suite (5046 passed) and all scan/check/pull/push/sync/clone suites green; the two remaining local integration timeouts are pre-existing slow tests (77s symlink add, loguru-queue duckdb glob) tripping an aggressive local --timeout, and pass in isolation — not refactor regressions. --- .../test_clone_ergonomics_integration.py | 10 ++--- .../integration/test_nested_catalog_clone.py | 6 +-- tests/unit/test_clone_ergonomics.py | 8 ++-- tests/unit/test_extension_registry.py | 2 +- tests/unit/test_sync.py | 40 +++++++++---------- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/tests/integration/test_clone_ergonomics_integration.py b/tests/integration/test_clone_ergonomics_integration.py index ed0190ae..11a7d75f 100644 --- a/tests/integration/test_clone_ergonomics_integration.py +++ b/tests/integration/test_clone_ergonomics_integration.py @@ -44,7 +44,7 @@ def test_clone_infers_directory_from_url( with ( patch("portolan_cli.sync.core.list_remote_collections") as mock_list, patch("portolan_cli.sync.core.init_catalog") as mock_init, - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_list.return_value = ["collection-a"] mock_init.return_value = None @@ -82,7 +82,7 @@ def test_clone_all_collections_when_none_specified( with ( patch("portolan_cli.sync.core.list_remote_collections") as mock_list, patch("portolan_cli.sync.core.init_catalog") as mock_init, - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_list.return_value = ["demographics", "imagery", "boundaries"] mock_init.return_value = None @@ -119,7 +119,7 @@ def test_clone_specific_collection_with_c_flag( with ( patch("portolan_cli.sync.core.list_remote_collections") as mock_list, patch("portolan_cli.sync.core.init_catalog") as mock_init, - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_init.return_value = None mock_pull.return_value = MagicMock( @@ -161,7 +161,7 @@ def test_clone_to_current_directory( try: with ( patch("portolan_cli.sync.core.init_catalog") as mock_init, - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_init.return_value = None mock_pull.return_value = MagicMock( @@ -209,7 +209,7 @@ def test_clone_partial_failure_reports_errors( with ( patch("portolan_cli.sync.core.list_remote_collections") as mock_list, patch("portolan_cli.sync.core.init_catalog") as mock_init, - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_list.return_value = ["good", "bad", "also-good"] mock_init.return_value = None diff --git a/tests/integration/test_nested_catalog_clone.py b/tests/integration/test_nested_catalog_clone.py index d31b8def..5e8acc6d 100644 --- a/tests/integration/test_nested_catalog_clone.py +++ b/tests/integration/test_nested_catalog_clone.py @@ -91,7 +91,7 @@ def mock_pull( "portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch_catalog ), patch("portolan_cli.sync.core.init_catalog"), - patch("portolan_cli.sync.pull", side_effect=mock_pull), + patch("portolan_cli.sync.core.pull", side_effect=mock_pull), ): result = clone( remote_url="s3://bucket/den-haag", @@ -163,7 +163,7 @@ def mock_pull( with ( patch("portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch), patch("portolan_cli.sync.core.init_catalog"), - patch("portolan_cli.sync.pull", side_effect=mock_pull), + patch("portolan_cli.sync.core.pull", side_effect=mock_pull), ): result = clone( remote_url="s3://bucket/catalog", @@ -218,7 +218,7 @@ def mock_pull( with ( patch("portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch), patch("portolan_cli.sync.core.init_catalog"), - patch("portolan_cli.sync.pull", side_effect=mock_pull), + patch("portolan_cli.sync.core.pull", side_effect=mock_pull), ): result = clone( remote_url="s3://bucket/catalog", diff --git a/tests/unit/test_clone_ergonomics.py b/tests/unit/test_clone_ergonomics.py index 19744346..f2d7d1fd 100644 --- a/tests/unit/test_clone_ergonomics.py +++ b/tests/unit/test_clone_ergonomics.py @@ -300,7 +300,7 @@ def test_clone_all_collections_when_none_specified(self, tmp_path: Path) -> None with ( patch("portolan_cli.sync.core.list_remote_collections") as mock_list, patch("portolan_cli.sync.core.init_catalog"), - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_list.return_value = ["collection-a", "collection-b"] mock_pull.return_value = MagicMock( @@ -332,7 +332,7 @@ def test_clone_single_collection_still_works(self, tmp_path: Path) -> None: with ( patch("portolan_cli.sync.core.list_remote_collections") as mock_list, patch("portolan_cli.sync.core.init_catalog"), - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_pull.return_value = MagicMock( success=True, @@ -385,7 +385,7 @@ def test_clone_partial_failure_reports_all_errors(self, tmp_path: Path) -> None: with ( patch("portolan_cli.sync.core.list_remote_collections") as mock_list, patch("portolan_cli.sync.core.init_catalog"), - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_list.return_value = ["good-collection", "bad-collection", "another-good"] @@ -419,7 +419,7 @@ def test_clone_to_dot_with_empty_directory(self, tmp_path: Path) -> None: with ( patch("portolan_cli.sync.core.init_catalog"), - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_pull.return_value = MagicMock( success=True, diff --git a/tests/unit/test_extension_registry.py b/tests/unit/test_extension_registry.py index fb9013d5..dde627b3 100644 --- a/tests/unit/test_extension_registry.py +++ b/tests/unit/test_extension_registry.py @@ -241,7 +241,7 @@ def test_constants_module(self) -> None: assert constants.SIDECAR_PATTERNS == {k: list(v) for k, v in reg.SIDECAR_OF.items()} def test_scan_classify_module(self) -> None: - from portolan_cli import scan_classify + from portolan_cli.scan import classify as scan_classify assert scan_classify.GEO_ASSET_EXTENSIONS == reg.extensions_where(scan_category="geo_asset") assert scan_classify.SIDECAR_EXTENSIONS == reg.extensions_where( diff --git a/tests/unit/test_sync.py b/tests/unit/test_sync.py index 8eeee6d1..e55afae0 100644 --- a/tests/unit/test_sync.py +++ b/tests/unit/test_sync.py @@ -225,7 +225,7 @@ def track_push(*args: Any, **kwargs: Any) -> MagicMock: return result with ( - patch("portolan_cli.sync.pull", side_effect=track_pull), + patch("portolan_cli.sync.core.pull", side_effect=track_pull), patch("portolan_cli.sync.core.init_catalog", side_effect=track_init), patch("portolan_cli.sync.core.scan_directory", side_effect=track_scan), patch("portolan_cli.sync.core.check_directory", side_effect=track_check), @@ -277,7 +277,7 @@ def failing_pull(*args: Any, **kwargs: Any) -> MagicMock: return result with ( - patch("portolan_cli.sync.pull", side_effect=failing_pull), + patch("portolan_cli.sync.core.pull", side_effect=failing_pull), patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.core.scan_directory") as mock_scan, patch("portolan_cli.sync.core.check_directory") as mock_check, @@ -315,7 +315,7 @@ def successful_push(*args: Any, **kwargs: Any) -> MagicMock: return result with ( - patch("portolan_cli.sync.pull", side_effect=up_to_date_pull), + patch("portolan_cli.sync.core.pull", side_effect=up_to_date_pull), patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.core.scan_directory") as mock_scan, patch("portolan_cli.sync.core.check_directory") as mock_check, @@ -368,7 +368,7 @@ def successful_push(*args: Any, **kwargs: Any) -> MagicMock: return result with ( - patch("portolan_cli.sync.pull", side_effect=successful_pull), + patch("portolan_cli.sync.core.pull", side_effect=successful_pull), patch("portolan_cli.sync.core.init_catalog") as mock_init, patch("portolan_cli.sync.core.scan_directory") as mock_scan, patch("portolan_cli.sync.core.check_directory") as mock_check, @@ -417,7 +417,7 @@ def mock_init_catalog(path: Path, **kwargs: Any) -> tuple[Path, list[str]]: return path / "catalog.json", [] with ( - patch("portolan_cli.sync.pull", side_effect=successful_pull), + patch("portolan_cli.sync.core.pull", side_effect=successful_pull), patch( "portolan_cli.sync.core.init_catalog", side_effect=mock_init_catalog ) as mock_init, @@ -463,7 +463,7 @@ def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: return result with ( - patch("portolan_cli.sync.pull", side_effect=successful_pull), + patch("portolan_cli.sync.core.pull", side_effect=successful_pull), patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.core.scan_directory") as mock_scan, patch("portolan_cli.sync.core.check_directory") as mock_check, @@ -491,7 +491,7 @@ def test_sync_dry_run_passes_to_pull(self, managed_catalog: Path) -> None: from portolan_cli.sync.core import sync with ( - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.core.scan_directory") as mock_scan, patch("portolan_cli.sync.core.check_directory") as mock_check, @@ -535,7 +535,7 @@ def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: return result with ( - patch("portolan_cli.sync.pull", side_effect=successful_pull), + patch("portolan_cli.sync.core.pull", side_effect=successful_pull), patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.core.scan_directory") as mock_scan, patch("portolan_cli.sync.core.check_directory") as mock_check, @@ -563,7 +563,7 @@ def test_sync_force_passes_to_pull(self, managed_catalog: Path) -> None: from portolan_cli.sync.core import sync with ( - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.core.scan_directory") as mock_scan, patch("portolan_cli.sync.core.check_directory") as mock_check, @@ -607,7 +607,7 @@ def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: return result with ( - patch("portolan_cli.sync.pull", side_effect=successful_pull), + patch("portolan_cli.sync.core.pull", side_effect=successful_pull), patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.core.scan_directory") as mock_scan, patch("portolan_cli.sync.core.check_directory") as mock_check, @@ -644,7 +644,7 @@ def test_sync_profile_passes_to_pull_and_push(self, managed_catalog: Path) -> No from portolan_cli.sync.core import sync with ( - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.core.scan_directory") as mock_scan, patch("portolan_cli.sync.core.check_directory") as mock_check, @@ -685,7 +685,7 @@ def test_sync_handles_pull_error_gracefully(self, managed_catalog: Path) -> None from portolan_cli.sync.core import sync from portolan_cli.sync.pull import PullError - with patch("portolan_cli.sync.pull") as mock_pull: + with patch("portolan_cli.sync.core.pull") as mock_pull: mock_pull.side_effect = PullError("Network timeout") result = sync( @@ -711,7 +711,7 @@ def successful_pull(*args: Any, **kwargs: Any) -> MagicMock: return result with ( - patch("portolan_cli.sync.pull", side_effect=successful_pull), + patch("portolan_cli.sync.core.pull", side_effect=successful_pull), patch("portolan_cli.sync.core.init_catalog"), patch("portolan_cli.sync.core.scan_directory") as mock_scan, patch("portolan_cli.sync.core.check_directory") as mock_check, @@ -817,7 +817,7 @@ def test_clone_creates_target_directory(self, tmp_path: Path) -> None: with ( patch("portolan_cli.sync.core.init_catalog") as mock_init, - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): # Mock successful operations mock_init.return_value = None @@ -845,7 +845,7 @@ def test_clone_calls_init_catalog(self, tmp_path: Path) -> None: with ( patch("portolan_cli.sync.core.init_catalog") as mock_init, - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_pull.return_value = MagicMock( success=True, @@ -872,7 +872,7 @@ def test_clone_calls_pull_with_correct_args(self, tmp_path: Path) -> None: with ( patch("portolan_cli.sync.core.init_catalog"), - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_pull.return_value = MagicMock( success=True, @@ -905,7 +905,7 @@ def test_clone_fails_when_pull_fails(self, tmp_path: Path) -> None: with ( patch("portolan_cli.sync.core.init_catalog"), - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_pull.return_value = MagicMock( success=False, @@ -930,7 +930,7 @@ def test_clone_fails_when_remote_not_found(self, tmp_path: Path) -> None: with ( patch("portolan_cli.sync.core.init_catalog"), - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_pull.return_value = MagicMock( success=False, @@ -956,7 +956,7 @@ def test_clone_returns_pull_result(self, tmp_path: Path) -> None: with ( patch("portolan_cli.sync.core.init_catalog"), - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_pull_result = MagicMock( success=True, @@ -1476,7 +1476,7 @@ def mock_fetch(remote_url: str, **kwargs: Any) -> dict[str, Any]: with ( patch("portolan_cli.sync.core._fetch_remote_catalog_json", side_effect=mock_fetch), patch("portolan_cli.sync.core.init_catalog"), - patch("portolan_cli.sync.pull") as mock_pull, + patch("portolan_cli.sync.core.pull") as mock_pull, ): mock_pull.return_value = MagicMock( success=True,