Skip to content

Commit a2c8cd4

Browse files
authored
Merge pull request #1 from FZ2000/fix/remaining-doc-drift
docs: fix the last drifted claims, and make usage-line drift impossible
2 parents da1c0d4 + 25dc70d commit a2c8cd4

9 files changed

Lines changed: 99 additions & 15 deletions

File tree

docs/commands/auth.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ DeviantArt's own error body, truncated:
512512
```console
513513
$ da refresh
514514
[warn] refreshing access token via refresh_token
515-
[error] refresh failed: HTTP 401 {"error":"invalid_client","error_description":"Client authentication failed.","status":"error"}
515+
[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.
516516
```
517517

518518
`invalid_client` there means the `client_id` or `client_secret` is wrong;

docs/commands/search.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ $ da search tag-suggest zzz --json | head -12
389389
## `da search user`
390390

391391
```text
392-
usage: da search user [-h] query [query ...]
392+
usage: da search user [-h] [--json] query [query ...]
393393
```
394394

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

431431
```text
432-
usage: da daily [-h] [--mature] [date]
432+
usage: da daily [-h] [--mature] [--json] [date]
433433
```
434434

435435
Prints DeviantArt's Daily Deviation picks — the staff-selected

docs/commands/sync.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ for the stop reason rather than trusting the exit code.
387387
## da sync watched
388388

389389
```text
390-
usage: da sync watched [-h] [--user USER] [--via-feed] [--feed-max FEED_MAX]
390+
usage: da sync watched [-h] [--user USER | --via-feed] [--feed-max FEED_MAX]
391391
[--mature | --no-mature] [--time-budget SECONDS]
392392
[--delay-api DELAY_API] [--delay-image DELAY_IMAGE]
393393
[--full] [--jitter JITTER] [--concurrency CONCURRENCY]

docs/explanation/adr/0007-package-layout.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ Accepted (2026-07-28). Binding.
88

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

7172
### Positive
7273

73-
- Each concern is readable on its own; the largest module is 848 lines.
74+
- Each concern is readable on its own. The largest is `sync.py` at ~1,200
75+
lines, which is the walk plus its checkpointing and is not usefully
76+
divisible; the rest sit well under that.
7477
- Dependencies between concerns are explicit imports rather than
7578
shared-namespace assumptions.
7679
- `ARCHITECTURE.md` describes modules, so it cannot drift the way a

docs/getting-started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ Output looks like:
308308
[0s] feed offset=0
309309
+ ArtistName/Sample Title 245 KB
310310
+ AnotherArtist/Cool Art 1.2 MB
311-
feed sync stopped: complete; ok=2 dup=0 noimg=0 fail=0
311+
feed sync stopped: feed exhausted; ok=2 dup=0 noimg=0 fail=0
312312
```
313313

314314
Check what was downloaded:

docs/guides/troubleshooting.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ Failures print what went wrong and exit `2`:
3838

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

