From addfb667af81260a4b784d4b37e73fb9e9bbaa0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:38:02 +0000 Subject: [PATCH 1/9] Accept broken symlinks as path arguments of `dandi validate` `dandi validate` already handles annexed files whose content has not been fetched (the broken symlinks of a DataLad dataset) according to `--missing-file-content`: `error`, `skip`, or `only-non-data`. Those policies were applied only when such a file was reached through its directory, though: given directly on the command line, the file never got past argument parsing, because `click.Path(exists=True)` follows the link and rejected it with "does not exist". Check the path with `lexists()` instead, so that dandi validate --missing-file-content=skip sub-01/sub-01.nwb works the same as validating `sub-01/`, while paths that truly do not exist are still rejected with the same error. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HFhMPs6vF5zyeHDvPTvRcA --- dandi/cli/cmd_validate.py | 26 ++++++++++++++++++++++++-- dandi/cli/tests/test_cmd_validate.py | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/dandi/cli/cmd_validate.py b/dandi/cli/cmd_validate.py index 375dbc578..78a86bb6a 100644 --- a/dandi/cli/cmd_validate.py +++ b/dandi/cli/cmd_validate.py @@ -7,7 +7,7 @@ import os import re import sys -from typing import IO, Union, cast +from typing import IO, Any, Union, cast import warnings import click @@ -35,6 +35,28 @@ class TruncationNotice: """Number of validation results omitted from this group.""" +class ExistingPath(click.Path): + """ + Like ``click.Path(exists=True)``, but also accepting broken symbolic links, + such as the annexed files of a DataLad dataset whose content has not been + fetched. ``click.Path(exists=True)`` follows the link and rejects those as + nonexistent, which prevented the ``--missing-file-content`` policies + (``error``, ``skip``, ``only-non-data``) from ever being applied to a file + given directly on the command line rather than via its directory. + """ + + def convert( + self, value: Any, param: click.Parameter | None, ctx: click.Context | None + ) -> Any: + if not os.path.lexists(value): + self.fail( + f"{self.name.title()} {os.fsdecode(value)!r} does not exist.", + param, + ctx, + ) + return super().convert(value, param, ctx) + + STRUCTURED_FORMATS = ("json", "json_pp", "json_lines", "yaml") _EXT_TO_FORMAT = { @@ -228,7 +250,7 @@ def validate_bids( multiple=True, default=(), ) -@click.argument("paths", nargs=-1, type=click.Path(exists=True, dir_okay=True)) +@click.argument("paths", nargs=-1, type=ExistingPath(dir_okay=True)) @click.pass_context @devel_debug_option() @map_to_click_exceptions diff --git a/dandi/cli/tests/test_cmd_validate.py b/dandi/cli/tests/test_cmd_validate.py index 6d0b503a0..1da5c16ca 100644 --- a/dandi/cli/tests/test_cmd_validate.py +++ b/dandi/cli/tests/test_cmd_validate.py @@ -996,3 +996,25 @@ def test_validate_missing_file_content_no_broken_symlinks(tmp_path: Path) -> Non assert r_default.exit_code == r_skip.exit_code == 0 assert "FILE_CONTENT_MISSING" not in r_default.output assert "FILE_CONTENT_MISSING" not in r_skip.output + + +@pytest.mark.ai_generated +def test_validate_broken_symlink_path_argument(tmp_path: Path) -> None: + """A broken symlink can be given directly as a path to validate. + + The --missing-file-content policies then apply to it just as they do when + the containing directory is given. + """ + ds = _make_dandiset_with_broken_symlinks(tmp_path) + link = ds / "sub-001" / "sub-001.nwb" + r = CliRunner().invoke(validate, [str(link)]) + assert r.exit_code == 1 + assert "does not exist" not in r.output + assert "FILE_CONTENT_MISSING" in r.output + r = CliRunner().invoke(validate, ["--missing-file-content", "skip", str(link)]) + assert r.exit_code == 0 + assert "FILE_CONTENT_MISSING_SKIPPED" in r.output + # Paths that truly do not exist are still rejected + r = CliRunner().invoke(validate, [str(ds / "sub-001" / "nonexistent.nwb")]) + assert r.exit_code == 2 + assert "does not exist" in r.output From 6fe8855144e0ff26bbe6521d3a93bc32837aa2aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 20:29:29 +0000 Subject: [PATCH 2/9] Harden and move the broken-symlink-accepting path type Address review on the `click.Path` subclass accepting broken symlinks: - Rename `ExistingPath` to `PathOrBrokenSymlink`, which says what sets it apart, and move it to `dandi/cli/base.py` with the other reusable `ParamType`s. - Force `exists=False` and refuse `exists=True` or `resolve_path=True`: the former would bring back click's own check, which rejects broken links, and the latter would replace an annexed link with its `.git/annex/objects/...` target. - Match `click.Path.convert` more closely: skip the check for `-` when `allow_dash` applies, and build the message with gettext and `click.utils.format_filename()` as click does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr --- dandi/cli/base.py | 41 ++++++++++++++++++++++++++++++++++++ dandi/cli/cmd_validate.py | 33 +++++++---------------------- dandi/cli/tests/test_base.py | 34 +++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 26 deletions(-) diff --git a/dandi/cli/base.py b/dandi/cli/base.py index 7aa3059c6..b1c152264 100644 --- a/dandi/cli/base.py +++ b/dandi/cli/base.py @@ -1,8 +1,11 @@ from enum import Enum from functools import wraps +from gettext import gettext as _ import os +from typing import Any import click +from click.utils import format_filename from .. import get_logger @@ -70,6 +73,44 @@ def get_metavar(self, param, ctx=None): return "[" + ",".join(self.values) + ",all]" +class PathOrBrokenSymlink(click.Path): + """ + Like ``click.Path(exists=True)``, but also accepting broken symbolic links, + such as the annexed files of a DataLad dataset whose content has not been + fetched. ``click.Path(exists=True)`` follows the link and rejects those as + nonexistent, which prevented ``dandi validate``'s ``--missing-file-content`` + policies (``error``, ``skip``, ``only-non-data``) from ever being applied to + a file given directly on the command line rather than via its directory. + + The existence check is done here with ``os.path.lexists()``, so ``exists`` + cannot be enabled (``click.Path``'s own check would reject broken links + again), nor can ``resolve_path`` (it would replace an annexed link with its + ``.git/annex/objects/...`` target). + """ + + def __init__(self, **kwargs: Any) -> None: + if kwargs.pop("exists", False) or kwargs.get("resolve_path"): + raise ValueError( + "PathOrBrokenSymlink does its own existence check and supports" + " neither exists=True nor resolve_path=True" + ) + super().__init__(exists=False, **kwargs) + + def convert( + self, value: Any, param: click.Parameter | None, ctx: click.Context | None + ) -> Any: + is_dash = self.file_okay and self.allow_dash and value in ("-", b"-") + if not is_dash and not os.path.lexists(value): + self.fail( + _("{name} {filename!r} does not exist.").format( + name=self.name.title(), filename=format_filename(value) + ), + param, + ctx, + ) + return super().convert(value, param, ctx) + + # ???: could make them always available but hidden # via hidden=True. def devel_option(*args, **kwargs): diff --git a/dandi/cli/cmd_validate.py b/dandi/cli/cmd_validate.py index 78a86bb6a..6119096a7 100644 --- a/dandi/cli/cmd_validate.py +++ b/dandi/cli/cmd_validate.py @@ -7,12 +7,17 @@ import os import re import sys -from typing import IO, Any, Union, cast +from typing import IO, Union, cast import warnings import click -from .base import devel_debug_option, devel_option, map_to_click_exceptions +from .base import ( + PathOrBrokenSymlink, + devel_debug_option, + devel_option, + map_to_click_exceptions, +) from .formatter import JSONFormatter, JSONLinesFormatter, TextFormatter, YAMLFormatter from ..utils import pluralize from ..validate._core import validate as validate_ @@ -35,28 +40,6 @@ class TruncationNotice: """Number of validation results omitted from this group.""" -class ExistingPath(click.Path): - """ - Like ``click.Path(exists=True)``, but also accepting broken symbolic links, - such as the annexed files of a DataLad dataset whose content has not been - fetched. ``click.Path(exists=True)`` follows the link and rejects those as - nonexistent, which prevented the ``--missing-file-content`` policies - (``error``, ``skip``, ``only-non-data``) from ever being applied to a file - given directly on the command line rather than via its directory. - """ - - def convert( - self, value: Any, param: click.Parameter | None, ctx: click.Context | None - ) -> Any: - if not os.path.lexists(value): - self.fail( - f"{self.name.title()} {os.fsdecode(value)!r} does not exist.", - param, - ctx, - ) - return super().convert(value, param, ctx) - - STRUCTURED_FORMATS = ("json", "json_pp", "json_lines", "yaml") _EXT_TO_FORMAT = { @@ -250,7 +233,7 @@ def validate_bids( multiple=True, default=(), ) -@click.argument("paths", nargs=-1, type=ExistingPath(dir_okay=True)) +@click.argument("paths", nargs=-1, type=PathOrBrokenSymlink(dir_okay=True)) @click.pass_context @devel_debug_option() @map_to_click_exceptions diff --git a/dandi/cli/tests/test_base.py b/dandi/cli/tests/test_base.py index 9732af10f..c05ed8a91 100644 --- a/dandi/cli/tests/test_base.py +++ b/dandi/cli/tests/test_base.py @@ -1,10 +1,12 @@ from enum import StrEnum +import os +from pathlib import Path import click from click.testing import CliRunner import pytest -from ..base import EnumChoice +from ..base import EnumChoice, PathOrBrokenSymlink class _Existing(StrEnum): @@ -69,3 +71,33 @@ def cmd(existing): r = CliRunner().invoke(cmd, []) assert r.exit_code == 0, r.output assert captured["existing"] is _Existing.SKIP + + +@pytest.mark.ai_generated +@pytest.mark.parametrize("kwargs", [{"exists": True}, {"resolve_path": True}]) +def test_path_or_broken_symlink_rejects_options(kwargs): + with pytest.raises(ValueError, match="own existence check"): + PathOrBrokenSymlink(**kwargs) + + +@pytest.mark.ai_generated +def test_path_or_broken_symlink(tmp_path: Path) -> None: + @click.command() + @click.argument("path", type=PathOrBrokenSymlink(allow_dash=True)) + def cmd(path): + click.echo(f"got:{path}") + + (tmp_path / "file.txt").write_text("content") + broken = tmp_path / "broken" + try: + os.symlink(tmp_path / "missing", broken) + except OSError: + pytest.skip("symlinks are not supported here") + for ok in [str(tmp_path / "file.txt"), str(broken), "-"]: + r = CliRunner().invoke(cmd, [ok]) + assert r.exit_code == 0, r.output + assert r.output == f"got:{ok}\n" + missing = str(tmp_path / "missing") + r = CliRunner().invoke(cmd, [missing]) + assert r.exit_code == 2 + assert f"Path {missing!r} does not exist." in r.output From 56bb1a592f7c5882e8b54b13958031aea572ad77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 22:01:39 +0000 Subject: [PATCH 3/9] Replace PathOrBrokenSymlink with a click.Path accepting a lexists flag Per review: rather than a special-purpose type whose name raises "what about a symlink that is not broken?", provide `dandi.cli.base.Path`, a `click.Path` taking a `lexists` flag next to `exists`. With `lexists=True` a path must satisfy `os.path.lexists()`, so it may be a symlink whose target is missing, which `exists=True` (following the link) rejects. `dandi validate` needs that so its `--missing-file-content` policies (error, skip, only-non-data) also apply to an annexed file whose content was not fetched when that file is given directly on the command line, not only when reached via its directory. `lexists=True` refuses `resolve_path=True`, which would replace such a link with its `.git/annex/objects/...` target; `exists=True` keeps its click meaning. The docstring now describes only the behavior, leaving the rationale to this message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr --- dandi/cli/base.py | 34 ++++++++++++++-------------------- dandi/cli/cmd_validate.py | 9 ++------- dandi/cli/tests/test_base.py | 32 ++++++++++++++++++++------------ 3 files changed, 36 insertions(+), 39 deletions(-) diff --git a/dandi/cli/base.py b/dandi/cli/base.py index b1c152264..e0ee907a7 100644 --- a/dandi/cli/base.py +++ b/dandi/cli/base.py @@ -73,34 +73,28 @@ def get_metavar(self, param, ctx=None): return "[" + ",".join(self.values) + ",all]" -class PathOrBrokenSymlink(click.Path): +class Path(click.Path): """ - Like ``click.Path(exists=True)``, but also accepting broken symbolic links, - such as the annexed files of a DataLad dataset whose content has not been - fetched. ``click.Path(exists=True)`` follows the link and rejects those as - nonexistent, which prevented ``dandi validate``'s ``--missing-file-content`` - policies (``error``, ``skip``, ``only-non-data``) from ever being applied to - a file given directly on the command line rather than via its directory. - - The existence check is done here with ``os.path.lexists()``, so ``exists`` - cannot be enabled (``click.Path``'s own check would reject broken links - again), nor can ``resolve_path`` (it would replace an annexed link with its - ``.git/annex/objects/...`` target). + ``click.Path`` with an additional ``lexists`` flag + + With ``lexists=True``, the path must satisfy `os.path.lexists`: it must + exist or be a symbolic link, possibly a broken one. ``exists=True`` keeps + its ``click.Path`` meaning, requiring the target of a link to exist too. + ``lexists=True`` cannot be combined with ``resolve_path=True``, which would + replace a link with its target. """ - def __init__(self, **kwargs: Any) -> None: - if kwargs.pop("exists", False) or kwargs.get("resolve_path"): - raise ValueError( - "PathOrBrokenSymlink does its own existence check and supports" - " neither exists=True nor resolve_path=True" - ) - super().__init__(exists=False, **kwargs) + def __init__(self, *, lexists: bool = False, **kwargs: Any) -> None: + if lexists and kwargs.get("resolve_path"): + raise ValueError("lexists=True cannot be combined with resolve_path=True") + super().__init__(**kwargs) + self.lexists = lexists def convert( self, value: Any, param: click.Parameter | None, ctx: click.Context | None ) -> Any: is_dash = self.file_okay and self.allow_dash and value in ("-", b"-") - if not is_dash and not os.path.lexists(value): + if self.lexists and not is_dash and not os.path.lexists(value): self.fail( _("{name} {filename!r} does not exist.").format( name=self.name.title(), filename=format_filename(value) diff --git a/dandi/cli/cmd_validate.py b/dandi/cli/cmd_validate.py index 6119096a7..fce5a625d 100644 --- a/dandi/cli/cmd_validate.py +++ b/dandi/cli/cmd_validate.py @@ -12,12 +12,7 @@ import click -from .base import ( - PathOrBrokenSymlink, - devel_debug_option, - devel_option, - map_to_click_exceptions, -) +from .base import Path, devel_debug_option, devel_option, map_to_click_exceptions from .formatter import JSONFormatter, JSONLinesFormatter, TextFormatter, YAMLFormatter from ..utils import pluralize from ..validate._core import validate as validate_ @@ -233,7 +228,7 @@ def validate_bids( multiple=True, default=(), ) -@click.argument("paths", nargs=-1, type=PathOrBrokenSymlink(dir_okay=True)) +@click.argument("paths", nargs=-1, type=Path(lexists=True, dir_okay=True)) @click.pass_context @devel_debug_option() @map_to_click_exceptions diff --git a/dandi/cli/tests/test_base.py b/dandi/cli/tests/test_base.py index c05ed8a91..62bb7a23d 100644 --- a/dandi/cli/tests/test_base.py +++ b/dandi/cli/tests/test_base.py @@ -6,7 +6,8 @@ from click.testing import CliRunner import pytest -from ..base import EnumChoice, PathOrBrokenSymlink +from ..base import EnumChoice +from ..base import Path as CliPath class _Existing(StrEnum): @@ -74,16 +75,23 @@ def cmd(existing): @pytest.mark.ai_generated -@pytest.mark.parametrize("kwargs", [{"exists": True}, {"resolve_path": True}]) -def test_path_or_broken_symlink_rejects_options(kwargs): - with pytest.raises(ValueError, match="own existence check"): - PathOrBrokenSymlink(**kwargs) +def test_path_lexists_rejects_resolve_path(): + with pytest.raises(ValueError, match="resolve_path"): + CliPath(lexists=True, resolve_path=True) @pytest.mark.ai_generated -def test_path_or_broken_symlink(tmp_path: Path) -> None: +@pytest.mark.parametrize( + "kwargs,broken_ok", + [ + ({"lexists": True}, True), + ({"lexists": True, "exists": True}, False), + ({"exists": True}, False), + ], +) +def test_path_lexists(tmp_path: Path, kwargs: dict, broken_ok: bool) -> None: @click.command() - @click.argument("path", type=PathOrBrokenSymlink(allow_dash=True)) + @click.argument("path", type=CliPath(allow_dash=True, **kwargs)) def cmd(path): click.echo(f"got:{path}") @@ -93,11 +101,11 @@ def cmd(path): os.symlink(tmp_path / "missing", broken) except OSError: pytest.skip("symlinks are not supported here") - for ok in [str(tmp_path / "file.txt"), str(broken), "-"]: + for ok in [str(tmp_path / "file.txt"), "-"] + ([str(broken)] if broken_ok else []): r = CliRunner().invoke(cmd, [ok]) assert r.exit_code == 0, r.output assert r.output == f"got:{ok}\n" - missing = str(tmp_path / "missing") - r = CliRunner().invoke(cmd, [missing]) - assert r.exit_code == 2 - assert f"Path {missing!r} does not exist." in r.output + for bad in [str(tmp_path / "missing")] + ([] if broken_ok else [str(broken)]): + r = CliRunner().invoke(cmd, [bad]) + assert r.exit_code == 2 + assert f"Path {bad!r} does not exist." in r.output From 5570a56218dd5e2ba5a4e5e07f9a67e1e780fd51 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 00:31:17 +0000 Subject: [PATCH 4/9] Deny Claude posting comments/reviews on GitHub PRs and issues Add project-level .claude/settings.json denying the GitHub MCP tools that post, edit, or resolve comments and reviews on pull requests and issues. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr --- .claude/settings.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..724c2392d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "deny": [ + "mcp__github__add_issue_comment", + "mcp__github__update_issue_comment", + "mcp__github__add_reply_to_pull_request_comment", + "mcp__github__add_comment_to_pending_review", + "mcp__github__pull_request_review_write", + "mcp__github__resolve_review_thread", + "mcp__github__unresolve_review_thread" + ] + } +} From c269f6e16a877d5d6210c05dfc957bbfb2d22900 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 00:34:47 +0000 Subject: [PATCH 5/9] Simplify docstring of cli.base.Path Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr --- dandi/cli/base.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/dandi/cli/base.py b/dandi/cli/base.py index e0ee907a7..259c85678 100644 --- a/dandi/cli/base.py +++ b/dandi/cli/base.py @@ -75,13 +75,12 @@ def get_metavar(self, param, ctx=None): class Path(click.Path): """ - ``click.Path`` with an additional ``lexists`` flag + A ``click.Path`` that can also accept broken symlinks - With ``lexists=True``, the path must satisfy `os.path.lexists`: it must - exist or be a symbolic link, possibly a broken one. ``exists=True`` keeps - its ``click.Path`` meaning, requiring the target of a link to exist too. - ``lexists=True`` cannot be combined with ``resolve_path=True``, which would - replace a link with its target. + With ``exists=True``, a symlink whose target is missing is rejected as if + it did not exist. Use ``lexists=True`` instead to accept it, so that, for + example, ``dandi validate`` can report on it. This cannot be combined with + ``resolve_path=True``, which would follow the link. """ def __init__(self, *, lexists: bool = False, **kwargs: Any) -> None: From 1f56b4f13b087dcfcd12f548b9a12119dd2f131a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 00:35:31 +0000 Subject: [PATCH 6/9] Rename cli.base.Path to LinkAwarePath and explain its purpose Path clashed with pathlib.Path; the docstring now explains why a symlink-aware path type is needed rather than how it works. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr --- dandi/cli/base.py | 17 +++++++++++------ dandi/cli/cmd_validate.py | 9 +++++++-- dandi/cli/tests/test_base.py | 7 +++---- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/dandi/cli/base.py b/dandi/cli/base.py index 259c85678..9fddf98d3 100644 --- a/dandi/cli/base.py +++ b/dandi/cli/base.py @@ -73,14 +73,19 @@ def get_metavar(self, param, ctx=None): return "[" + ",".join(self.values) + ",all]" -class Path(click.Path): +class LinkAwarePath(click.Path): """ - A ``click.Path`` that can also accept broken symlinks + A ``click.Path`` that knows a symlink can be there even if its target is not - With ``exists=True``, a symlink whose target is missing is rejected as if - it did not exist. Use ``lexists=True`` instead to accept it, so that, for - example, ``dandi validate`` can report on it. This cannot be combined with - ``resolve_path=True``, which would follow the link. + ``click.Path(exists=True)`` follows symlinks, so it turns away a broken one + as "does not exist" before the command ever sees it. In a git-annex or + DataLad dataset, that is common: a file whose content has not been fetched + is exactly such a link. Commands like ``dandi validate`` should still take + these paths, to report on them (or skip them) rather than refuse them. + + With ``lexists=True``, the link itself only has to be there, whether or not + its target is. It cannot be combined with ``resolve_path=True``, which + would swap the link for its missing target. """ def __init__(self, *, lexists: bool = False, **kwargs: Any) -> None: diff --git a/dandi/cli/cmd_validate.py b/dandi/cli/cmd_validate.py index 47d43204f..5241c5eb4 100644 --- a/dandi/cli/cmd_validate.py +++ b/dandi/cli/cmd_validate.py @@ -11,7 +11,12 @@ import click -from .base import Path, devel_debug_option, devel_option, map_to_click_exceptions +from .base import ( + LinkAwarePath, + devel_debug_option, + devel_option, + map_to_click_exceptions, +) from .formatter import JSONFormatter, JSONLinesFormatter, TextFormatter, YAMLFormatter from ..utils import pluralize from ..validate._core import validate as validate_ @@ -176,7 +181,7 @@ def _filter_results( multiple=True, default=(), ) -@click.argument("paths", nargs=-1, type=Path(lexists=True, dir_okay=True)) +@click.argument("paths", nargs=-1, type=LinkAwarePath(lexists=True, dir_okay=True)) @click.pass_context @devel_debug_option() @map_to_click_exceptions diff --git a/dandi/cli/tests/test_base.py b/dandi/cli/tests/test_base.py index 62bb7a23d..73929441d 100644 --- a/dandi/cli/tests/test_base.py +++ b/dandi/cli/tests/test_base.py @@ -6,8 +6,7 @@ from click.testing import CliRunner import pytest -from ..base import EnumChoice -from ..base import Path as CliPath +from ..base import EnumChoice, LinkAwarePath class _Existing(StrEnum): @@ -77,7 +76,7 @@ def cmd(existing): @pytest.mark.ai_generated def test_path_lexists_rejects_resolve_path(): with pytest.raises(ValueError, match="resolve_path"): - CliPath(lexists=True, resolve_path=True) + LinkAwarePath(lexists=True, resolve_path=True) @pytest.mark.ai_generated @@ -91,7 +90,7 @@ def test_path_lexists_rejects_resolve_path(): ) def test_path_lexists(tmp_path: Path, kwargs: dict, broken_ok: bool) -> None: @click.command() - @click.argument("path", type=CliPath(allow_dash=True, **kwargs)) + @click.argument("path", type=LinkAwarePath(allow_dash=True, **kwargs)) def cmd(path): click.echo(f"got:{path}") From af4e20694c97dec0ce1ef78b4bb97f91b59f8a5b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 00:36:48 +0000 Subject: [PATCH 7/9] Focus LinkAwarePath docstring on its purpose; document lexists on __init__ Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr --- dandi/cli/base.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/dandi/cli/base.py b/dandi/cli/base.py index 9fddf98d3..fea37995c 100644 --- a/dandi/cli/base.py +++ b/dandi/cli/base.py @@ -75,20 +75,24 @@ def get_metavar(self, param, ctx=None): class LinkAwarePath(click.Path): """ - A ``click.Path`` that knows a symlink can be there even if its target is not + A ``click.Path`` for commands that should accept broken symlinks - ``click.Path(exists=True)`` follows symlinks, so it turns away a broken one - as "does not exist" before the command ever sees it. In a git-annex or - DataLad dataset, that is common: a file whose content has not been fetched - is exactly such a link. Commands like ``dandi validate`` should still take - these paths, to report on them (or skip them) rather than refuse them. - - With ``lexists=True``, the link itself only has to be there, whether or not - its target is. It cannot be combined with ``resolve_path=True``, which - would swap the link for its missing target. + The typical broken symlink is a git-annex (or DataLad) file whose content + has not been fetched. A command like ``dandi validate`` should still take + such a path, to report on it or skip it, rather than refuse it as missing. """ def __init__(self, *, lexists: bool = False, **kwargs: Any) -> None: + """ + Parameters + ---------- + lexists + If True, the path must exist, but a symlink to a missing target + counts as existing (unlike with ``exists=True``). Cannot be + combined with ``resolve_path=True``. + **kwargs + Passed to ``click.Path``. + """ if lexists and kwargs.get("resolve_path"): raise ValueError("lexists=True cannot be combined with resolve_path=True") super().__init__(**kwargs) From 7a3237b0d64cc693d8cb1a034429ebf94e7d5855 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 00:37:09 +0000 Subject: [PATCH 8/9] Use a plain f-string for the LinkAwarePath error message Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr --- dandi/cli/base.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/dandi/cli/base.py b/dandi/cli/base.py index fea37995c..5b74b687b 100644 --- a/dandi/cli/base.py +++ b/dandi/cli/base.py @@ -1,6 +1,5 @@ from enum import Enum from functools import wraps -from gettext import gettext as _ import os from typing import Any @@ -104,9 +103,7 @@ def convert( is_dash = self.file_okay and self.allow_dash and value in ("-", b"-") if self.lexists and not is_dash and not os.path.lexists(value): self.fail( - _("{name} {filename!r} does not exist.").format( - name=self.name.title(), filename=format_filename(value) - ), + f"{self.name.title()} {format_filename(value)!r} does not exist.", param, ctx, ) From bc533a605157978c41306fc37ec666899c95151d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 03:35:44 +0000 Subject: [PATCH 9/9] Remove project-level Claude settings Keep that configuration personal instead, as in con/fscacher#113. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr --- .claude/settings.json | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 724c2392d..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "permissions": { - "deny": [ - "mcp__github__add_issue_comment", - "mcp__github__update_issue_comment", - "mcp__github__add_reply_to_pull_request_comment", - "mcp__github__add_comment_to_pending_review", - "mcp__github__pull_request_review_write", - "mcp__github__resolve_review_thread", - "mcp__github__unresolve_review_thread" - ] - } -}