Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/commands/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ DeviantArt's own error body, truncated:
```console
$ da refresh
[warn] refreshing access token via refresh_token
[error] refresh failed: HTTP 401 {"error":"invalid_client","error_description":"Client authentication failed.","status":"error"}
[error] DeviantArt rejected the refresh token (HTTP 401): {"error":"invalid_client","error_description":"Client authentication failed.","status":"error"}. Run `da auth` to sign in again.
```

`invalid_client` there means the `client_id` or `client_secret` is wrong;
Expand Down
4 changes: 2 additions & 2 deletions docs/commands/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ $ da search tag-suggest zzz --json | head -12
## `da search user`

```text
usage: da search user [-h] query [query ...]
usage: da search user [-h] [--json] query [query ...]
```

Resolves one or more usernames to DeviantArt user records. Use it to
Expand Down Expand Up @@ -429,7 +429,7 @@ da search user spyed devart
## `da daily`

```text
usage: da daily [-h] [--mature] [date]
usage: da daily [-h] [--mature] [--json] [date]
```

Prints DeviantArt's Daily Deviation picks — the staff-selected
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ for the stop reason rather than trusting the exit code.
## da sync watched

```text
usage: da sync watched [-h] [--user USER] [--via-feed] [--feed-max FEED_MAX]
usage: da sync watched [-h] [--user USER | --via-feed] [--feed-max FEED_MAX]
[--mature | --no-mature] [--time-budget SECONDS]
[--delay-api DELAY_API] [--delay-image DELAY_IMAGE]
[--full] [--jitter JITTER] [--concurrency CONCURRENCY]
Expand Down
7 changes: 5 additions & 2 deletions docs/explanation/adr/0007-package-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ Accepted (2026-07-28). Binding.

da-cli spans several distinct concerns — paths, logging, config,
secrets, a SQLite index, an HTTP client, OAuth 2.1, a concurrent sync
engine, thirteen command handlers, and the argument parser. Held in one
engine, twenty-nine command handlers across thirteen top-level
subcommands, and the argument parser. Held in one
namespace they were mutually reachable, so nothing recorded which parts
were meant to depend on which, and any documentation of the internal
structure had to be maintained by hand against line numbers.
Expand Down Expand Up @@ -70,7 +71,9 @@ touches it, so it stays a plain module global.

### Positive

- Each concern is readable on its own; the largest module is 848 lines.
- Each concern is readable on its own. The largest is `sync.py` at ~1,200
lines, which is the walk plus its checkpointing and is not usefully
divisible; the rest sit well under that.
- Dependencies between concerns are explicit imports rather than
shared-namespace assumptions.
- `ARCHITECTURE.md` describes modules, so it cannot drift the way a
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ Output looks like:
[0s] feed offset=0
+ ArtistName/Sample Title 245 KB
+ AnotherArtist/Cool Art 1.2 MB
feed sync stopped: complete; ok=2 dup=0 noimg=0 fail=0
feed sync stopped: feed exhausted; ok=2 dup=0 noimg=0 fail=0
```

Check what was downloaded:
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ Failures print what went wrong and exit `2`:

```console
$ da whoami
[error] HTTP 503 from https://www.deviantart.com/api/v1/oauth2/user/whoami: Service Unavailable
[error] re-run with -v for the full traceback
[error] DeviantArt is having trouble (HTTP 503 Service Unavailable).
This is usually temporary and on their end. The next run resumes where this one stopped; nothing has been lost.
```

