diff --git a/README.rst b/README.rst index 04b526b..a2465c1 100644 --- a/README.rst +++ b/README.rst @@ -85,12 +85,17 @@ Create a repository, stage a remote catalog, then commit it:: hm download --all --dry-run hm download --all -``hm`` is the CLI command; ``hallmark`` remains an alias. Initialization creates the empty -local repository. Remote ``add`` discovers files without downloading dataset -contents or committing the catalog. CyVerse, ordinary browsable HTTPS -directories, SSH and SFTP use the same workflow. Omit the filter to catalog -all discoverable files beneath the supplied URL. - +``hm`` is the CLI command; ``hallmark`` remains an alias. Commands work from any +folder inside a repository; ``hm`` finds ``.hm`` in parent folders. If ``.hm`` +is damaged or missing files, commands explain the problem and change nothing. +Initialization creates the empty local repository. Remote ``add`` discovers +files without downloading dataset contents or committing the catalog. CyVerse, +ordinary browsable HTTPS directories, SSH and SFTP use the same workflow. Omit +the filter to catalog all discoverable files beneath the supplied URL. + +``hm download PATH...`` selects catalogued files or folders instead of +``--all``; ``--filter`` and ``--fmt`` only narrow a selection, and paths that +are not in the catalog stop the download before the server is contacted. Every nonempty CLI transfer asks for confirmation. For separate Python downloads, callers can inspect ``repo.plan_download()`` and then execute ``repo.download(plan, approved=True)``. @@ -99,10 +104,12 @@ Optional authentication profiles remain local and can be selected with The obsolete ``build`` and remote initialization interfaces have been removed. Use ``init``, ``add`` and ``commit`` for new remote catalogs. -Use ``hm clone CATALOG PATH`` for an existing Git-hosted ``.hm`` or a -published HTTP/SFTP catalog snapshot. Catalogs can live on GitHub or another +Use ``hm clone CATALOG [PATH]`` for an existing Git-hosted ``.hm`` or a +published HTTP/SFTP catalog snapshot. ``PATH`` defaults to a new folder named +after the source and must otherwise be new or empty, outside any Hallmark +repository. Catalogs can live on GitHub or another server while their data remotes point elsewhere. Git clones preserve the full -catalog and its history. Clone displays a data download plan and asks for +catalog, its history and every branch, after checking each branch's catalog. Clone displays a data download plan and asks for confirmation by default; declining keeps the catalog. Use ``hm clone CATALOG PATH --no-download`` for catalog only. Filters narrow the download and cannot accompany ``--no-download``. Python ``Repo.clone`` downloads without prompting @@ -123,7 +130,9 @@ Remote ``add`` supports one dataset root and one ``data.tsv`` table per repository. Keep remote catalogs separate from locally versioned files. Local ``add`` selects files by filename format; ``commit`` stores their contents in ``.hm/objects``. Clone copies catalog history and downloads from -its configured data remote; transfer of a local object store is not implemented. +its configured data remote. Repositories with a branch that tracks local files +cannot be cloned yet: clone reports this and creates nothing, even with +``--no-download``. Empty repositories and remote catalogs can be cloned. Development and support ----------------------- diff --git a/demo/demo_cli.ipynb b/demo/demo_cli.ipynb index 4c2107b..2d866e6 100644 --- a/demo/demo_cli.ipynb +++ b/demo/demo_cli.ipynb @@ -438,7 +438,7 @@ "source": [ "### 2.4 Download some files\n", "\n", - "`--filter` picks files by name. `*` matches any characters.\n", + "`--all` selects every file in the catalog, and `--filter` keeps only the names that match. `*` matches any characters.\n", "Hallmark shows the plan and asks `[y/N]`. Here we answer `y` with `echo y`.\n", "Each file is checked against its checksum." ] @@ -450,7 +450,7 @@ "metadata": {}, "outputs": [], "source": [ - "echo y | hm download --filter \"SgrA_*\" 2>/dev/null\n", + "echo y | hm download --all --filter \"SgrA_*\" 2>/dev/null\n", "ls SgrA_*.txt" ] }, @@ -541,7 +541,7 @@ "source": [ "echo n | hm clone eht-data/.hm later-copy\n", "cd later-copy\n", - "echo y | hm download --filter \"M87_*\" 2>/dev/null\n", + "echo y | hm download --all --filter \"M87_*\" 2>/dev/null\n", "cd .." ] }, @@ -588,7 +588,8 @@ "| `hm checkout NAME` | Switch to a branch, or create it |\n", "| `hm set-config --fmt \"PATTERN\"` | Change the file name pattern |\n", "| `hm set-config --encoding \"FIELD=REGEX\"` | Add an encoding rule |\n", - "| `hm download --all` / `--filter` / `PATHS` | Download files in the catalog (asks first) |\n", + "| `hm download --all` / `PATHS` | Download all files, or files and folders in the catalog (asks first) |\n", + "| `hm download ... --filter \"GLOB\"` | Download only the selected files that match (asks first) |\n", "| `hm download --dry-run` | Show what would be downloaded |\n", "| `hm clone SOURCE DIR` | Copy a repository and download its data (asks first) |\n", "| `hm clone SOURCE DIR --no-download` | Copy only the catalog and history |" diff --git a/doc/api.rst b/doc/api.rst index 689295e..e5ee7bc 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -98,9 +98,11 @@ Stage and commit a remote catalog without downloading dataset files:: hm add 'ssh://lab-data/srv/data/run{run:d}.h5' hm commit -m 'Add remote data' -Use ``hm clone SOURCE PATH`` for an existing Hallmark Git repository -or published HTTP/SFTP snapshot. Git clones retain the full catalog and its -history; snapshots start new local history. Use ``--source-type git`` or +Use ``hm clone SOURCE [PATH]`` for an existing Hallmark Git repository +or published HTTP/SFTP snapshot. ``PATH`` defaults to a new folder named after +the source; it must be new or empty and outside any Hallmark repository, +including the source. Git clones retain the full catalog, its history and +every branch; snapshots start new local history. Use ``--source-type git`` or ``--source-type catalog`` to override automatic detection. Downloading remote data @@ -109,11 +111,14 @@ Downloading remote data Preview the selected files using the local catalog, then confirm a download:: hm download --all --dry-run - hm download --filter 'runs/**' - -Explicit paths and ``--tsv data.tsv`` also select files. Every nonempty transfer -in the CLI requires interactive confirmation, including the default clone -transfer. ``clone --no-download`` skips data and the prompt. Clone filters and + hm download runs + +Choose catalogued paths or folders, relative to the current folder, or +``--all`` or ``--tsv data.tsv``; ``--filter`` and ``--fmt`` only narrow that +selection, as in ``hm download --all --filter 'runs/**'``. Paths that are not +in the catalog stop the download before the server is contacted. Every +nonempty transfer in the CLI requires interactive confirmation, including the +default clone transfer. ``clone --no-download`` skips data and the prompt. Clone filters and formats cannot be combined with this flag and leave the complete catalog unchanged. Declining clone's prompt keeps the catalog and exits successfully. A filter or filename format never authorizes a transfer. diff --git a/doc/backends.rst b/doc/backends.rst index c321cd9..c097f50 100644 --- a/doc/backends.rst +++ b/doc/backends.rst @@ -114,8 +114,8 @@ as missing objects. Discovery reads listings and published metadata only. It must not download payloads to infer checksums. The shared downloader owns approval, destination -path validation, checksum verification, atomic replacement and worker -concurrency. Backends map logical paths to server paths or API requests and +path validation, checksum verification, skipping or refusing existing files, +moving finished files into place and worker concurrency. Backends map logical paths to server paths or API requests and write only to the temporary destination they are given. An instance can serve concurrent ``fetch`` calls. Protect shared mutable diff --git a/doc/private_data.rst b/doc/private_data.rst index ecfdf6d..83ddab4 100644 --- a/doc/private_data.rst +++ b/doc/private_data.rst @@ -66,8 +66,12 @@ Suppose the export contains ``runs/run_001.h5``, ``runs/run_002.h5`` and This creates ``./lab/.hm`` and catalogs only the matching run files. The URL is the exact discovery root. A filename format selects paths and extracts -parameters; it does not approve a download. A glob filter can select paths -without defining parameters: +parameters; it does not approve a download. Each branch has one filename +format: adding the bare URL again rescans with the saved format, and a new +format is refused, before the server is contacted, if any catalogued file does +not fit it. There is no option to force it; a broader format that fits every +catalogued file replaces the old one. A glob filter can select paths without +defining parameters: .. code-block:: bash @@ -83,6 +87,11 @@ directories needed to find matching files. Discovery shows an indeterminate progress bar with counts of completed directories and discovered files. The percentage and completion time remain unknown until a total is available. +A scan stages its catalog only after it completes. If the connection or access +fails, or no file matches the URL, filter and filename format, ``add`` explains +the problem, exits with an error and keeps the previously staged catalog +unchanged; it never stages a partial result. + Both URL schemes use structured SFTP directory enumeration and file transfers. SFTP-only accounts work for discovery as well as downloading. No ``--allow-remote-commands`` or ``--remote-hash`` option is needed. Discovery @@ -104,6 +113,17 @@ nonsecret backend settings. These settings are saved with the data remote; credentials belong in local authentication configuration. See :doc:`backends` for the plugin interface. +Keep passwords and keys outside ``.hm``. ``add URL`` and +``set-config --remote-url`` reject URLs containing a password, an HTTP(S) +username or token, a query string or a fragment, before contacting the server +and without repeating the URL. Store SSH users and keys in ``~/.ssh/config`` or +a local profile in ``~/.config/hallmark/auth.yml`` (see section 4), and +HTTP(S) logins in ``~/.netrc``. An SSH username such as +``ssh://researcher@lab-data/srv/exports/lab/`` is not a secret and is allowed. +``clone`` likewise refuses a source URL with a password or an HTTP(S) username +or token, before creating anything, because Git would save it in +``.hm/.git/config``; use a Git credential helper or ``~/.netrc`` instead. + 3. Preview, approve and download -------------------------------- @@ -117,7 +137,8 @@ From the directory containing ``lab``, enter that repository: hm download runs/run_001.h5 The dry run reads only the local catalog. It reports file count, known bytes, -the number of unknown file sizes, destination and source. The CLI reports +the number of unknown file sizes, destination and source, then lists the +selected files and any paths that are not in the catalog. The CLI reports duration as unknown. Python callers can supply a transfer rate to estimate duration when all file sizes are known. The dry run does not test credentials, host trust or remote-file existence. @@ -133,21 +154,42 @@ Choose a scope from inside ``lab``: .. code-block:: bash - hm download --filter 'runs/run_00[12].h5' --dry-run + hm download runs --dry-run + hm download --all --filter 'runs/run_00[12].h5' --dry-run hm download --all --output ./payload -Explicit paths use their recorded catalog checksums when available, just like -``--tsv`` and ``--all``. A path absent from the catalog can be requested, but -its size and checksum are unknown. Files without a usable publisher checksum -can be downloaded, but their contents cannot be checked against the catalog. +Choose files with catalogued paths or folders, ``--tsv`` or ``--all``. Paths +are relative to the current folder, which can be any folder inside the +repository. A folder selects every catalogued file below it, including +subfolders; ``.`` selects everything below the current folder. ``--filter`` +and ``--fmt`` only narrow that selection: on their own they show usage and +download nothing. A path that matches no catalogued file, such as a typo or a +folder without catalogued files, stops the download before the server is +contacted; ``--dry-run`` lists such paths. Selected files use their recorded +catalog checksums. Files without a usable publisher checksum can be +downloaded, but their contents cannot be checked against the catalog. ``--tsv`` can be repeated and combined with explicit paths; overlapping entries are deduplicated. ``--all`` cannot be combined with either selection mode. Relative output paths are relative to the current directory. Without ``--output``, downloads go to the repository worktree; bare ``.hm`` repositories -require an explicit output directory. Repeating a download transfers the -selection again and replaces destinations only after successful transfer and -any recorded checksum verification. +require an explicit output directory. + +Existing files are never replaced. Before contacting the server, a download +checks each selected file that already exists at its destination, first by +its recorded size and then by its catalog checksum. A file with its catalog +checksum is reported as already downloaded and skipped, so repeating a +download, for example after a failed transfer, fetches only what is missing. +When the catalog has no checksum for a file, as for SSH exports without a +checksum manifest, a file with the recorded size is reported as present (size +matches, not verified) and also skipped. A file with a different size or +contents, a file the catalog records neither a checksum nor a size for, or a +folder in its place is a conflict: the download stops and transfers nothing. +Delete conflicting files first to replace them; there is no overwrite option. +``--dry-run`` lists files to download, skipped and unverified files, conflicts +and unknown paths. Checksums from manifests that do not name their algorithm, +such as ``checksums.txt``, are used when the digest length identifies MD5, +SHA-1, SHA-256 or SHA-512. After committing the catalog, use ``download`` to review and approve a transfer. Declining keeps the catalog available; an empty selection needs no approval: @@ -168,8 +210,26 @@ HTTP/SFTP directory containing its metadata. For example, copy this catalog: hm clone ./.hm ../lab-copy -Git endpoints preserve the complete catalog and history. Published HTTP/SFTP -snapshots start new local history. A snapshot URL may name the metadata +Without a destination, ``hm clone`` creates a folder in the current folder +named after the source: ``lab`` for ``./lab/.hm`` or +``https://github.com/example/lab.git``. A destination must be a new or empty +folder outside every Hallmark repository, including the source, because +repositories cannot be nested; these checks happen before the source is read. +A failed clone removes only what it created: the destination folder if it was +new, or the ``.hm`` it added to an existing empty folder. It never follows a +symbolic link while cleaning up. +Only empty repositories and remote catalogs can be cloned: if any branch of the +source tracks local files, whose contents live in its ``.hm/objects``, clone +reports it and creates nothing, even with ``--no-download``. A local source is +checked before anything is copied; a Git host or snapshot is checked after its +metadata is read, and the partial copy is removed. + +Git endpoints preserve the complete catalog and history, with a local branch +for every branch of the source; the copy starts on the branch the source has +selected. Before finishing, clone checks every branch's committed catalog: +readable ``config.yml``, ``meta.yml`` and tables, safe relative paths and +well-formed checksums. An invalid catalog stops the clone and removes the +copy. Published HTTP/SFTP snapshots start new local history. A snapshot URL may name the metadata directory itself or its parent containing ``.hm``. Use ``--source-type git`` to require Git or ``--source-type catalog`` to require a snapshot. Automatic selection treats local paths, SCP-style addresses, Git/file/SSH URLs and URLs @@ -190,6 +250,10 @@ confirmation; Enter or ``n`` keeps the catalog and exits successfully: Use ``--no-download`` for catalog only, without a prompt. It cannot be combined with ``--filter`` or ``--fmt`` and is required for bare destinations. An empty selection prints ``No files selected for download.`` without prompting. +If the download cannot run or a file fails, clone reports the copy as +incomplete. It keeps the catalog and the files that downloaded, and names the +``hm download --all`` command, with the same filters, that retries; files +already downloaded are skipped. Git authentication and data authentication are independent; catalog-only cloning does not require credentials for its data. @@ -283,9 +347,11 @@ Without ``approved=True``, a nonempty transfer raises ``DownloadError`` before opening a connection. A plan freezes the selected files, source URL, profile reference, backend, backend options and destination. Later catalog or remote configuration changes do not redirect it. ``plan_download(filter="**/*.h5")`` -selects matching files from the catalog; -``plan_download(output_path="subset", tsv_names=["data.tsv"])`` selects a TSV -and destination. Planning makes no network requests. Known size totals and +selects matching files from the catalog; ``plan_download(file_paths=["runs"])`` +selects a folder; ``plan_download(output_path="subset", tsv_names=["data.tsv"])`` +selects a TSV and destination. Planning makes no network requests. Requested +paths that match no catalogued file are listed in ``plan.unknown_paths``; +downloading such a plan raises ``DownloadError`` before opening a connection. Known size totals and unknown-size counts are available as ``known_bytes`` and ``unknown_size_count``. ``estimated_seconds`` stays ``None`` unless all sizes are known and ``estimated_bytes_per_second`` was supplied when planning. @@ -346,7 +412,7 @@ access retains Requests' normal ``.netrc`` and environment behavior; Hallmark's named auth profiles apply to SSH/SFTP. From either initialized catalog, inspect and approve a selected subset using -``hm download --filter PATTERN --dry-run`` and then the same command +``hm download --all --filter PATTERN --dry-run`` and then the same command without ``--dry-run``. Python follows the same steps: .. code-block:: python @@ -425,10 +491,12 @@ catalog hosting independent of data hosting. Download behavior and troubleshooting ------------------------------------- -Files are downloaded to temporary paths and published atomically after transfer -and any recorded checksum verification. A failed transfer preserves an existing -destination and removes its temporary file. Successful files remain available -when another file fails; a multi-file download is not a single transaction. +Unfinished downloads are kept in temporary ``.part`` files beside their +destinations and moved into place only after the transfer and any recorded +checksum verification succeed. A failed transfer removes its temporary file. +A destination that appears while the download runs is not replaced; that file +fails instead. Successful files remain available when another file fails; a +multi-file download is not a single transaction. The CLI exits nonzero on transfer failures. Cancellation stops the active operation and closes the SSH connections and processes it started. @@ -451,6 +519,9 @@ operation and closes the SSH connections and processes it started. * - Checksum mismatch - Check whether the export changed since catalog creation. Reconcile the catalog and source against a trusted version before retrying. + * - Existing files conflict with the catalog + - Compare the listed files with the catalog. Delete or move them, then + download again; Hallmark never overwrites existing files. * - Transfer timeout or session limit - Adjust the local per-file time budget or lower concurrency to match the server's capacity. diff --git a/doc/usecase.rst b/doc/usecase.rst index c6fd20e..466b62b 100644 --- a/doc/usecase.rst +++ b/doc/usecase.rst @@ -155,13 +155,16 @@ The ``--remote`` option selects one data server; |hallmark|_ does not automatically try another server if the download fails. Downloads are written to temporary files and checked against any -checksums supplied with the selection before atomically replacing -their destinations. +checksums supplied with the selection before they are moved into place. The ``--tsv`` and ``--all`` options include catalog checksums. Explicit -paths also use their recorded checksums when available. -If a transfer or checksum check fails, the existing destination is -preserved and the temporary file is removed. +paths and folders must be in the catalog and use their recorded checksums. +If a transfer or checksum check fails, the temporary file is removed. Files that have already downloaded successfully remain available. +Existing files are never replaced: files that already match their +catalog checksum, or their recorded size when the catalog has no checksum, +are skipped, and a file with different or uncheckable contents stops the +download before anything is transferred. Delete conflicting files first to +replace them. SSH access and file paths ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -294,7 +297,8 @@ an apparently complete catalog. The generated data remote ``origin`` records the source URL and optional profile name. Existing Git-hosted catalogs keep their complete catalog, history and -recorded data remotes when cloned. Clone offers data downloads by default and +recorded data remotes when cloned. Repositories that track local files cannot +be cloned yet. Clone offers data downloads by default and asks for confirmation. ``--no-download`` copies only the catalog. Clone filters narrow the download and cannot accompany ``--no-download``; the catalog and history remain unchanged. diff --git a/mod/hallmark/__init__.py b/mod/hallmark/__init__.py index 9ddf4d9..b20c557 100644 --- a/mod/hallmark/__init__.py +++ b/mod/hallmark/__init__.py @@ -17,13 +17,14 @@ from .repo import Repo from .paraframe import ParaFrame -from .remote.plan import DownloadItem, DownloadPlan +from .remote.plan import DownloadConflict, DownloadItem, DownloadPlan from .remote.backends import ( DataBackend, HttpBackend, SshBackend, CyVerseBackend, RemoteEntry, register_backend, ) __all__ = [ - "Repo", "ParaFrame", "DownloadItem", "DownloadPlan", "DataBackend", - "HttpBackend", "SshBackend", "CyVerseBackend", "RemoteEntry", "register_backend", + "Repo", "ParaFrame", "DownloadConflict", "DownloadItem", "DownloadPlan", + "DataBackend", "HttpBackend", "SshBackend", "CyVerseBackend", "RemoteEntry", + "register_backend", ] diff --git a/mod/hallmark/cli.py b/mod/hallmark/cli.py index ca1996b..574dddb 100644 --- a/mod/hallmark/cli.py +++ b/mod/hallmark/cli.py @@ -14,6 +14,7 @@ """Hallmark CLI entrypoint and command wiring.""" +import os from contextlib import contextmanager from pathlib import Path @@ -24,8 +25,10 @@ from . import Repo from .remote.download import DownloadError +from .remote.clone import default_clone_destination from .remote.discovery import path_matches -from .error import CheckoutError, CloneError +from .error import CheckoutError, CloneError, DothmError +from .utils import use_working_directory # use a context manager to translate application errors into clean Click errors @@ -117,7 +120,9 @@ def _run_download(repo, plan, *, max_workers): """ click.echo(plan.summary()) if not plan.file_count: - click.echo("No files selected for download.") + click.echo("All selected files are already downloaded." + if plan.skipped or plan.unverified + else "No files selected for download.") return click.confirm("Download these files?", default=False, abort=True) results = repo.download(plan, approved=True, max_workers=max_workers, @@ -138,9 +143,58 @@ def hallmark(ctx): if ctx.invoked_subcommand in [None, "init", "clone"]: # return early without attempting to open a repository return - # attempt to open the hallmark repository in the current directory + # open the repository containing the current folder, searching its parents with _translate_cli_errors(GitError, prefix="Failed to open hallmark repository"): - ctx.obj = Repo(".") + with _translate_cli_errors(DothmError): + ctx.obj = Repo.find(Path.cwd()) + + +def _repo_relative_paths(repo, paths): + """ + Convert paths given relative to the current folder into repository paths. + + Bare repositories have no worktree, so their paths are already relative to + the catalog root and are returned unchanged. + + Args: + repo: The hallmark repository object. + paths (sequence[str]): Paths as typed by the user. + + Returns: + tuple[str, ...]: POSIX paths relative to the worktree; "." names the + whole worktree. + + Raises: + ClickException: If a path is outside the worktree or inside ``.hm``. + """ + if repo.worktree is None: + return tuple(paths) + worktree = Path(repo.worktree) + converted = [] + for value in paths: + # Normalize lexically: catalog paths are literal and need not exist yet. + absolute = Path(os.path.normpath(Path.cwd() / value)) + try: + relative = absolute.relative_to(worktree) + except ValueError: + raise ClickException( + f'"{value}" is outside the repository worktree "{worktree}"') from None + if relative.parts and relative.parts[0].lower() == ".hm": + raise ClickException(f'"{value}" is inside .hm, not a data path') + converted.append(relative.as_posix()) + return tuple(converted) + + +def _names_worktree_root(repo, value): + """Return whether an add input is "." or another path to the worktree root.""" + if repo.worktree is None: + return False + if value == ".": + return True + try: + return (Path.cwd() / value).resolve() == Path(repo.worktree).resolve() + except (OSError, RuntimeError, ValueError): + return False def _load_backend_options(path): @@ -254,11 +308,14 @@ def add(repo, encoding, inputs, auth, backend, backend_options, filters, remote_ """Add files to the hallmark index. A remote URL or URL pattern stages a catalog without downloading files - or committing. Use --fmt and --filter to select remote paths. + or committing. Use --fmt and --filter to select remote paths. A scan that + fails or matches no files keeps the previously staged catalog. `hm add [--regex] FORMAT` uses the branch format string workflow. `hm add "."` rebuilds the manifest from current files that match - the branch `fmt` in `config.yml`. + the branch `fmt` in `config.yml`, across the whole repository from any + folder inside it. Any other path to the worktree root, such as `..` from + a subfolder, does the same. Explicit path inputs such as shell-expanded `*` are not supported yet with the parameter-based manifest format. """ @@ -276,6 +333,11 @@ def add(repo, encoding, inputs, auth, backend, backend_options, filters, remote_ remote = "://" in inputs[0] if remote or any(value is not None for value in remote_options.values()): pf = repo.add(inputs[0], encoding, progress=True, **remote_options) + elif _names_worktree_root(repo, inputs[0]): + # Like ".", a path naming the worktree root stages the whole + # repository from any folder inside it. + with use_working_directory(repo.worktree): + pf = repo.add(".", encoding) else: pf = repo.add(inputs[0], encoding) # oterhwise, use the add_paths method for multiple inputs @@ -399,6 +461,35 @@ def checkout(repo, target_branch): click.echo(f'Switched to branch "{target_branch}".') +def _echo_paths(title, paths, limit=20): + """Print a titled list of paths, abbreviating long lists.""" + if not paths: + return + click.echo(title) + for path in paths[:limit]: + click.echo(f" {path}") + if len(paths) > limit: + click.echo(f" ... {len(paths) - limit} more") + + +def _report_plan_problems(plan): + """ + List unknown paths and conflicting files that prevent a download. + + Returns: + list[str]: Short descriptions of the problems; empty if there are none. + """ + _echo_paths("Existing files that conflict with the catalog:", [ + f"{conflict.item.relative_path.as_posix()} ({conflict.reason})" + for conflict in plan.conflicts]) + if plan.conflicts: + click.echo("Delete conflicting files first to replace them.") + _echo_paths("Not in the catalog:", plan.unknown_paths) + return [f"{count} {text}" for count, text in ( + (len(plan.conflicts), "existing file(s) conflict with the catalog"), + (len(plan.unknown_paths), "path(s) not in the catalog")) if count] + + @hallmark.command(short_help="Download files from the configured data remote.") @click.argument("files", nargs=-1) @click.option("--tsv", "tsv_names", multiple=True, @@ -406,9 +497,9 @@ def checkout(repo, target_branch): @click.option("--all", "download_all", is_flag=True, help="Select all cataloged files.") @click.option("--filter", "filters", multiple=True, - help="Select paths matching a glob. ** matches recursively. " - "May be repeated.") -@click.option("--fmt", help="Select paths matching a filename format.") + help="Keep only selected paths matching a glob. ** matches " + "recursively. May be repeated.") +@click.option("--fmt", help="Keep only selected paths matching a filename format.") @click.option("--remote", "remote_name", help="Name of the configured data remote to use.") @click.option("--output", type=click.Path(file_okay=False), @@ -423,32 +514,50 @@ def download(repo, files, tsv_names, download_all, filters, fmt, remote_name, """ Download selected files from a configured data remote. + Choose catalogued FILES or folders, or --all; a folder selects every + catalogued file below it. FILES are relative to the current folder, which + can be any folder inside the repository. --filter and --fmt only narrow + that selection. Files already downloaded with their catalog checksum, or + with the catalog size when it records no checksum, are skipped. Paths + that are not in the catalog, and existing files that differ from it or + cannot be checked, stop the download before the server is contacted; + delete conflicting files first to replace them. + Preview the selection with --dry-run. Every nonempty download displays its plan and asks for confirmation before transferring files. """ if download_all and (files or tsv_names): - raise ClickException("--all cannot be combined with file paths or --tsv") - if not files and not tsv_names and not download_all and not filters and not fmt: - raise ClickException("Provide file paths, --tsv, --all, --filter, or --fmt") + raise click.UsageError("Give paths or --all, not both") + if not files and not tsv_names and not download_all: + raise click.UsageError( + "Choose files with paths or --all; --filter and --fmt only narrow " + "that selection") if repo.worktree is None and output is None: raise ClickException("--output is required when downloading from a bare repo") + files = _repo_relative_paths(repo, files) with _translate_cli_errors(DownloadError, ValueError): plan = repo.plan_download( output, file_paths=files, tsv_names=tsv_names, all_files=download_all, filter=filters or None, fmt=fmt, remote_name=remote_name) if dry_run: click.echo(plan.summary()) - for item in plan.items[:20]: - click.echo(f" {item.relative_path.as_posix()}") - if plan.file_count > 20: - click.echo(f" ... {plan.file_count - 20} more file(s)") + _echo_paths("Files to download:", [ + item.relative_path.as_posix() for item in plan.items]) + _echo_paths("Already downloaded (skipped):", [ + item.relative_path.as_posix() for item in plan.skipped]) + _echo_paths("Present (size matches, not verified):", [ + item.relative_path.as_posix() for item in plan.unverified]) + _report_plan_problems(plan) return + problems = _report_plan_problems(plan) + if problems: + raise ClickException("; ".join(problems) + "; nothing was downloaded") _run_download(repo, plan, max_workers=max_workers) @hallmark.command(short_help="Clone an existing Hallmark catalog.") @click.argument("url") -@click.argument("path") +@click.argument("path", required=False) @click.option("--auth", help="Optional local SSH authentication profile.") @click.option("--filter", "filters", multiple=True, help="Select paths matching a glob. ** matches recursively. " @@ -467,6 +576,8 @@ def clone(url, path, auth, filters, fmt, source_type, no_download, max_workers): """ Clone an existing Git catalog or published catalog snapshot at PATH. + PATH defaults to a new folder named after the source. It must be new or + an empty folder, outside any Hallmark repository and the source. By default, copy the catalog then ask before downloading dataset files. Declining keeps the catalog and exits successfully. --no-download skips data and the prompt; bare destinations require it. --filter and --fmt narrow the @@ -475,6 +586,11 @@ def clone(url, path, auth, filters, fmt, source_type, no_download, max_workers): """ if (filters or fmt is not None) and no_download: raise ClickException("--filter and --fmt cannot be used with --no-download") + if path is None: + try: + path = str(default_clone_destination(url)) + except CloneError as exc: + raise click.UsageError(str(exc)) from exc if not no_download and Repo.resolve_repo_paths(path)[1] is None: raise ClickException("Bare clones require --no-download") diff --git a/mod/hallmark/remote/add.py b/mod/hallmark/remote/add.py index 4df2a5a..19417a3 100644 --- a/mod/hallmark/remote/add.py +++ b/mod/hallmark/remote/add.py @@ -6,9 +6,13 @@ from .discovery import discover_remote_files, path_matches from ..utils import as_list_of_dicts -from ..repo.config import normalize_remotes, validate_tsv_filename +from ..repo.config import ( + check_pattern_keeps_catalogue, filename_fields, normalize_remotes, + single_data_format, validate_tsv_filename) from ..transport import OperationContext, RemoteSpec -from ..transport.base import copy_backend_options +from ..transport.base import ( + DownloadError, RemoteConfigurationError, copy_backend_options, + reject_url_secrets) def is_remote_catalog(state): @@ -19,6 +23,7 @@ def split_remote_pattern(value, fmt=None): parts = urlsplit(value) if parts.scheme not in {"https", "http", "ssh", "sftp"}: raise ValueError("Remote add requires an HTTP(S), SSH or SFTP URL") + reject_url_secrets(value) path = parts.path if "{" in path: if fmt is not None: @@ -31,6 +36,37 @@ def split_remote_pattern(value, fmt=None): return urlunsplit(parts._replace(path=path)), fmt +CATALOG_COLUMNS = ("path", "checksum_algorithm", "checksum", "size_bytes", "mtime") + + +def _pattern_fields(fmt, path): + """Return the filename fields that a matching pattern extracts from a path.""" + matched = parse.parse(fmt, path, case_sensitive=True) + return {key: value for key, value in matched.named.items() + if key not in {*CATALOG_COLUMNS, "sha1"}} + + +def _replace_pattern_fields(frame, fmt, old_fmt): + """Swap the old pattern's field columns for those of a new pattern.""" + fields = pd.DataFrame([_pattern_fields(fmt, path) for path in frame["path"]], + index=frame.index) + old_fields = set(filename_fields(old_fmt)) if old_fmt else set() + replaced = ((old_fields | set(fields.columns)) + & (set(frame.columns) - set(CATALOG_COLUMNS))) + return frame.drop(columns=sorted(replaced)).join(fields) + + +def _describe_selection(fmt, filter): + """Describe a remote selection for messages about the files it matched.""" + parts = [] + if fmt: + parts.append(f"pattern {fmt!r}") + if filter: + patterns = [filter] if isinstance(filter, str) else list(filter) + parts.append("filter " + ", ".join(repr(pattern) for pattern in patterns)) + return " and ".join(parts) or "any file" + + def add_remote(repo, value, *, fmt=None, filter=None, auth=None, backend=None, backend_options=None, progress=False): url, fmt = split_remote_pattern(value, fmt) @@ -61,25 +97,39 @@ def add_remote(repo, value, *, fmt=None, filter=None, auth=None, backend=None, or remotes[0].get("url") != source.url): raise ValueError( "Remote add currently supports one dataset root per repository") - with OperationContext(source) as context: - entries = discover_remote_files( - context, filter=filter, fmt=fmt, progress=progress - ) - columns = ["path", "checksum_algorithm", "checksum", "size_bytes", "mtime"] + # A branch has one pattern: a bare URL rescans with the saved pattern, and + # a new pattern must fit every catalogued file before the server is read. + saved_fmt = single_data_format(repo.state.config) + if fmt is None: + fmt = saved_fmt + check_pattern_keeps_catalogue(repo.state, fmt) + try: + with OperationContext(source) as context: + entries = discover_remote_files( + context, filter=filter, fmt=fmt, progress=progress + ) + except RemoteConfigurationError: + raise + except DownloadError as exc: + # Nothing is saved until the scan completes, so the catalogue is intact. + raise DownloadError( + f"Remote scan failed (previous catalogue kept): {exc}") from exc + if not entries: + raise ValueError( + f"No remote files matched {_describe_selection(fmt, filter)} under " + f"{url}; previous catalogue kept") rows = [] for entry in entries: - row = dict(zip(columns, (entry.path, entry.checksum_algorithm, - entry.checksum, entry.size, entry.mtime))) + row = dict(zip(CATALOG_COLUMNS, (entry.path, entry.checksum_algorithm, + entry.checksum, entry.size, entry.mtime))) if fmt: - matched = parse.parse(fmt, entry.path, case_sensitive=True) - row.update({key: value for key, value in matched.named.items() - if key not in {*columns, "sha1"}}) + row.update(_pattern_fields(fmt, entry.path)) rows.append(row) - frame = pd.DataFrame(rows) if rows else pd.DataFrame(columns=columns) - if frame.empty: - return frame + frame = pd.DataFrame(rows) state = deepcopy(repo.state) if not existing.empty: + if fmt != saved_fmt: + existing = _replace_pattern_fields(existing, fmt, saved_fmt) frame = pd.concat([existing, frame], ignore_index=True, sort=False) frame = frame.drop_duplicates(subset=["path"], keep="last") state.data = frame.fillna("").sort_values("path").reset_index(drop=True) diff --git a/mod/hallmark/remote/clone.py b/mod/hallmark/remote/clone.py index 085dcd7..35d741b 100644 --- a/mod/hallmark/remote/clone.py +++ b/mod/hallmark/remote/clone.py @@ -6,14 +6,17 @@ from io import StringIO from pathlib import Path from shutil import rmtree -from urllib.parse import urlsplit +from urllib.parse import unquote, urlsplit import pandas as pd import yaml +from git import Repo as GitRepo +from git.exc import GitCommandError, GitError from ..repo.dothm import Dothm from ..error import CloneError, DestinationExistsError -from ..utils import as_list_of_dicts +from ..utils import as_list_of_dicts, load_yaml +from .download import _checksum_from_row, _validate_expected_checksum from ..repo.config import ( filename_fields, normalize_remotes, @@ -21,8 +24,11 @@ row_to_path, ) from ..transport import OperationContext, RemoteSpec -from ..transport.base import RemoteObjectMissing, validate_remote_path +from ..transport.base import ( + DownloadError, RemoteObjectMissing, reject_url_credentials, + validate_remote_path) from ..repo.worktree import Worktree +from ..repo.branches import validate_branch_name def _catalog_filenames(config): @@ -63,14 +69,30 @@ def _resolve_catalog_path(row, formats): return paths[0] -def _validate_snapshot(files, config): - """Validate catalog tables and paths before writing the snapshot.""" +def _validate_catalog(files, config): + """ + Validate a catalog's tables before it is written or kept. + + Every row must resolve to one safe relative path, and its checksum, if + any, must use a supported algorithm and a well-formed digest. + + Args: + files (dict[str, str]): Text of ``data.tsv`` and the other TSVs + named by ``config``. + config (dict): Parsed ``config.yml``. + + Raises: + CloneError: If a table is missing, unreadable or invalid. + ValueError: If a path or table name is unsafe. + """ for name in _catalog_filenames(config): + if name not in files: + raise CloneError(f"Catalog table is missing: {name}") try: frame = pd.read_csv(StringIO(files[name]), sep="\t", dtype=str, keep_default_na=False) except (pd.errors.EmptyDataError, pd.errors.ParserError) as exc: - raise CloneError(f"Invalid published catalog table: {name}") from exc + raise CloneError(f"Invalid catalog table: {name}") from exc formats = _catalog_formats(config, name) has_fields = False for fmt in formats: @@ -79,9 +101,61 @@ def _validate_snapshot(files, config): has_fields = True break if not ({"path", "sha1"} & set(frame.columns) or has_fields): - raise CloneError(f"Unrecognized published catalog columns: {name}") + raise CloneError(f"Unrecognized catalog columns: {name}") for _, row in frame.iterrows(): - _resolve_catalog_path(row, formats) + path = _resolve_catalog_path(row, formats) + try: + _validate_expected_checksum(_checksum_from_row(row)) + except DownloadError as exc: + raise CloneError(f"{name}: {path}: {exc}") from exc + + +def _validate_branch(git, branch): + """ + Validate the committed catalog of one branch of a cloned repository. + + Raises: + CloneError: If a state file is missing or the catalog is invalid. + """ + def read(name): + try: + return git.show("--end-of-options", f"{branch}:{name}") + except GitCommandError: + raise CloneError( + f"Branch '{branch}' is missing {name}") from None + + try: + config = load_yaml(read("config.yml")) + texts = {"meta.yml": read("meta.yml"), "data.tsv": read("data.tsv")} + load_yaml(texts["meta.yml"]) + except (ValueError, yaml.YAMLError) as exc: + detail = " ".join(str(exc).split()) + raise CloneError( + f"Branch '{branch}' has an invalid config.yml or meta.yml: " + f"{detail}") from exc + try: + for name in _catalog_filenames(config): + texts.setdefault(name, read(name)) + _validate_catalog(texts, config) + except (CloneError, ValueError) as exc: + raise CloneError( + f"Branch '{branch}' has an invalid catalog: {exc}") from exc + + +def _copy_branch_pointers(dothm): + """ + Create a local branch for every branch copied from the source. + + Returns: + list[str]: Names of all local branches. + """ + local = {head.name for head in dothm.heads} + for remote in dothm.remotes: + for ref in remote.refs: + if ref.remote_head != "HEAD" and ref.remote_head not in local: + dothm.git.branch("--track", "--", ref.remote_head, ref.name) + local.add(ref.remote_head) + return sorted(local) def _read_catalog_snapshot(context): @@ -118,7 +192,7 @@ def _read_catalog_snapshot(context): for name in _catalog_filenames(config): if name not in files: files[name] = context.read_text(name) - _validate_snapshot(files, config) + _validate_catalog(files, config) return files @@ -134,6 +208,204 @@ def _commit_catalog_metadata(repo, files, message): +def _scp_style(url): + """Return whether a Git source uses the SCP-like ``host:path`` form.""" + return ("://" not in url and not Path(url).expanduser().exists() + and bool(re.match(r"^(?:[^/@:]+@)?[^/:]+:.+", url))) + + +def default_clone_destination(source) -> Path: + """ + Name the folder that a clone creates when no destination is given. + + The name is the last segment of the source path or URL without a ``.git`` + suffix. A ``.hm`` folder is named after its worktree, and ``NAME.hm`` + after ``NAME``, so the default is never a bare repository. + + Args: + source (str): Git URL, local path, or published catalog URL. + + Returns: + Path: Relative folder name in the current folder. + + Raises: + CloneError: If the source has no usable name. + """ + text = str(source).strip() + if "://" in text: + path = unquote(urlsplit(text).path) + elif _scp_style(text): + path = text.split(":", 1)[1] + else: + path = text + segments = [part for part in path.split("/") if part not in ("", ".")] + while segments: + name = segments.pop() + if name == ".hm": + continue + for suffix in (".git", ".hm"): + if name.endswith(suffix): + name = name[:-len(suffix)] + if name and name != "..": + return Path(name) + break + raise CloneError("Cannot name a folder after this source; give a DIRECTORY") + + +def _local_source_root(url): + """Return the folder of a local source repository, or None for remote URLs.""" + if url.startswith("file://"): + local = Path(unquote(urlsplit(url).path)) + elif "://" in url: + return None + else: + local = Path(url).expanduser() + if not local.exists(): + return None + local = local.resolve() + return local.parent if local.name == ".hm" else local + + +def _check_destination(cls, url, destination, display_path): + """ + Refuse a destination that is not empty, is nested in a repository, or + overlaps a local source. Nothing is created or contacted. + + Returns: + bool: True when the destination is an existing empty folder. + """ + if destination.is_symlink() or (destination.exists() and ( + not destination.is_dir() or any(destination.iterdir()))): + raise DestinationExistsError( + f"fatal: destination path '{display_path}' already exists and is " + "not empty.") + target = destination.resolve() + source = _local_source_root(url) + if source is not None and (target == source or source in target.parents + or target in source.parents): + raise CloneError( + f"Destination '{display_path}' overlaps the source repository " + f"'{source}'; choose a separate folder") + enclosing = cls.find_root(target.parent) + if enclosing is not None: + raise CloneError( + f"Destination '{display_path}' is inside the Hallmark repository at " + f"'{enclosing}'; nested repositories are not supported, so choose " + "a folder outside it") + return destination.is_dir() + + +def _remove_partial_clone(cls, destination, *, created): + """ + Remove what a failed clone created, never following a symbolic link. + + The folders the clone created for its destination are removed. In a + worktree folder that existed only its new ``.hm`` is removed; a bare + folder that existed, and held nothing before, is emptied again. + + Args: + cls: Repository class, used to tell bare destinations apart. + destination (Path): Clone destination. + created (Path, optional): Highest folder created for the destination, + or None if the destination existed. + """ + if created is not None: + if not created.is_symlink(): + rmtree(created, ignore_errors=True) + return + if destination.is_symlink(): + return + if cls.resolve_repo_paths(destination)[1] is not None: + dothm = destination / ".hm" + if dothm.is_symlink(): + dothm.unlink() + else: + rmtree(dothm, ignore_errors=True) + return + destination.mkdir(exist_ok=True) + for child in destination.iterdir(): + if child.is_dir() and not child.is_symlink(): + rmtree(child, ignore_errors=True) + else: + child.unlink(missing_ok=True) + + +def _tracks_local_files(table_text): + """ + Return whether a ``data.tsv`` catalog versions local files. + + Local catalogs record a ``sha1`` per file and keep contents in + ``.hm/objects``; remote catalogs list ``path`` rows on a data server. + Unreadable tables are left to catalog validation. + """ + try: + frame = pd.read_csv(StringIO(table_text), sep="\t", dtype=str, + keep_default_na=False) + except (pd.errors.EmptyDataError, pd.errors.ParserError): + return False + return (not frame.empty and "sha1" in frame.columns + and "path" not in frame.columns) + + +def _refuse_local_file_branches(git, branches): + """ + Refuse a clone when a branch's committed catalog versions local files. + + Args: + git: GitPython command wrapper of the repository holding the branches. + branches (list[tuple[str, str]]): Branch names and their revisions. + + Raises: + CloneError: If any branch tracks local files. + """ + local = [] + for name, revision in branches: + try: + table = git.show("--end-of-options", f"{revision}:data.tsv") + except GitCommandError: + continue + if _tracks_local_files(table): + local.append(name) + if local: + names = ", ".join(f"'{name}'" for name in local) + raise CloneError( + f"Branch {names} tracks local files; cloning local-file repositories " + "is not supported yet. Nothing was created.") + + +def _refuse_invalid_branch_names(git, names): + """ + Refuse source branches whose names Git would misread, such as options. + + Raises: + CloneError: If a branch name is invalid. + """ + for name in names: + try: + validate_branch_name(git, name) + except ValueError: + raise CloneError( + f"Source branch {name!r} has an invalid name; cloning it is not " + "supported. Nothing was created.") from None + + +def _check_local_source(url): + """Inspect a local Git source read-only, before anything is written.""" + root = _local_source_root(url) + if root is None: + return + git_dir = root / ".hm" if (root / ".hm").is_dir() else root + try: + source = GitRepo(git_dir) + except GitError: + # Not a readable repository: cloning reports the problem itself. + return + with source: + names = [head.name for head in source.heads] + _refuse_invalid_branch_names(source.git, names) + _refuse_local_file_branches(source.git, [(name, name) for name in names]) + + def _is_git_source(url, source_type): """Determine whether the selected source should be cloned with Git.""" if source_type == "git": @@ -155,11 +427,20 @@ def _clone_git(cls, url, destination, display_path, auth): local = Path(url).expanduser() git_url = str(local / ".hm") if (local / ".hm").is_dir() else url try: - Dothm.clone(git_url, dothm_path, display_path=display_path) + dothm = Dothm.clone(git_url, dothm_path, display_path=display_path) except CloneError as exc: raise CloneError( f"{exc}\nFor a raw dataset, use hm init PATH, " "then hm add URL.") from exc + # Sources on a Git host can only be inspected once copied; the caller + # removes the copy if a check fails. + _refuse_invalid_branch_names(dothm.git, [ + ref.remote_head for remote in dothm.remotes + for ref in remote.refs if ref.remote_head != "HEAD"]) + branches = _copy_branch_pointers(dothm) + _refuse_local_file_branches(dothm.git, [(name, name) for name in branches]) + for name in branches: + _validate_branch(dothm.git, name) if worktree_path: Worktree.init(worktree_path) return cls(destination) @@ -169,9 +450,13 @@ def clone_catalog(cls, url, path, *, auth=None, source_type="auto"): """ Clone an existing Git catalog or published HTTP/SFTP catalog snapshot. - Existing destinations are rejected before source access. An incomplete - destination created by this call is removed on failure. Dataset discovery - belongs to ``Repo.add(URL)``. + Source URLs with credentials are rejected first. Repositories with a + branch that tracks local files cannot be cloned yet. The destination must + be new or an empty folder, outside any Hallmark repository and the local + source; this is checked before source access. On failure, only what this + call created is removed: the destination it created, or the ``.hm`` it + added to an existing worktree folder. An existing bare folder is emptied + again. Dataset discovery belongs to ``Repo.add(URL)``. Args: cls: Repository class used to initialize or open the result. @@ -184,26 +469,38 @@ def clone_catalog(cls, url, path, *, auth=None, source_type="auto"): Repo: Repository containing complete catalog metadata without payloads. Raises: - DestinationExistsError: If the destination already exists. - CloneError: If a requested catalog is missing or invalid. + DestinationExistsError: If the destination exists and is not an empty + folder. + CloneError: If the destination is inside a repository or overlaps a + local source, a branch tracks local files, or a requested catalog + is missing or invalid. ValueError: If source options are invalid. DownloadError: If remote metadata cannot be read or validated. """ if source_type not in {"auto", "git", "catalog"}: raise ValueError("source_type must be auto, git, or catalog") + # Git records the source URL in .hm/.git/config, so it must hold no secret. + reject_url_credentials(str(url)) url = str(url) destination = Path(path).expanduser().absolute() - if destination.exists() or destination.is_symlink(): - raise DestinationExistsError( - f"fatal: destination path '{path}' already exists and is not empty.") + existed = _check_destination(cls, url, destination, path) is_git = _is_git_source(url, source_type) if is_git and auth is not None: raise ValueError("Git cloning uses Git/SSH authentication, not auth profiles") - destination.parent.mkdir(parents=True, exist_ok=True) - try: - destination.mkdir() - except FileExistsError as exc: - raise DestinationExistsError(f"Destination already exists: {path}") from exc + if is_git: + _check_local_source(url) + created = None + if not existed: + # Remember the highest missing folder so a failure removes them all. + created = destination + while not (created.parent.exists() or created.parent.is_symlink()): + created = created.parent + destination.parent.mkdir(parents=True, exist_ok=True) + try: + destination.mkdir() + except FileExistsError as exc: + raise DestinationExistsError( + f"Destination already exists: {path}") from exc try: if is_git: return _clone_git(cls, url, destination, path, auth) @@ -220,11 +517,18 @@ def clone_catalog(cls, url, path, *, auth=None, source_type="auto"): raise CloneError("No published Hallmark catalog at this URL; " "use hm init PATH, then hm add URL " "for a raw dataset") + if _tracks_local_files(snapshot["data.tsv"]): + raise CloneError( + "The published catalog tracks local files; cloning local-file " + "repositories is not supported yet. Nothing was created.") + if cls.resolve_repo_paths(destination)[1] is None: + # Initialization creates a bare ".hm" folder itself. + destination.rmdir() repo = cls.init(destination) for name, contents in snapshot.items(): (repo.dothm.path / name).write_text(contents, encoding="utf-8") _commit_catalog_metadata(repo, snapshot, "Import published catalog snapshot") return repo except BaseException: - rmtree(destination, ignore_errors=True) + _remove_partial_clone(cls, destination, created=created) raise diff --git a/mod/hallmark/remote/download.py b/mod/hallmark/remote/download.py index 888424b..632b571 100644 --- a/mod/hallmark/remote/download.py +++ b/mod/hallmark/remote/download.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait from decimal import Decimal, InvalidOperation from pathlib import Path @@ -13,9 +14,10 @@ from ..transport import OperationContext, RemoteSpec from ..transport.base import DownloadError, validate_remote_path -from .plan import DownloadItem, DownloadPlan +from .plan import DownloadConflict, DownloadItem, DownloadPlan from ..utils import ( CHECKSUM_ALGORITHMS_BY_STRENGTH, + CHECKSUM_LENGTHS, SUPPORTED_CHECKSUM_ALGORITHMS, as_list_of_dicts, replace_file_on_success, @@ -34,6 +36,9 @@ ChecksumSpec = Union[str, tuple[str, str]] # The size of chunks to read from a file when downloading or computing checksums. DOWNLOAD_CHUNK_SIZE = 8192 +# Supported checksum algorithms by the length of their hexadecimal digests. +ALGORITHMS_BY_DIGEST_LENGTH = { + length: algorithm for algorithm, length in CHECKSUM_LENGTHS.items()} def _repository_config(repo) -> dict: """ @@ -152,7 +157,13 @@ def _parse_checksum(value, algorithm=None) -> Optional[ChecksumSpec]: algorithm_text = _clean_checksum(algorithm) # If the algorithm is None, return None to indicate no valid algorithm is available if algorithm_text is None: - return None + if str(algorithm).strip().lower() != "unknown": + return None + # Manifests such as checksums.txt do not name their algorithm; the + # length of a well-formed digest identifies the supported one it is. + algorithm_text = ALGORITHMS_BY_DIGEST_LENGTH.get(len(checksum)) + if algorithm_text is None or not is_valid_checksum(algorithm_text, checksum): + return None # Normalize the algorithm to lowercase and check if it is supported algorithm_text = algorithm_text.lower() if algorithm_text not in SUPPORTED_CHECKSUM_ALGORITHMS: @@ -504,7 +515,7 @@ def _download_and_verify_file( context, relative_path, destination, expected_checksum, chunk_size ): """ - Download and verify a file before replacing its destination. + Download and verify a file, then move it to its absent destination. Args: context (OperationContext): Transport and destination for this download. @@ -518,7 +529,8 @@ def _download_and_verify_file( int: Number of downloaded bytes. Raises: - DownloadError: If the transfer, checksum, or destination is invalid. + DownloadError: If the transfer, checksum, or destination is invalid, + or the destination exists. """ validated_checksum = _validate_expected_checksum(expected_checksum) try: @@ -535,12 +547,43 @@ def _download_and_verify_file( # Another process can still change the path after this check. resolve_path_in_root(context.output_root, relative_path, label="download destination") + # Planning skips or refuses existing files, so a file here appeared + # later; never replace it. The same race as above remains. + if destination.exists() or destination.is_symlink(): + raise DownloadError( + f"Refusing to replace {destination.name}: it appeared after " + "the download was planned; delete it first to replace it") context.check_cancelled() return size except (OSError, ValueError) as exc: raise DownloadError(f"Failed to write {destination.name}: {exc}") from None +def _normalize_requests(file_paths: Sequence[str]) -> tuple[str, ...]: + """ + Used by _select_download_items and plan_download. + Normalize requested catalog paths and folders, keeping their order. + + A trailing "/" is optional for folders, and "." requests the whole catalog. + + Returns: + tuple[str, ...]: Unique POSIX paths relative to the catalog root. + """ + requests = [] + for value in file_paths: + text = str(value) + request = "." if text.rstrip("/") == "." else validate_remote_path( + text).as_posix() + if request not in requests: + requests.append(request) + return tuple(requests) + + +def _is_requested(path: str, request: str) -> bool: + """Return whether a catalog path is a requested file or lies below a folder.""" + return request == "." or path == request or path.startswith(request + "/") + + def _select_download_items( repo, file_paths: Sequence[str] = (), @@ -553,7 +596,9 @@ def _select_download_items( Args: repo: The hallmark repository object. - file_paths: A sequence of specific file paths to download. + file_paths: Catalogued files or folders to download. A folder selects + every catalogued file below it; paths outside the catalog select + nothing. tsv_names: A sequence of TSV names to download files from. all_files: If True, include all files from the repository's configuration. @@ -568,7 +613,10 @@ def _select_download_items( # dictionary to store selected files with relative paths and optional checksums. selected: dict[str, tuple[Path, Optional[ChecksumSpec]]] = {} metadata = {} - explicit_paths = {validate_remote_path(path).as_posix() for path in file_paths} + explicit_paths = _normalize_requests(file_paths) + + def requested(path: str) -> bool: + return any(_is_requested(path, request) for request in explicit_paths) def add_file( value: Union[str, Path], @@ -609,12 +657,9 @@ def add_frame(frame: pd.DataFrame, fmt_entries: list[dict], row = dict(zip(columns, values)) # add the resolved remote path and its checksum to the selected files relative_path = validate_remote_path(_resolve_remote_path(row, fmt_entries)) - if not explicit_only or relative_path.as_posix() in explicit_paths: + if not explicit_only or requested(relative_path.as_posix()): add_file(relative_path, _checksum_from_row(row), row) - # Add explicitly requested file paths to the selected files. - for file_path in file_paths: - add_file(file_path) # Organize data configuration entries by their TSV names for easier access. entries_by_tsv: dict[str, list[dict]] = {} for entry in data_config: @@ -691,7 +736,7 @@ def add_frame(frame: pd.DataFrame, fmt_entries: list[dict], # if a file path is specified in the entry, add it to the selected files if file_path and ( all_files - or validate_remote_path(file_path).as_posix() in explicit_paths + or requested(validate_remote_path(file_path).as_posix()) ): add_file(file_path, _checksum_from_config(entry), entry) @@ -711,6 +756,73 @@ def add_frame(frame: pd.DataFrame, fmt_entries: list[dict], for path, checksum in selected.values()] +def _existing_file_status(item: DownloadItem, path: Path): + """ + Used by _sort_existing_destinations. + Compare an existing destination with its catalog size and checksum. + + The size is compared first, so a file of the wrong size is never read. + + Returns: + tuple[str, str | None]: ``("verified", None)`` when the checksum + matches; ``("unverified", None)`` when the catalog has no usable + checksum but records the same size; otherwise ``("conflict", reason)``. + """ + if path.is_symlink() or not path.is_file(): + return "conflict", "not a regular file" + try: + size = path.stat().st_size + except OSError: + return "conflict", "cannot be read" + if item.size_bytes is not None and size != item.size_bytes: + return "conflict", (f"{size:,} bytes, but the catalog records " + f"{item.size_bytes:,}") + try: + expected = _validate_expected_checksum(item.checksum) + except DownloadError: + expected = None + if expected is None: + if item.size_bytes is None: + return "conflict", "no catalog checksum or size to verify it" + return "unverified", None + algorithm, digest = expected + try: + actual = calculate_file_checksum(path, algorithm=algorithm) + except OSError: + return "conflict", "cannot be read" + if actual.lower() != digest: + return "conflict", "different contents" + return "verified", None + + +def _sort_existing_destinations(items, destinations): + """ + Used by plan_download. + Separate files to transfer from those whose destinations already exist. + + Existing files are checked in parallel with their catalog size and + checksum algorithm, using a small pool because hashing reads whole files. + + Returns: + tuple: Items to transfer, items already downloaded and verified, + items present with the catalog size but no checksum, and conflicts. + """ + existing = [(item, path) for item, path in zip(items, destinations) + if path.exists() or path.is_symlink()] + if not existing: + return list(items), [], [], [] + with ThreadPoolExecutor(max_workers=min(8, os.cpu_count() or 1)) as executor: + statuses = list(executor.map( + lambda pair: _existing_file_status(*pair), existing)) + present = {item.relative_path for item, _ in existing} + by_status = {"verified": [], "unverified": [], "conflict": []} + for (item, _), (status, reason) in zip(existing, statuses): + by_status[status].append( + DownloadConflict(item, reason) if status == "conflict" else item) + return ([item for item in items if item.relative_path not in present], + by_status["verified"], by_status["unverified"], by_status["conflict"]) + + def _parse_file_size(value) -> Optional[int]: """Read a recorded size without treating missing or malformed sizes as zero.""" if value is None or isinstance(value, bool) or pd.isna(value): @@ -741,12 +853,21 @@ def plan_download( No network requests are made. With no explicit paths or TSVs, select the complete catalog before applying filters. Missing sizes remain unknown. + Requested paths that match no catalogued file are recorded in + ``unknown_paths``. Existing destinations are compared with the catalog: + files with their catalog checksum are ``skipped``; files without a usable + catalog checksum but with the recorded size are ``unverified`` and also + not transferred; files that differ, cannot be checked at all or are not + regular files are ``conflicts``. A plan with unknown paths or conflicts + cannot be executed. Args: repo: The hallmark repository object. output_path (Path | str, optional): Destination directory. Defaults to the worktree; required for a bare repository. - file_paths (sequence[str], optional): Remote-relative file paths. + file_paths (sequence[str], optional): Catalogued files or folders, + relative to the catalog root. A folder selects every catalogued + file below it, and "." selects the whole catalog. tsv_names (sequence[str], optional): Catalog TSVs to select. all_files (bool): Select all configured files. Cannot be combined with explicit paths or TSVs. Defaults to False. @@ -782,6 +903,11 @@ def plan_download( items = _select_download_items( repo, file_paths=file_paths, tsv_names=tsv_names, all_files=all_files or (not file_paths and not tsv_names)) + # A path is unknown when no catalogued file is, or lies below, that path. + selected_paths = [item.relative_path.as_posix() for item in items] + unknown_paths = tuple( + request for request in _normalize_requests(file_paths) + if not any(_is_requested(path, request) for path in selected_paths)) if filter is not None or fmt is not None: from .discovery import path_matches items = [item for item in items if path_matches( @@ -795,18 +921,23 @@ def plan_download( source = RemoteSpec.from_url( remote["url"], remote.get("auth"), backend=remote.get("backend"), backend_options=remote.get("backend_options")) + destinations = [] for item in items: try: - resolve_path_in_root(output_root, item.relative_path, - label="download destination") + destinations.append(resolve_path_in_root( + output_root, item.relative_path, label="download destination")) except ValueError as exc: raise DownloadError(str(exc)) from exc + items, skipped, unverified, conflicts = _sort_existing_destinations( + items, destinations) return DownloadPlan( tuple(items), remote.get("url"), output_root, remote_auth=remote.get("auth"), remote_name=remote.get("name"), estimated_bytes_per_second=estimated_bytes_per_second, remote_backend=source.backend if source is not None else None, - backend_options=remote.get("backend_options")) + backend_options=remote.get("backend_options"), + unknown_paths=unknown_paths, skipped=tuple(skipped), + unverified=tuple(unverified), conflicts=tuple(conflicts)) def execute_download_plan( @@ -834,11 +965,22 @@ def execute_download_plan( Raises: TypeError: If ``plan`` is not a DownloadPlan. - DownloadError: If approval is missing, setup fails, or the - destination is invalid. + DownloadError: If the plan has unknown paths or conflicts, approval + is missing, setup fails, or the destination is invalid. """ if not isinstance(plan, DownloadPlan): raise TypeError("plan must be a DownloadPlan") + if plan.unknown_paths: + raise DownloadError( + "Not in the catalog: " + ", ".join(plan.unknown_paths) + + "; nothing was downloaded") + if plan.conflicts: + raise DownloadError( + "Existing files conflict with the catalog: " + ", ".join( + f"{conflict.item.relative_path.as_posix()} ({conflict.reason})" + for conflict in plan.conflicts) + + "; nothing was downloaded. " + "Delete conflicting files first to replace them") if plan.items and approved is not True: raise DownloadError("Dataset downloads require explicit approval") max_workers = _require_positive_integer(max_workers, label="max_workers") diff --git a/mod/hallmark/remote/plan.py b/mod/hallmark/remote/plan.py index fbf7854..c762e64 100644 --- a/mod/hallmark/remote/plan.py +++ b/mod/hallmark/remote/plan.py @@ -50,6 +50,21 @@ def __post_init__(self) -> None: raise ValueError("mtime must be a string or None") +@dataclass(frozen=True) +class DownloadConflict: + """ + A selected file whose destination exists and cannot be kept as it is. + + Attributes: + item (DownloadItem): The catalogued file. + reason (str): Why the existing destination conflicts, for example + ``"different contents"``. + """ + + item: DownloadItem + reason: str + + @dataclass(frozen=True) class DownloadPlan: """ @@ -57,9 +72,12 @@ class DownloadPlan: Creating a plan does not approve a transfer. Sizes come from the catalog; a duration estimate requires every file size and a supplied transfer rate. + Existing destinations are never replaced: files already downloaded are + skipped, and any conflict stops the whole download. Attributes: - items (tuple[DownloadItem]): Selected files and their metadata. + items (tuple[DownloadItem]): Selected files to transfer, with their + metadata. remote_url (str, optional): Data source URL; may be None for an empty plan. output_path (Path): Absolute destination directory. remote_auth (str, optional): Local SSH profile name. @@ -68,6 +86,16 @@ class DownloadPlan: by the caller for duration estimates. remote_backend (str, optional): Registered backend selected for transfer. backend_options (mapping): Immutable backend configuration snapshot. + unknown_paths (tuple[str]): Requested paths that match no catalogued + file. A plan with unknown paths cannot be executed. + skipped (tuple[DownloadItem]): Selected files already present with + their catalog checksum; they are not transferred. + unverified (tuple[DownloadItem]): Selected files already present with + their catalog size but without a catalog checksum to verify them; + they are not transferred. + conflicts (tuple[DownloadConflict]): Selected files whose existing + destinations differ from the catalog or cannot be verified. A plan + with conflicts cannot be executed. """ items: tuple[DownloadItem, ...] @@ -78,10 +106,25 @@ class DownloadPlan: estimated_bytes_per_second: Optional[float] = None remote_backend: Optional[str] = None backend_options: Mapping = field(default_factory=dict, repr=False, hash=False) + unknown_paths: tuple[str, ...] = () + skipped: tuple[DownloadItem, ...] = () + conflicts: tuple[DownloadConflict, ...] = () + unverified: tuple[DownloadItem, ...] = () def __post_init__(self) -> None: """Copy the selection and validate the source fields and rate.""" - object.__setattr__(self, "items", tuple(self.items)) + for name in ("items", "unknown_paths", "skipped", "conflicts", + "unverified"): + object.__setattr__(self, name, tuple(getattr(self, name))) + if any(not isinstance(path, str) for path in self.unknown_paths): + raise TypeError("unknown_paths must contain strings") + for name in ("skipped", "unverified"): + if any(not isinstance(item, DownloadItem) + for item in getattr(self, name)): + raise TypeError(f"{name} must contain DownloadItem values") + if any(not isinstance(conflict, DownloadConflict) + for conflict in self.conflicts): + raise TypeError("conflicts must contain DownloadConflict values") object.__setattr__( self, "output_path", Path(self.output_path).expanduser().absolute()) if any(not isinstance(item, DownloadItem) for item in self.items): @@ -133,7 +176,9 @@ def summary(self) -> str: Describe the planned transfer, including unavailable estimates. Returns: - str: File count, size and duration estimates, source, and destination. + str: File count, size and duration estimates, source, destination, + and the numbers of skipped and unverified files, conflicts and + unknown paths. URL credentials, query parameters, and fragments are omitted. """ size = f"{self.known_bytes:,} bytes" @@ -147,6 +192,13 @@ def summary(self) -> str: source = urlunsplit(parsed._replace( netloc=parsed.netloc.rsplit("@", 1)[-1], query="", fragment="")) backend = f"\nBackend: {self.remote_backend}" if self.remote_backend else "" + notes = "".join(f"\n{count} {text}" for count, text in ( + (len(self.skipped), "file(s) already downloaded; skipped"), + (len(self.unverified), + "file(s) present (size matches, not verified); skipped"), + (len(self.conflicts), "existing file(s) conflict with the catalog"), + (len(self.unknown_paths), "path(s) not in the catalog"), + ) if count) return (f"{self.file_count} file(s); {size}; estimated duration: {duration}" f"\nSource: {source}{backend}" - f"\nDestination: {self.output_path}") + f"\nDestination: {self.output_path}{notes}") diff --git a/mod/hallmark/repo/__init__.py b/mod/hallmark/repo/__init__.py index b2fdacf..d38cefc 100644 --- a/mod/hallmark/repo/__init__.py +++ b/mod/hallmark/repo/__init__.py @@ -15,13 +15,14 @@ from __future__ import annotations +import shlex from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Tuple, Union -from git.exc import GitCommandError +from git.exc import InvalidGitRepositoryError, NoSuchPathError -from .branches import checkout, add_worktree +from .branches import checkout, add_worktree, validate_branch_name from ..remote.add import add_remote, is_remote_catalog from .dothm import Dothm from .state import State @@ -31,7 +32,7 @@ from .manifest import build_file_table, file_versions_by_path, iter_manifest_entries from .history import ( load_head_state) -from ..error import DestinationExistsError +from ..error import DestinationExistsError, DothmError from ..utils import ( FILE_IO_CHUNK_SIZE, use_working_directory, @@ -80,6 +81,56 @@ def resolve_repo_paths(path: Union[Path, str]) -> Tuple[Path, Optional[Path]]: return path, None return path / ".hm", path + @staticmethod + def find_root(start: Union[Path, str] = ".") -> Optional[Path]: + """ + Locate the repository containing a folder without opening it. + + Args: + start (Path | str): Folder to search from. Parent folders are + searched in turn. + + Returns: + Path | None: The worktree, or the bare ``.hm`` repository, of the + nearest enclosing repository; None if there is none. + """ + start = Path(start).expanduser().resolve() + for folder in (start, *start.parents): + # A bare repository is itself the ".hm" folder, as in Repo(path). + if folder.name == ".hm" or ( + folder.suffix == ".hm" and (folder / ".git").exists()): + return folder + # Stop at a damaged ".hm" too, so opening it can explain the damage. + if (folder / ".hm").exists() or (folder / ".hm").is_symlink(): + return folder + return None + + @classmethod + def find(cls, start: Union[Path, str] = ".") -> "Repo": + """ + Open the repository containing a folder, searching parent folders. + + ``Repo(path)`` opens exactly ``path``; ``find`` lets commands run from + any folder inside a worktree. + + Args: + start (Path | str): Folder to search from. Defaults to the current + folder. + + Returns: + Repo: The nearest enclosing repository. + + Raises: + DothmError: If neither the folder nor any parent contains ``.hm``. + """ + root = cls.find_root(start) + if root is None: + raise DothmError( + f'Not a Hallmark repository (or any parent folder): ' + f'"{Path(start).expanduser().resolve()}"; ' + "run hm init or hm clone") + return cls(root) + def __init__(self, path: Union[Path, str]) -> None: ''' Open an existing hallmark repository. @@ -90,18 +141,29 @@ def __init__(self, path: Union[Path, str]) -> None: none. ''' dothm_path, worktree_path = self.resolve_repo_paths(path) - self.dothm = Dothm(dothm_path) + # Opening only reads: a damaged repository is explained, not repaired. + try: + self.dothm = Dothm(dothm_path) + except InvalidGitRepositoryError as exc: + raise DothmError( + f'Repository at "{dothm_path}" is damaged: it is not a Git ' + "worktree; nothing was changed") from exc + except NoSuchPathError as exc: + # A missing .hm keeps the GitPython error; a dangling link is damage. + if not Path(dothm_path).is_symlink(): + raise + raise DothmError( + f'Repository at "{dothm_path}" is damaged: it links to a folder ' + "that does not exist; nothing was changed") from exc self.worktree = worktree_path and Worktree(worktree_path) - self.state = self.dothm.load_state() + try: + self.state = self.dothm.load_state() + except DothmError as exc: + raise DothmError(f"{exc}; nothing was changed") from exc self.download_result = None - common = Path(self.dothm.common_dir).resolve().parent - self.objects = Objects(common) - dothm_objects = Path(dothm_path) / "objects" - main_objects = common / "objects" - if dothm_objects.resolve() != main_objects.resolve() \ - and not dothm_objects.exists(): - dothm_objects.symlink_to(main_objects) + # Linked worktrees share the object store of the main ".hm". + self.objects = Objects(Path(self.dothm.common_dir).resolve().parent) def _resolve_worktree_path(self, value, *, label: str = "tracked path") -> Path: """ @@ -135,21 +197,7 @@ def _validate_branch_name(self, value) -> str: Raises: ValueError: If the branch name is invalid. """ - # Normalize the branch name to ensure it is a non-empty string - branch_name = require_nonempty_string(value, label="branch name") - # if the branch name starts with a hyphen, raise a ValueError - if branch_name.startswith("-"): - raise ValueError(f"invalid branch name: {branch_name!r}") - - # try to validate the branch name using Git's check_ref_format command - try: - self.dothm.git.check_ref_format("--branch", branch_name) - # if Git raises a GitCommandError, re-raise it as a ValueError - except GitCommandError as exc: - raise ValueError(f"invalid branch name: {branch_name!r}") from exc - - # if all checks pass, return the normalized branch name - return branch_name + return validate_branch_name(self.dothm.git, value) def _calculate_file_checksums(self, pf: ParaFrame) -> None: """ @@ -206,27 +254,43 @@ def init(cls, path: Union[Path, str] = ".") -> "Repo": def _download_cloned_files(self, *, approve, max_workers, progress, filter=None, fmt=None): - """Run an approved transfer after catalog creation has completed.""" + """ + Run an approved transfer after catalog creation has completed. + + Raises: + DownloadError: If the download cannot run or a file fails. The + message reports the clone as incomplete and how to retry. + """ from ..remote.download import DownloadError - plan = self.plan_download(filter=filter, fmt=fmt) - if plan.file_count and approve is not None and not approve(plan): - return - self.download_result = self.download( - plan, approved=True, max_workers=max_workers, progress=progress) + patterns = [filter] if isinstance(filter, str) else list(filter or []) + command = ["hm download --all", + *(f"--filter {shlex.quote(pattern)}" for pattern in patterns)] + if fmt is not None: + command.append(f"--fmt {shlex.quote(fmt)}") + retry = f'run {" ".join(command)} in "{self.worktree}" to retry' + incomplete = f'Clone incomplete: catalogue at "{self.dothm.path}"' + try: + plan = self.plan_download(filter=filter, fmt=fmt) + if plan.file_count and approve is not None and not approve(plan): + return + self.download_result = self.download( + plan, approved=True, max_workers=max_workers, progress=progress) + except DownloadError as exc: + raise DownloadError( + f"{incomplete}; the download failed: {exc}; {retry}") from exc if self.download_result["failed"]: details = "\n".join(self.download_result["errors"][:5]) raise DownloadError( - f"Failed to download {self.download_result['failed']} " - f"file(s):\n{details}\n" - f'Catalog kept at "{self.dothm.path}". ' - "Successfully downloaded files were kept.") + f"{incomplete}; {self.download_result['failed']} file(s) " + f"failed:\n{details}\n" + f"Successfully downloaded files were kept; {retry}.") @classmethod def clone( cls, url: str, - path: Union[Path, str], + path: Optional[Union[Path, str]] = None, *, auth: Optional[str] = None, filter=None, @@ -247,7 +311,10 @@ def clone( Args: url (str): Existing Git catalog or published snapshot location. - path (Path | str): New worktree or bare ``.hm`` repository path. + path (Path | str, optional): New or empty worktree folder, or bare + ``.hm`` repository path, outside any repository and the local + source. Defaults to a folder in the current folder named after + the source. auth (str, optional): Local profile for snapshot metadata access. Git sources use Git's authentication configuration instead. filter (str | list[str], optional): Download selection globs. @@ -270,16 +337,21 @@ def clone( when a download was attempted, including an empty selection. Raises: - DestinationExistsError: If the destination already exists. - CloneError: If a requested catalog is missing or invalid. + DestinationExistsError: If the destination exists and is not an + empty folder. + CloneError: If the destination is nested in a repository or + overlaps the source, or a requested catalog is missing or + invalid. ValueError: If source options are invalid or conflict. DownloadError: If metadata access or downloading fails, or a download is requested for a bare destination. """ - from ..remote.clone import clone_catalog + from ..remote.clone import clone_catalog, default_clone_destination from ..remote.discovery import path_matches from ..remote.download import DownloadError, _require_positive_integer + if path is None: + path = default_clone_destination(url) _require_positive_integer(max_workers, label="max_workers") if (filter is not None or fmt is not None) and not download: raise ValueError("clone filter and fmt require download=True; " diff --git a/mod/hallmark/repo/branches.py b/mod/hallmark/repo/branches.py index b94c728..1d041ab 100644 --- a/mod/hallmark/repo/branches.py +++ b/mod/hallmark/repo/branches.py @@ -5,7 +5,7 @@ from git.exc import GitCommandError from ..error import CheckoutError, DestinationExistsError, DothmError -from ..utils import resolve_path_in_root +from ..utils import require_nonempty_string, resolve_path_in_root from ..remote.add import is_remote_catalog from .config import branch_filename_format, row_to_path, single_data_format from .manifest import iter_manifest_entries @@ -15,6 +15,39 @@ from .changes import ensure_clean_tracked_files, tracked_paths +def validate_branch_name(git, value) -> str: + """ + Validate and normalize a Git branch name. + + Names that Git would read as options, such as ``-q``, are refused. + + Args: + git: GitPython command wrapper used to run ``check-ref-format``. + value (str): The branch name to validate. + + Returns: + str: The normalized branch name. + + Raises: + ValueError: If the branch name is invalid. + """ + # Normalize the branch name to ensure it is a non-empty string + branch_name = require_nonempty_string(value, label="branch name") + # if the branch name starts with a hyphen, raise a ValueError + if branch_name.startswith("-"): + raise ValueError(f"invalid branch name: {branch_name!r}") + + # try to validate the branch name using Git's check_ref_format command + try: + git.check_ref_format("--branch", branch_name) + # if Git raises a GitCommandError, re-raise it as a ValueError + except GitCommandError as exc: + raise ValueError(f"invalid branch name: {branch_name!r}") from exc + + # if all checks pass, return the normalized branch name + return branch_name + + def checkout(repo, target_branch): # Validate and normalize the target branch name target_branch = repo._validate_branch_name(target_branch) @@ -366,6 +399,9 @@ def add_worktree(repo, target_branch): # iterate over the target state data and restore files from the object store try: + # Point the new ".hm" at the shared object store for tools that read + # objects through a repository path, such as a clone of this worktree. + (target_dothm / "objects").symlink_to(repo.objects.root) for _, row in target_state.data.iterrows(): rel_path = row_to_path(row, target_fmt) # resolve relative path to an absolute path in the target worktree diff --git a/mod/hallmark/repo/config.py b/mod/hallmark/repo/config.py index 31694bb..fcf134e 100644 --- a/mod/hallmark/repo/config.py +++ b/mod/hallmark/repo/config.py @@ -12,6 +12,8 @@ from string import Formatter from typing import Dict, Optional +import parse + from ..utils import ( convert_format_value, require_nonempty_string, validate_path_name, validate_relative_path) @@ -21,6 +23,7 @@ validate_backend_name, validate_profile_name, reject_control_characters, + reject_url_secrets, copy_backend_options, ) @@ -399,6 +402,7 @@ def set_config( if isinstance(remote_url, str): reject_control_characters(remote_url, "Remote URL") remote_url = require_nonempty_string(remote_url, label="remote_url") + reject_url_secrets(remote_url) if remote_auth not in (None, ""): validate_profile_name(remote_auth) @@ -484,6 +488,52 @@ def filename_fields(fmt: str) -> list[str]: return fields +def _catalogued_paths(state) -> list[str]: + """ + Used by check_pattern_keeps_catalogue. + Return the relative path of every file in a state's catalog. + """ + if "path" in state.data.columns: + return [str(path) for path in state.data["path"]] + fmt = single_data_format(state.config) + if fmt is None: + return [] + return [row_to_path(row, fmt).as_posix() + for row in state.data.to_dict(orient="records")] + + +def check_pattern_keeps_catalogue(state, new_fmt: Optional[str]) -> None: + """ + Refuse a filename pattern that files in the catalog do not fit. + + A branch has one pattern, so replacing it must not drop catalogued files. + Only local state is read. + + Args: + state (State): Staged repository state. + new_fmt (str, optional): Proposed filename pattern; None keeps none. + + Raises: + ValueError: If any catalogued path does not match ``new_fmt``. The + message lists a few of them; there is no force option. + """ + if new_fmt is None or state.data.empty: + return + parser = parse.compile(new_fmt, case_sensitive=True) + misfits = [path for path in _catalogued_paths(state) + if parser.parse(path) is None] + if not misfits: + return + shown = ", ".join(misfits[:3]) + if len(misfits) > 3: + shown += f" and {len(misfits) - 3} more" + raise ValueError( + f"Pattern {new_fmt!r} does not fit {len(misfits)} catalogued file(s) " + f"({shown}). A branch has one pattern and a new one cannot drop " + "catalogued files; use a pattern that fits them too (there is no " + "force option)") + + def validate_tsv_filename(value) -> str: """ Validate a TSV database name and add the .tsv suffix when necessary. diff --git a/mod/hallmark/repo/dothm.py b/mod/hallmark/repo/dothm.py index 9243189..387c820 100644 --- a/mod/hallmark/repo/dothm.py +++ b/mod/hallmark/repo/dothm.py @@ -29,6 +29,9 @@ replace_file_on_success, load_yaml_file, validate_path_name) from .state import State +# Files that make up the state database of every ``.hm`` repository. +STATE_FILES = ("config.yml", "meta.yml", "data.tsv") + class _HallmarkYamlDumper(yaml.Dumper): """ @@ -196,10 +199,36 @@ def link_worktree(self, path: Union[Path, str], branch: Optional[str] = None): return Dothm(path) def load_state(self) -> State: + """ + Read the state database files. + + Returns: + State: The repository configuration, metadata and catalog. + + Raises: + DothmError: If a state file is missing or cannot be read. The + files are only read, never repaired. + """ + missing = [name for name in STATE_FILES if not (self.path / name).is_file()] + if missing: + raise DothmError( + f'Repository at "{self.path}" is damaged: missing ' + + ", ".join(missing)) + + def read(name, reader): + try: + return reader(Path(name).stem) + except (OSError, ValueError, yaml.YAMLError) as exc: + # ValueError covers pandas parser and Unicode decoding errors. + detail = " ".join(str(exc).split()) + raise DothmError( + f'Repository at "{self.path}" is damaged: cannot read ' + f"{name} ({detail})") from exc + return State( - config = self.read_yaml("config"), - meta = self.read_yaml("meta"), - data = self.read_tsv("data")) + config = read("config.yml", self.read_yaml), + meta = read("meta.yml", self.read_yaml), + data = read("data.tsv", self.read_tsv)) def save_state(self, state: State) -> None: self.write_yaml(state.config, "config") diff --git a/mod/hallmark/transport/base.py b/mod/hallmark/transport/base.py index 58df83f..b88549c 100644 --- a/mod/hallmark/transport/base.py +++ b/mod/hallmark/transport/base.py @@ -95,6 +95,45 @@ def reject_control_characters(value: str, label: str) -> None: raise RemoteConfigurationError(f"{label} contains control characters") +def reject_url_credentials(url: str) -> None: + """ + Refuse a URL carrying a password, or an HTTP(S) username or token. + + SSH usernames are allowed. Error messages never repeat the URL. + + Raises: + RemoteConfigurationError: If the URL contains credentials. + """ + try: + parsed = urlsplit(url) + except ValueError: + raise RemoteConfigurationError("Invalid remote URL") from None + if parsed.password is not None or ( + parsed.username is not None + and parsed.scheme.lower() in {"http", "https"}): + raise RemoteConfigurationError( + "URLs must not contain credentials; configure them locally, for " + "example in ~/.netrc, ~/.ssh/config or a Git credential helper") + + +def reject_url_secrets(url: str) -> None: + """ + Refuse a data-remote URL that would store a secret in the catalog. + + Passwords, HTTP(S) usernames (often access tokens), query strings and + fragments are rejected; SSH usernames are allowed. Error messages never + repeat the URL. + + Raises: + RemoteConfigurationError: If the URL could contain a secret. + """ + reject_url_credentials(url) + if "?" in url or "#" in url: + raise RemoteConfigurationError( + "Remote URLs must not contain a query or fragment; " + "percent-encode ? and # in file names") + + def validate_remote_path(value) -> Path: """Validate a literal catalog path without decoding or trimming it.""" raw = str(value) diff --git a/test/test_backend_config.py b/test/test_backend_config.py index f1c8029..5708189 100644 --- a/test/test_backend_config.py +++ b/test/test_backend_config.py @@ -54,7 +54,7 @@ def test_add_passes_backend_settings_to_discovery(monkeypatch, tmp_path): def discover_remote_files(context, **kwargs): captured.append(context.remote) - return [] + return [RemoteEntry("tile.fits")] # No backend installation or network is required to inspect this hand-off. class Context: diff --git a/test/test_catalog.py b/test/test_catalog.py index 61bd5e8..b5ff875 100644 --- a/test/test_catalog.py +++ b/test/test_catalog.py @@ -59,12 +59,20 @@ def test_remote_add_does_not_require_filename_inference(tmp_path, metadata_serve assert repo.plan_download().file_count == 1 -def test_empty_filtered_add_is_valid(tmp_path, metadata_server): +def test_empty_filtered_add_is_an_error_and_keeps_catalog(tmp_path, metadata_server): pages, _ = metadata_server root = "https://example.test/data/" pages[root] = '