4545
The short form keeps the common case readable and keeps the exit code

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@ ignore = [
189189
# `X as X` alias — the form ruff recognises as a re-export. A blanket
190190
# suppression hid 28 imports that had stopped being anything at all.
191191
"dacli/__init__.py" = ["SLF001"]
192+
# argparse exposes no public accessor for its subparser tree, so both
193+
# doc tools walk `_actions` / `_SubParsersAction` to reach it.
194+
"tools/check_doc_flags.py" = ["SLF001"]
195+
"tools/gen_cli_docs.py" = ["SLF001"]
192196
"tests/**" = [
193197
"D", # docstrings optional in tests
194198
"ANN", # annotations optional in tests

tools/check_doc_flags.py

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ def parser_surface() -> tuple[dict[str, dict[str, object]], set[str]]:
4949

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

8585

86+
def check_usage_blocks(problems: list[str]) -> int:
87+
"""Every `usage: da ...` block in the docs must match the parser.
88+
89+
The flag-table check above catches a flag that is documented wrongly or
90+
not at all. It does not look at the `usage:` line above the table, and
91+
that line drifts independently — two were wrong when this was added:
92+
`da daily` had gained `--json`, and `da sync watched` had gained a
93+
mutually-exclusive group, so the docs showed
94+
`[--user USER] [--via-feed]` where argparse prints
95+
`[--user USER | --via-feed]`. Both are exactly the kind of detail a
96+
reader copies verbatim.
97+
98+
Compared on the first line only, and whitespace-normalised: argparse
99+
wraps to terminal width, so the continuation lines are cosmetic.
100+
"""
101+
checked = 0
102+
for doc in sorted((REPO / "docs").rglob("*.md")):
103+
# cli.md is generated by tools/gen_cli_docs.py from this same parser
104+
# and is verified by a git-diff check in CI; re-checking it here
105+
# would just duplicate that.
106+
if doc.name == "cli.md":
107+
continue
108+
rel = doc.relative_to(REPO)
109+
for lineno, line in enumerate(doc.read_text().splitlines(), 1):
110+
if not line.startswith("usage: da "):
111+
continue
112+
checked += 1
113+
parts = line[len("usage: ") :].split()
114+
path: list[str] = []
115+
for tok in parts[1:]:
116+
if tok.startswith(("[", "-", "{")):
117+
break
118+
path.append(tok)
119+
sub = _resolve(path)
120+
if sub is None:
121+
problems.append(
122+
f"{rel}:{lineno}: `usage: da {' '.join(path)}` names no such command"
123+
)
124+
continue
125+
real = sub.format_usage().strip().splitlines()[0]
126+
if _norm(real) != _norm(line):
127+
problems.append(
128+
f"{rel}:{lineno}: usage line drifted\n doc: {line}\n real: {real}"
129+
)
130+
return checked
131+
132+
133+
def _norm(s: str) -> str:
134+
return " ".join(s.split())
135+
136+
137+
def _resolve(path: list[str]) -> argparse.ArgumentParser | None:
138+
"""Walk the subparser tree to the parser for `da <path...>`.
139+
140+
Reaching into `_actions` / `_SubParsersAction` is the only way argparse
141+
exposes its subparser tree; there is no public accessor. The rest of
142+
this file and tools/gen_cli_docs.py do the same for the same reason.
143+
"""
144+
cur = dacli.build_parser()
145+
for name in path:
146+
nxt = None
147+
for a in cur._actions:
148+
if isinstance(a, argparse._SubParsersAction) and name in a.choices:
149+
nxt = a.choices[name]
150+
break
151+
if nxt is None:
152+
return None
153+
cur = nxt
154+
return cur
155+
156+
86157
def main() -> int:
87158
surface, known = parser_surface()
88159
problems: list[str] = []
@@ -162,12 +233,18 @@ def main() -> int:
162233
if missing:
163234
problems.append(f"{rel}: `{command}` table omits {sorted(missing)}")
164235

236+
usage_checked = check_usage_blocks(problems)
237+
165238
for p in problems:
166239
print(p)
240+
167241
if problems:
168-
print(f"\n{len(problems)} flag-table problem(s).", file=sys.stderr)
242+
print(f"\n{len(problems)} documentation problem(s).", file=sys.stderr)
169243
return 1
170-
print(f"{rows_checked} flag rows across {tables_checked} tables match the parser")
244+
print(
245+
f"{rows_checked} flag rows across {tables_checked} tables and "
246+
f"{usage_checked} usage lines match the parser"
247+
)
171248
return 0
172249

173250

tools/gen_cli_docs.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ def _subparsers(parser: argparse.ArgumentParser) -> dict[str, argparse.ArgumentP
106106
since Python 3.2 and the generator is dev-only — a break shows up
107107
as a failed `make docs`, never as a runtime error for a user.
108108
"""
109-
for action in parser._actions: # noqa: SLF001
110-
if isinstance(action, argparse._SubParsersAction): # noqa: SLF001
109+
for action in parser._actions:
110+
if isinstance(action, argparse._SubParsersAction):
111111
return dict(action.choices)
112112
return {}
113113

0 commit comments

Comments
 (0)