The short form keeps the common case readable and keeps the exit code
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ ignore = [
# `X as X` alias — the form ruff recognises as a re-export. A blanket
# suppression hid 28 imports that had stopped being anything at all.
"dacli/__init__.py" = ["SLF001"]
# argparse exposes no public accessor for its subparser tree, so both
# doc tools walk `_actions` / `_SubParsersAction` to reach it.
"tools/check_doc_flags.py" = ["SLF001"]
"tools/gen_cli_docs.py" = ["SLF001"]
"tests/**" = [
"D", # docstrings optional in tests
"ANN", # annotations optional in tests
Expand Down
85 changes: 81 additions & 4 deletions tools/check_doc_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ def parser_surface() -> tuple[dict[str, dict[str, object]], set[str]]:

def walk(parser: argparse.ArgumentParser, prefix: str) -> None:
opts: dict[str, object] = {}
for action in parser._actions: # noqa: SLF001 — argparse exposes no public API
if isinstance(action, argparse._SubParsersAction): # noqa: SLF001
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
for name, sub in action.choices.items():
walk(sub, f"{prefix} {name}")
for opt in action.option_strings:
Expand Down Expand Up @@ -83,6 +83,77 @@ def _defaults_agree(claimed: str, actual: object) -> bool:
return str(actual).lower() in c


def check_usage_blocks(problems: list[str]) -> int:
"""Every `usage: da ...` block in the docs must match the parser.

The flag-table check above catches a flag that is documented wrongly or
not at all. It does not look at the `usage:` line above the table, and
that line drifts independently — two were wrong when this was added:
`da daily` had gained `--json`, and `da sync watched` had gained a
mutually-exclusive group, so the docs showed
`[--user USER] [--via-feed]` where argparse prints
`[--user USER | --via-feed]`. Both are exactly the kind of detail a
reader copies verbatim.

Compared on the first line only, and whitespace-normalised: argparse
wraps to terminal width, so the continuation lines are cosmetic.
"""
checked = 0
for doc in sorted((REPO / "docs").rglob("*.md")):
# cli.md is generated by tools/gen_cli_docs.py from this same parser
# and is verified by a git-diff check in CI; re-checking it here
# would just duplicate that.
if doc.name == "cli.md":
continue
rel = doc.relative_to(REPO)
for lineno, line in enumerate(doc.read_text().splitlines(), 1):
if not line.startswith("usage: da "):
continue
checked += 1
parts = line[len("usage: ") :].split()
path: list[str] = []
for tok in parts[1:]:
if tok.startswith(("[", "-", "{")):
break
path.append(tok)
sub = _resolve(path)
if sub is None:
problems.append(
f"{rel}:{lineno}: `usage: da {' '.join(path)}` names no such command"
)
continue
real = sub.format_usage().strip().splitlines()[0]
if _norm(real) != _norm(line):
problems.append(
f"{rel}:{lineno}: usage line drifted\n doc: {line}\n real: {real}"
)
return checked


def _norm(s: str) -> str:
return " ".join(s.split())


def _resolve(path: list[str]) -> argparse.ArgumentParser | None:
"""Walk the subparser tree to the parser for `da <path...>`.

Reaching into `_actions` / `_SubParsersAction` is the only way argparse
exposes its subparser tree; there is no public accessor. The rest of
this file and tools/gen_cli_docs.py do the same for the same reason.
"""
cur = dacli.build_parser()
for name in path:
nxt = None
for a in cur._actions:
if isinstance(a, argparse._SubParsersAction) and name in a.choices:
nxt = a.choices[name]
break
if nxt is None:
return None
cur = nxt
return cur


def main() -> int:
surface, known = parser_surface()
problems: list[str] = []
Expand Down Expand Up @@ -162,12 +233,18 @@ def main() -> int:
if missing:
problems.append(f"{rel}: `{command}` table omits {sorted(missing)}")

usage_checked = check_usage_blocks(problems)

for p in problems:
print(p)

if problems:
print(f"\n{len(problems)} flag-table problem(s).", file=sys.stderr)
print(f"\n{len(problems)} documentation problem(s).", file=sys.stderr)
return 1
print(f"{rows_checked} flag rows across {tables_checked} tables match the parser")
print(
f"{rows_checked} flag rows across {tables_checked} tables and "
f"{usage_checked} usage lines match the parser"
)
return 0


Expand Down
4 changes: 2 additions & 2 deletions tools/gen_cli_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ def _subparsers(parser: argparse.ArgumentParser) -> dict[str, argparse.ArgumentP
since Python 3.2 and the generator is dev-only — a break shows up
as a failed `make docs`, never as a runtime error for a user.
"""
for action in parser._actions: # noqa: SLF001
if isinstance(action, argparse._SubParsersAction): # noqa: SLF001
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
return dict(action.choices)
return {}

Expand Down
Loading