From 9b3451b66e9d070f3bd08547388c8f4d37558640 Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Fri, 25 Sep 2026 19:09:26 -0400 Subject: [PATCH 1/5] Added FEAT-20 --- .vscode/tasks.json | 13 +++++ .../FEAT-20_cliAccessorsForAllHeadmatter.md | 18 +++++++ roadmap.md | 47 ++----------------- 3 files changed, 36 insertions(+), 42 deletions(-) create mode 100644 .vscode/tasks.json create mode 100644 docs/tickets/todo/FEAT-20_cliAccessorsForAllHeadmatter.md diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..2d50cc0 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Build Roadmap and Diagram", + "type": "shell", + "command": "docket graph todo -o .\\graph.mmd; docket docs roadmap todo", + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/docs/tickets/todo/FEAT-20_cliAccessorsForAllHeadmatter.md b/docs/tickets/todo/FEAT-20_cliAccessorsForAllHeadmatter.md new file mode 100644 index 0000000..b402f90 --- /dev/null +++ b/docs/tickets/todo/FEAT-20_cliAccessorsForAllHeadmatter.md @@ -0,0 +1,18 @@ +--- +id: FEAT-20 +title: CLI Accessors for All Headmatter +status: todo +priority: 0 +requires: [] +metadata: {} +--- + +# CLI Accessors for All Headmatter + +Right now, only `status` (which gets `status` from the headmatter) and `meta` (which gets the `metadata` from the headmatter) exist to provide pipeable accessors to headmatter data for tickets. + +`show` exists, but prints the ticket information in a *human-centric* format which is not well suited for scripting. + +To resolve this, accessors should be added to `docket ...` in some manner to facilitate direct output of headmatter information in a pipeable way. + +A note should be made in instruction files to ensure that any new ticket headmatter also receives an accessor. diff --git a/roadmap.md b/roadmap.md index 9c76cee..ffc554b 100644 --- a/roadmap.md +++ b/roadmap.md @@ -1,66 +1,29 @@ -# Roadmap +# Roadmap: todo > Generated with `docket docs roadmap`. ```mermaid graph TD subgraph BUG - BUG_1("BUG-1
Fix Character Encoding
Issue in FEAT-5
p1 done") - BUG_2("BUG-2
Cannot Clear with Set
Command
p0 done") - BUG_3("BUG-3
Migrate Server to MCP
2.x MCPServer API
p0 done") - BUG_4("BUG-4
Add to Requires in CLI
p1 done") - BUG_5("BUG-5
Serialize Ticket Writes
Across Processes
p3 done") BUG_6["BUG-6
Key Removal Checks Usage
Outside the Lock
p4 todo"] end subgraph FEAT - FEAT_1("FEAT-1
Record Demo GIF with VHS
p2 done") - FEAT_2("FEAT-2
Set Up and Publish to
PyPI
p3 done") - FEAT_3("FEAT-3
All Tickets Graph
p2 done") - FEAT_4("FEAT-4
Shorthand for CLI
p2 done") - FEAT_5("FEAT-5
Selection Options in CLI
p2 done") FEAT_6["FEAT-6
Templates for Tickets
per Key
p3 todo"] - FEAT_7("FEAT-7
Unified Version Code
p1 done") FEAT_8["FEAT-8
Host Flag
p4 todo"] - FEAT_9("FEAT-9
Track Arbitrary
Additional Metadata on
Tickets/Groups
p1 done") - FEAT_10("FEAT-10
Tree Style CLI Commands
p2 done") - FEAT_11("FEAT-11
Repository Automation
and Contribution
Scaffolding
p3 done") FEAT_12["FEAT-12
Per Key Ticket Board
View
p2 todo"] - FEAT_13("FEAT-13
Check if Ticket Is Ready
for Work
p1 done") - FEAT_14["FEAT-14
Roadmap Graph Generation
p2 todo"] - FEAT_15("FEAT-15
Use Title Case for
Tickets
p1 done") FEAT_16["FEAT-16
Ticket Show Uses
Optional Formatting
p2 todo"] - FEAT_17("FEAT-17
Add Status to Graph
Scope
p1 done") - FEAT_18("FEAT-18
Offsite Ticket Authoring
Brief
p1 done") FEAT_19["FEAT-19
Add Google Style
Documentation Comments
p1 todo"] + FEAT_20["FEAT-20
CLI Accessors for All
Headmatter
p0 todo"] end - BUG_1 --> FEAT_2 - BUG_1 --> FEAT_5 - BUG_2 --> FEAT_2 - BUG_3 --> FEAT_2 - BUG_4 --> FEAT_2 - BUG_5 --> BUG_6 - BUG_5 --> FEAT_2 - FEAT_1 --> FEAT_2 - FEAT_2 --> FEAT_11 - FEAT_4 --> FEAT_2 - FEAT_5 --> FEAT_2 FEAT_6 --> FEAT_12 - FEAT_7 --> FEAT_2 - FEAT_10 --> FEAT_2 - classDef doneP0 fill:#2d6a4f,color:#fff,stroke:#ff6b6b,stroke-width:4px - classDef doneP1 fill:#2d6a4f,color:#fff,stroke:#ff922b,stroke-width:3px - classDef doneP2 fill:#2d6a4f,color:#fff,stroke:#ffd43b,stroke-width:2px - classDef doneP3 fill:#2d6a4f,color:#fff,stroke:#adb5bd,stroke-width:2px + classDef todoP0 fill:#495057,color:#fff,stroke:#ff6b6b,stroke-width:4px classDef todoP1 fill:#495057,color:#fff,stroke:#ff922b,stroke-width:3px classDef todoP2 fill:#495057,color:#fff,stroke:#ffd43b,stroke-width:2px classDef todoP3 fill:#495057,color:#fff,stroke:#adb5bd,stroke-width:2px classDef todoP4 fill:#495057,color:#fff,stroke:#6c757d,stroke-width:1px - class BUG_2,BUG_3 doneP0 - class BUG_1,BUG_4,FEAT_13,FEAT_15,FEAT_17,FEAT_18,FEAT_7,FEAT_9 doneP1 - class FEAT_1,FEAT_10,FEAT_3,FEAT_4,FEAT_5 doneP2 - class BUG_5,FEAT_11,FEAT_2 doneP3 + class FEAT_20 todoP0 class FEAT_19 todoP1 - class FEAT_12,FEAT_14,FEAT_16 todoP2 + class FEAT_12,FEAT_16 todoP2 class FEAT_6 todoP3 class BUG_6,FEAT_8 todoP4 ``` From a770069ede6eeab8af1dd7b301a0d3378816917c Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Fri, 25 Sep 2026 19:18:47 -0400 Subject: [PATCH 2/5] Docs conversion --- src/docket/cli/__init__.py | 16 +-- src/docket/cli/commands.py | 179 ++++++++++++++++++++-------------- src/docket/cli/grammar.py | 98 +++++++++++++------ src/docket/cli/output.py | 28 ++++-- src/docket/core/atomic.py | 5 +- src/docket/core/config.py | 90 ++++++++++++----- src/docket/core/deploy.py | 64 ++++++++---- src/docket/core/fields.py | 82 +++++++++------- src/docket/core/graph.py | 154 ++++++++++++++++++----------- src/docket/core/handoff.py | 18 ++-- src/docket/core/ids.py | 77 ++++++++++----- src/docket/core/inputs.py | 35 +++++-- src/docket/core/lock.py | 26 +++-- src/docket/core/mermaid.py | 68 ++++++++----- src/docket/core/resources.py | 8 +- src/docket/core/roadmap.py | 20 ++-- src/docket/core/store.py | 147 ++++++++++++++++++---------- src/docket/core/templating.py | 17 ++-- src/docket/core/ticket.py | 57 +++++++---- src/docket/core/titles.py | 18 ++-- src/docket/core/validate.py | 82 ++++++++++------ src/docket/server.py | 81 ++++++++------- 22 files changed, 890 insertions(+), 480 deletions(-) diff --git a/src/docket/cli/__init__.py b/src/docket/cli/__init__.py index d0041dd..7c1db9b 100644 --- a/src/docket/cli/__init__.py +++ b/src/docket/cli/__init__.py @@ -118,9 +118,11 @@ def main(argv: Optional[list[str]] = None) -> int: """ Entry point for the `docket` console script. - argv: Argument list to parse, defaulting to `sys.argv[1:]`. + Args: + argv: Argument list to parse, defaulting to `sys.argv[1:]`. - Returns the process exit code. + Returns: + The process exit code. """ # Read the configuration before the parser is built, so the help text can name the keys and priorities this repository actually allows. A missing one is not fatal here, since the commands that need it say so themselves. @@ -151,11 +153,13 @@ def dispatch(args: argparse.Namespace, config: Optional[Config], output: Output) """ Route parsed arguments to the command that handles them. - args: The parsed arguments. - config: The configuration already discovered for the help text, or `None` when none was found. - output: Where to write. + Args: + args: The parsed arguments. + config: The configuration already discovered for the help text, or `None` when none was found. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ # Deploy and upgrade run before a configuration exists, or in order to repair one, so they must not require discovering it first. diff --git a/src/docket/cli/commands.py b/src/docket/cli/commands.py index 7b0b6b1..20b0ab6 100644 --- a/src/docket/cli/commands.py +++ b/src/docket/cli/commands.py @@ -37,11 +37,13 @@ def commandTicket(args: argparse.Namespace, store: Store, output: Output) -> int A bare id shows the ticket, since showing it is what naming one almost always means. - args: The parsed arguments. - store: The store to read from or write through. - output: Where to write. + Args: + args: The parsed arguments. + store: The store to read from or write through. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ # A status word is not a command carrying a value, it is the whole instruction. @@ -64,11 +66,13 @@ def commandNew(args: argparse.Namespace, store: Store, output: Output) -> int: """ Create a ticket. - args: The parsed arguments. - store: The store to write through. - output: Where to write. + Args: + args: The parsed arguments. + store: The store to write through. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ result: TicketResult = store.create( @@ -94,11 +98,13 @@ def commandShow(args: argparse.Namespace, store: Store, output: Output) -> int: The raw file carries bare ids in one direction only, so this resolves the titles and statuses the file deliberately does not duplicate. Use `cat` for the raw file. - args: The parsed arguments. - store: The store to read from. - output: Where to write. + Args: + args: The parsed arguments. + store: The store to read from. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ loaded: TicketSet = store.loadAll() @@ -124,11 +130,13 @@ def commandList(args: argparse.Namespace, store: Store, output: Output) -> int: """ List ticket summaries. - args: The parsed arguments. - store: The store to read from. - output: Where to write. + Args: + args: The parsed arguments. + store: The store to read from. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ status, key, priorityMax = resolveListFilters(args.filters, args.status, args.key, args.priorityMax) @@ -166,11 +174,13 @@ def commandSet(args: argparse.Namespace, store: Store, output: Output) -> int: """ Change a ticket's title, priority, or dependencies. - args: The parsed arguments. - store: The store to write through. - output: Where to write. + Args: + args: The parsed arguments. + store: The store to write through. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ # Nothing to do is a usage error rather than a silent success, since the caller clearly meant to change something. @@ -199,11 +209,13 @@ def commandStatus(args: argparse.Namespace, store: Store, output: Output) -> int """ Change a ticket's status, moving its file in the same operation. - args: The parsed arguments. - store: The store to write through. - output: Where to write. + Args: + args: The parsed arguments. + store: The store to write through. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ # The command that was typed is the status, since each status is its own command rather than a value handed to a shared one. @@ -220,11 +232,13 @@ def commandStatusRead(args: argparse.Namespace, store: Store, output: Output) -> This goes out raw, with no styling and no surrounding words, so a shell can read the answer as easily as a person can. - args: The parsed arguments. - store: The store to read from. - output: Where to write. + Args: + args: The parsed arguments. + store: The store to read from. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ ticket: Ticket = store.load(args.id) @@ -240,11 +254,13 @@ def commandReady(args: argparse.Namespace, store: Store, output: Output) -> int: This goes out raw for the same reason `status` does. What is blocking is deliberately left to `show`, which already tables both dependency directions with their statuses. - args: The parsed arguments. - store: The store to read from. - output: Where to write. + Args: + args: The parsed arguments. + store: The store to read from. + output: Where to write. - Returns the process exit code, which reports whether the question could be answered rather than what the answer was. + Returns: + The process exit code, which reports whether the question could be answered rather than what the answer was. """ readiness: Readiness = ticketReadiness(store.loadAll(), args.id) @@ -260,11 +276,13 @@ def commandMeta(args: argparse.Namespace, store: Store, output: Output) -> int: How much of the call was typed is what it means. No key reads the whole map, a key alone reads that one entry, and a key with a value writes it, which is the same shape a status has. - args: The parsed arguments. - store: The store to read from or write through. - output: Where to write. + Args: + args: The parsed arguments. + store: The store to read from or write through. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ if args.key is None: @@ -321,11 +339,13 @@ def commandGraph(args: argparse.Namespace, store: Store, output: Output) -> int: """ Render the dependency graph as mermaid source. - args: The parsed arguments. - store: The store to read from. - output: Where to write. + Args: + args: The parsed arguments. + store: The store to read from. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ ticketId, key, status = resolveGraphScope(args.scope, args.id, args.key, args.status) @@ -343,11 +363,13 @@ def commandKey(args: argparse.Namespace, store: Store, output: Output) -> int: """ Inspect and manage the key registry. - args: The parsed arguments. - store: The store, which also reports which keys are in use. - output: Where to write. + Args: + args: The parsed arguments. + store: The store, which also reports which keys are in use. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ config: Config = store.config @@ -389,11 +411,13 @@ def commandValidate(args: argparse.Namespace, store: Store, output: Output) -> i """ Run every integrity rule. - args: The parsed arguments. - store: The store to validate. - output: Where to write. + Args: + args: The parsed arguments. + store: The store to validate. + output: Where to write. - Returns the process exit code, non-zero when errors were found. + Returns: + The process exit code, non-zero when errors were found. """ report: ValidationReport = validate(store) @@ -419,8 +443,9 @@ def requireScopeKey(store: Store, key: Optional[str]) -> None: Scoping to an unknown key would draw an empty graph, which reads as an answer rather than as the typo it is. `list` refuses one for the same reason. A status needs no equivalent check, since the vocabulary is fixed and both spellings are checked against it before they arrive here, so an empty result there is a true answer. - store: The store holding the registry. - key: The key the scope named, or `None` when it named something else. + Args: + store: The store holding the registry. + key: The key the scope named, or `None` when it named something else. """ if key is not None: @@ -433,10 +458,12 @@ def documentPath(config: Optional[Config], filename: str) -> Path: A document belongs to the repository it describes, so it lands beside the configuration that governs it. Run outside a repository there is no such place, and the working directory is the only honest fallback, which is the same reasoning that lets the brief render without a configuration at all. - config: The configuration governing the document, or `None` when none was found. - filename: What the document is called. + Args: + config: The configuration governing the document, or `None` when none was found. + filename: What the document is called. - Returns the path to write to unless the caller names another. + Returns: + The path to write to unless the caller names another. """ return (config.repoRoot if config is not None else Path.cwd()) / filename @@ -450,15 +477,17 @@ def emitDocument(text: str, destination: Optional[str], name: str, output: Outpu A named destination always wins. Without one, the prescribed path is written unless printing was asked for instead, and asking for both does both. - text: The rendered text to emit. - destination: The path the caller named, or `None` when none was named. - name: What to name the destination in an error message, for example `--output path`. - output: Where to write. - defaultPath: The path to write when none was named, or `None` to leave stdout as the only destination. - toPrint: Whether to print the text to stdout. - note: Anything to append to the confirmation line, already spaced and parenthesized. + Args: + text: The rendered text to emit. + destination: The path the caller named, or `None` when none was named. + name: What to name the destination in an error message, for example `--output path`. + output: Where to write. + defaultPath: The path to write when none was named, or `None` to leave stdout as the only destination. + toPrint: Whether to print the text to stdout. + note: Anything to append to the confirmation line, already spaced and parenthesized. - Returns the process exit code. + Returns: + The process exit code. """ chosen: Optional[str] = destination @@ -483,11 +512,13 @@ def commandDocs(args: argparse.Namespace, config: Optional[Config], output: Outp """ Write a document docket ships, rendered for this repository. - args: The parsed arguments. - config: The configuration governing the current directory, or `None` when none was found. - output: Where to write. + Args: + args: The parsed arguments. + config: The configuration governing the current directory, or `None` when none was found. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ if args.docsCommand == "handoff": @@ -509,11 +540,13 @@ def commandRoadmap(args: argparse.Namespace, store: Store, output: Output) -> in """ Write the dependency graph as a markdown document with an embedded mermaid diagram. - args: The parsed arguments. - store: The store to read from. - output: Where to write. + Args: + args: The parsed arguments. + store: The store to read from. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ ticketId, key, status = resolveGraphScope(args.scope, args.id, args.key, args.status) @@ -535,10 +568,12 @@ def commandDeploy(args: argparse.Namespace, output: Output) -> int: """ Install docket into a repository, or refresh what is already deployed there. - args: The parsed arguments. - output: Where to write. + Args: + args: The parsed arguments. + output: Where to write. - Returns the process exit code. + Returns: + The process exit code. """ target: Path = Path(args.path) diff --git a/src/docket/cli/grammar.py b/src/docket/cli/grammar.py index 9c5346f..dd9ee51 100644 --- a/src/docket/cli/grammar.py +++ b/src/docket/cli/grammar.py @@ -55,9 +55,11 @@ def describeKeys(config: Optional[Config]) -> str: The registry is per-repository, so the options can only be named once a configuration has been found. Without one the description stays general rather than guessing. - config: The configuration holding the registry, or `None` when none was found. + Args: + config: The configuration holding the registry, or `None` when none was found. - Returns the sentence to append. + Returns: + The sentence to append. """ if config is None: @@ -75,9 +77,11 @@ def describePriorities(config: Optional[Config]) -> str: The band runs from 0 through the configured `maxPriority`, so like the key registry it can only be listed once a configuration has been found. - config: The configuration holding the band, or `None` when none was found. + Args: + config: The configuration holding the band, or `None` when none was found. - Returns the sentence to append. + Returns: + The sentence to append. """ if config is None: @@ -92,7 +96,8 @@ def tryDiscoverConfig() -> Optional[Config]: Only the help text depends on this, and every command that truly needs a configuration discovers it again through `dispatch`, so a missing one must not stop the parser from being built. That is what keeps `--help`, `--version`, and `deploy` working outside a repository. - Returns the loaded `Config`, or `None` when none could be loaded. + Returns: + The loaded `Config`, or `None` when none could be loaded. """ try: @@ -107,9 +112,11 @@ def classifyToken(token: str) -> Optional[str]: This is the whole of the rule that lets `docket list todo CORE 1` and `docket graph CORE-14` be written without a flag between them. The four classes cannot overlap, so no token is ever ambiguous and no ordering between the checks changes an answer. - token: The token to read. + Args: + token: The token to read. - Returns one of the `TOKEN_` names, or `None` when the token is none of them. + Returns: + One of the `TOKEN_` names, or `None` when the token is none of them. """ if isValidId(token): @@ -134,9 +141,11 @@ def rewriteIdFirst(argv: list[str]) -> list[str]: `docket CORE-14 done` is the shape a person types, and `argparse` cannot express "a subcommand, or else an id". It does not have to, because the two are distinguishable before parsing begins: every command name is lowercase and every id is an uppercase key followed by a hyphen and a number. So naming the branch is all this does, and the parser is left to do the rest, including the help and every error. - argv: The raw argument list. + Args: + argv: The raw argument list. - Returns the list to parse, unchanged when it does not open with an id. + Returns: + The list to parse, unchanged when it does not open with an id. """ if not argv or not isValidId(argv[0]): @@ -151,12 +160,18 @@ def resolveListFilters(tokens: list[str], status: Optional[str], key: Optional[s A token says which filter it is by its own shape, so `docket list todo CORE 1` needs no flags at all. The flags remain for scripts and for anyone who would rather be explicit, and naming one filter twice is refused rather than quietly resolved in whichever direction the code happens to read. - tokens: The bare filter tokens, in any order. - status: The status named by `--status`, or `None`. - key: The key named by `--key`, or `None`. - priorityMax: The ceiling named by `--priority-max`, or `None`. + Args: + tokens: The bare filter tokens, in any order. + status: The status named by `--status`, or `None`. + key: The key named by `--key`, or `None`. + priorityMax: The ceiling named by `--priority-max`, or `None`. - Returns the resolved `(status, key, priorityMax)` triple. + Returns: + The resolved `(status, key, priorityMax)` triple. + + Raises: + InvalidArgumentError: '{token}' is a ticket id, which does not filter a list. Show it with '{PROGRAM_NAME} {token}'. + ConflictingArgumentsError: The {kind} filter was given twice, the second time as '{token}'. Pass it once. """ # Held as text so one loop can fill any of the three, then converted back on the way out. @@ -194,12 +209,18 @@ def resolveGraphScope(scope: Optional[str], ticketId: Optional[str], key: Option The three scopes remain exclusive rather than composing. A graph is scoped to one thing, and narrowing an already narrowed graph is what `list`'s filters are for. - scope: The bare scope token, or `None`. - ticketId: The id named by `--id`, or `None`. - key: The key named by `--key`, or `None`. - status: The status named by `--status`, or `None`. + Args: + scope: The bare scope token, or `None`. + ticketId: The id named by `--id`, or `None`. + key: The key named by `--key`, or `None`. + status: The status named by `--status`, or `None`. + + Returns: + The resolved `(id, key, status)` triple, at most one of which is set. - Returns the resolved `(id, key, status)` triple, at most one of which is set. + Raises: + ConflictingArgumentsError: '{scope}' already scopes the graph, so it cannot be combined with -i/--id, -k/--key, or -s/--status. + InvalidArgumentError: Cannot read '{scope}' as a scope. Expected a ticket id, for example 'CORE-14', a key, or a status ({', '.join(STATUSES)}). """ if scope is None: @@ -226,9 +247,11 @@ def buildParser(config: Optional[Config] = None) -> argparse.ArgumentParser: """ Build the top-level argument parser and every subcommand parser. - config: The configuration whose keys and priority band the help text names, or `None` to describe both in general terms. + Args: + config: The configuration whose keys and priority band the help text names, or `None` to describe both in general terms. - Returns the configured `argparse.ArgumentParser`. + Returns: + The configured `argparse.ArgumentParser`. """ parser: argparse.ArgumentParser = argparse.ArgumentParser( @@ -326,8 +349,9 @@ def addScopeArguments(parser: argparse.ArgumentParser, keyOptions: str) -> None: Both commands that draw a graph offer the same scopes, read by the same rules, and resolved by the same `resolveGraphScope`. Declaring them once is what keeps the two from drifting into disagreeing about what a bare token means. - parser: The parser to add the arguments to. - keyOptions: The sentence describing the registered keys, already built. + Args: + parser: The parser to add the arguments to. + keyOptions: The sentence describing the registered keys, already built. """ parser.add_argument("scope", nargs="?", metavar="SCOPE", help=f"What to scope to, read from its own shape: a ticket id, a key, or a status ({', '.join(STATUSES)}). The flags below are the same three, named explicitly.") @@ -344,10 +368,12 @@ def buildTicketParser(commands: argparse._SubParsersAction, priorityOptions: str `prog` is set to the program name alone, and the id is added before the subcommands, so `argparse` derives every child's usage line as `docket ID `. That is exactly what was typed, rather than the placeholder the branch is registered under. - commands: The top-level subparser action to register into. - priorityOptions: The sentence describing the priority band, already built. + Args: + commands: The top-level subparser action to register into. + priorityOptions: The sentence describing the priority band, already built. - Returns the branch parser. + Returns: + The branch parser. """ ticketParser: argparse.ArgumentParser = commands.add_parser( @@ -392,9 +418,14 @@ def parseIdList(value: Optional[str]) -> Optional[list[str]]: Both `none` and an empty string yield an empty list rather than `None`, which is how `set --requires` clears a ticket's dependencies. The sentinel exists because PowerShell discards an empty-string argument before the process ever sees it, leaving the documented empty-string form unreachable on Windows. - value: The raw argument value. + Args: + value: The raw argument value. - Returns the ids, or `None` when the argument was absent. + Returns: + The ids, or `None` when the argument was absent. + + Raises: + InvalidIdError: '{CLEAR_SENTINEL}' clears the whole list, so it cannot be combined with an id. Pass either '{CLEAR_SENTINEL}' alone or only ids. """ if value is None: @@ -418,10 +449,15 @@ def parseEditIdList(value: Optional[str], flag: str) -> Optional[list[str]]: Clearing is what `--requires` is for, so an empty result here means the caller named an edit and then named nothing to do, which is a usage error rather than a silent no-op. - value: The raw argument value. - flag: The flag the value came from, named in the error. + Args: + value: The raw argument value. + flag: The flag the value came from, named in the error. + + Returns: + The ids, or `None` when the argument was absent. - Returns the ids, or `None` when the argument was absent. + Raises: + InvalidIdError: '{flag}' needs at least one id. Use '--requires {CLEAR_SENTINEL}' to clear the list instead. """ entries: Optional[list[str]] = parseIdList(value) diff --git a/src/docket/cli/output.py b/src/docket/cli/output.py index 6a63a68..797598c 100644 --- a/src/docket/cli/output.py +++ b/src/docket/cli/output.py @@ -45,7 +45,8 @@ def print(self, renderable: object) -> None: """ Print human-facing output. - renderable: Anything `rich` can render. + Args: + renderable: Anything `rich` can render. """ self.console.print(renderable) @@ -56,7 +57,8 @@ def raw(self, text: str) -> None: Mermaid source goes through here, so redirecting it to a file or a pipe yields exactly the source and nothing else. - text: The text to write. + Args: + text: The text to write. """ sys.stdout.write(text) @@ -65,7 +67,8 @@ def warn(self, message: str) -> None: """ Report a non-fatal warning. - message: The warning text. + Args: + message: The warning text. """ self.errorConsole.print(Text(f"warning: {message}", style="yellow")) @@ -74,7 +77,8 @@ def error(self, message: str) -> None: """ Report a failure. - message: The error text. + Args: + message: The error text. """ self.errorConsole.print(Text(f"error: {message}", style="bold red")) @@ -87,10 +91,12 @@ def buildContextTable(heading: str, entries: list[dict[str, object]]) -> Table: """ Build the table showing one direction of a ticket's resolved dependencies. - heading: What to title the table. - entries: The resolved records. + Args: + heading: What to title the table. + entries: The resolved records. - Returns the table. + Returns: + The table. """ table: Table = Table(title=heading, title_justify="left", box=None, pad_edge=False, title_style="bold") @@ -119,10 +125,12 @@ def relativeToRoot(path: Optional[Path], root: Path) -> str: """ Describe a path relative to a repository root, so output stays readable in a narrow terminal. - path: The path to describe. - root: The directory to describe it against. + Args: + path: The path to describe. + root: The directory to describe it against. - Returns the relative path, falling back to the absolute one when it lies outside the root. + Returns: + The relative path, falling back to the absolute one when it lies outside the root. """ if path is None: diff --git a/src/docket/core/atomic.py b/src/docket/core/atomic.py index fe3c29a..28c7f68 100644 --- a/src/docket/core/atomic.py +++ b/src/docket/core/atomic.py @@ -29,8 +29,9 @@ def writeTextAtomic(path: Path, text: str) -> None: Newlines are written as LF explicitly, matching what every direct write in this repository already did, so a checkout on Windows does not churn the file. - path: The file to replace. Its parent directory must already exist. - text: The full contents to write. + Args: + path: The file to replace. Its parent directory must already exist. + text: The full contents to write. """ # Hold the temporary path outside the try, so cleanup can find it even when the write itself is what failed. diff --git a/src/docket/core/config.py b/src/docket/core/config.py index ecc9cb1..298008a 100644 --- a/src/docket/core/config.py +++ b/src/docket/core/config.py @@ -55,8 +55,12 @@ def __init__(self, path: Path, document: TOMLDocument) -> None: """ Wrap a parsed document. - path: The path the document was read from, and the path `save` writes back to. - document: The parsed `tomlkit` document, retained for round-tripping. + Args: + path: The path the document was read from, and the path `save` writes back to. + document: The parsed `tomlkit` document, retained for round-tripping. + + Raises: + ConfigError: defaultPriority {self.defaultPriority} is outside 0 through maxPriority {self.maxPriority} in {self.path}. """ self.path: Path = path @@ -144,7 +148,11 @@ def __keysTable(self) -> Table: """ Return the `[keys]` table, creating it in the document when absent. - Returns the table. + Returns: + The table. + + Raises: + ConfigError: Section '[{KEYS_TABLE}]' in {self.path} must be a table. """ if KEYS_TABLE not in self.document: @@ -163,7 +171,8 @@ def __dropFromKeysTable(self, key: str) -> None: A comment a human wrote elsewhere in the table is untouched, since only the run directly above the key is considered part of it. The table is rebuilt rather than edited in place, because deleting a comment line from the document's body directly would leave `tomlkit`'s internal index pointing at the wrong entries. - key: The key to drop. + Args: + key: The key to drop. """ body: list[Any] = self.__keysTable().value.body @@ -207,7 +216,8 @@ def sharedLock(self) -> ContextManager[None]: """ Hold this repository's read lock, waiting no longer than the configured `lockTimeout`. - Returns the context manager to hold for the duration of the read. + Returns: + The context manager to hold for the duration of the read. """ return sharedLock(self.repoRoot, self.lockTimeout) @@ -216,7 +226,8 @@ def exclusiveLock(self) -> ContextManager[None]: """ Hold this repository's write lock, waiting no longer than the configured `lockTimeout`. - Returns the context manager to hold for the duration of the read-modify-write. + Returns: + The context manager to hold for the duration of the read-modify-write. """ return exclusiveLock(self.repoRoot, self.lockTimeout) @@ -225,9 +236,11 @@ def isRegisteredKey(self, key: str) -> bool: """ Report whether a key has been approved. - key: The key to test. + Args: + key: The key to test. - Returns `True` when the key is registered. + Returns: + `True` when the key is registered. """ return key in self.registeredKeys @@ -238,9 +251,14 @@ def requireKnownKey(self, key: str) -> str: The message points at `add_key`, since that is the recovery path an agent has. - key: The key to check. + Args: + key: The key to check. - Returns the same key. + Returns: + The same key. + + Raises: + UnknownKeyError: Key '{key}' is not registered. Known keys: {known}. Ask the user whether to add a new one, then call add_key. """ requireValidKey(key) @@ -255,11 +273,16 @@ def addKey(self, key: str, description: str, rationale: Optional[str] = None) -> """ Register a key so tickets may be created under it. - key: The key to register. - description: What the key groups, shown alongside the other keys. - rationale: Why the key was added, written as a comment above it so the reasoning survives in the file. + Args: + key: The key to register. + description: What the key groups, shown alongside the other keys. + rationale: Why the key was added, written as a comment above it so the reasoning survives in the file. + + Returns: + The same key. - Returns the same key. + Raises: + InvalidKeyError: Key '{key}' is already registered. """ requireValidKey(key) @@ -292,8 +315,13 @@ def removeKey(self, key: str, usedBy: Optional[list[str]] = None) -> None: Removing a key that tickets already carry would strand those tickets with an unknown key, so the caller passes the ids it found and this refuses loudly. - key: The key to remove. - usedBy: Ids of tickets currently carrying the key, if any. + Args: + key: The key to remove. + usedBy: Ids of tickets currently carrying the key, if any. + + Raises: + InvalidKeyError: Key '{key}' cannot be removed because {len(usedBy)} ticket(s) use it: {', '.join(sorted(usedBy))}. + UnknownKeyError: Key '{key}' is not registered, so there is nothing to remove. """ # Refuse while tickets depend on the key, and name them so the user can act. @@ -330,9 +358,14 @@ def findConfigPath(startDir: Optional[Path] = None) -> Path: The walk stops at the git root, so a parent repository's configuration is never picked up by a nested one. - startDir: The directory to start from, defaulting to the current working directory. + Args: + startDir: The directory to start from, defaulting to the current working directory. + + Returns: + The path to the configuration file. - Returns the path to the configuration file. + Raises: + ConfigNotFoundError: No {CONFIG_FILENAME} found. Searched: {', '.join(searched)}. Run 'docket deploy .' at the repository root to create one. """ current: Path = (startDir if startDir is not None else Path.cwd()).resolve() @@ -357,9 +390,11 @@ def loadConfig(configPath: Path) -> Config: """ Parse a configuration file. - configPath: The path to read. + Args: + configPath: The path to read. - Returns the loaded `Config`. + Returns: + The loaded `Config`. """ return Config(path=configPath, document=_parseDocument(configPath)) @@ -369,9 +404,14 @@ def _parseDocument(configPath: Path) -> TOMLDocument: """ Read and parse a configuration file into a round-trippable document. - configPath: The path to read. + Args: + configPath: The path to read. + + Returns: + The parsed document. - Returns the parsed document. + Raises: + ConfigError: Could not read {configPath}: {error} """ try: @@ -386,9 +426,11 @@ def discoverConfig(startDir: Optional[Path] = None) -> Config: """ Find and load the configuration governing a directory. - startDir: The directory to start the walk from, defaulting to the current working directory. + Args: + startDir: The directory to start the walk from, defaulting to the current working directory. - Returns the loaded `Config`. + Returns: + The loaded `Config`. """ return loadConfig(findConfigPath(startDir)) diff --git a/src/docket/core/deploy.py b/src/docket/core/deploy.py index 0e53a6d..c2fb488 100644 --- a/src/docket/core/deploy.py +++ b/src/docket/core/deploy.py @@ -65,8 +65,9 @@ def record(self, path: Path, existed: bool) -> None: """ Note that a path was written. - path: The path written. - existed: Whether the file was already there. + Args: + path: The path written. + existed: Whether the file was already there. """ (self.updated if existed else self.created).append(path) @@ -75,7 +76,8 @@ def toDict(self) -> dict[str, list[str]]: """ Build the serializable form. - Returns the report as plain data. + Returns: + The report as plain data. """ return { @@ -94,9 +96,11 @@ def deploy(target: Path) -> DeployReport: This is idempotent. Missing pieces are created and templates are refreshed, but an existing `.docket.toml` is never rewritten, because it holds the key registry a human has curated. - target: The repository root to deploy into. + Args: + target: The repository root to deploy into. - Returns what was done. + Returns: + What was done. """ root: Path = _requireDirectory(target) @@ -134,9 +138,14 @@ def upgrade(target: Path) -> DeployReport: This rewrites what docket owns and nothing else. The configuration is left alone because it holds the key registry, and tickets are left alone because they are the repository's data. The `.gitignore` is the one file docket does not own but still appends to, since a repository deployed before the lock existed has no entry for it and would otherwise commit one. - target: The repository root to upgrade. + Args: + target: The repository root to upgrade. - Returns what was done. + Returns: + What was done. + + Raises: + DeployError: No {CONFIG_FILENAME} in {root}. Run 'docket deploy {root}' first. """ root: Path = _requireDirectory(target) @@ -162,9 +171,11 @@ def readTemplate(name: str) -> str: """ Read a template shipped inside the package. - name: The template filename. + Args: + name: The template filename. - Returns the template text. + Returns: + The template text. """ return readPackageText(TEMPLATES_DIRECTORY, name) @@ -176,8 +187,9 @@ def _writeClaudeTemplate(config: Config, report: DeployReport) -> None: This lands beside the tickets rather than at the repository root, so an agent opening the ticket directory reads it in place. - config: The loaded configuration, naming the ticket root. - report: The report to record the write against. + Args: + config: The loaded configuration, naming the ticket root. + report: The report to record the write against. """ path: Path = config.rootPath / CLAUDE_FILENAME @@ -193,8 +205,12 @@ def _mergeMcpConfig(root: Path, report: DeployReport) -> None: This is the step most likely to damage something, so it reads, modifies, and writes rather than overwriting. Docket's own entry is replaced and every other entry is preserved. A file that is not valid JSON is refused rather than clobbered. - root: The repository root. - report: The report to record the write against. + Args: + root: The repository root. + report: The report to record the write against. + + Raises: + DeployError: {path} is not valid JSON, so it was left untouched: {error} """ path: Path = root / MCP_FILENAME @@ -232,8 +248,12 @@ def _ignoreLockFile(root: Path, report: DeployReport) -> None: The lock file is machine state rather than repository content, so committing it would put one developer's lock in another developer's checkout. Only the entry is appended. Everything already in the file is preserved, and a file that already covers the lock is left exactly as it is. - root: The repository root. - report: The report to record the write against. + Args: + root: The repository root. + report: The report to record the write against. + + Raises: + DeployError: Could not read {path}: {error} """ path: Path = root / GITIGNORE_FILENAME @@ -266,9 +286,14 @@ def _requireDirectory(target: Path) -> Path: """ Resolve a deploy target, refusing anything that is not an existing directory. - target: The path given by the caller. + Args: + target: The path given by the caller. + + Returns: + The resolved directory. - Returns the resolved directory. + Raises: + DeployError: {resolved} is not a directory. """ resolved: Path = target.resolve() @@ -283,8 +308,9 @@ def _write(path: Path, text: str) -> None: """ Write a file, creating its parent directories. - path: Where to write. - text: What to write. + Args: + path: Where to write. + text: What to write. """ path.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/docket/core/fields.py b/src/docket/core/fields.py index 90e026d..94ccb8e 100644 --- a/src/docket/core/fields.py +++ b/src/docket/core/fields.py @@ -19,13 +19,15 @@ def readString(mapping: Mapping[str, Any], name: str, errorType: Type[DocketErro """ Read a string field. - mapping: The parsed mapping to read from. - name: The field name. - errorType: The exception raised when the field is absent or of the wrong type. - source: What to name in the error message, for example `Frontmatter`. - fallback: The value used when the field is absent, or `None` to make the field required. - - Returns the field value. + Args: + mapping: The parsed mapping to read from. + name: The field name. + errorType: The exception raised when the field is absent or of the wrong type. + source: What to name in the error message, for example `Frontmatter`. + fallback: The value used when the field is absent, or `None` to make the field required. + + Returns: + The field value. """ value: Any = _readRaw(mapping, name, errorType, source, fallback) @@ -41,13 +43,15 @@ def readInt(mapping: Mapping[str, Any], name: str, errorType: Type[DocketError], """ Read an integer field. - mapping: The parsed mapping to read from. - name: The field name. - errorType: The exception raised when the field is absent or of the wrong type. - source: What to name in the error message, for example `Frontmatter`. - fallback: The value used when the field is absent, or `None` to make the field required. + Args: + mapping: The parsed mapping to read from. + name: The field name. + errorType: The exception raised when the field is absent or of the wrong type. + source: What to name in the error message, for example `Frontmatter`. + fallback: The value used when the field is absent, or `None` to make the field required. - Returns the field value. + Returns: + The field value. """ value: Any = _readRaw(mapping, name, errorType, source, fallback) @@ -66,13 +70,15 @@ def readFloat(mapping: Mapping[str, Any], name: str, errorType: Type[DocketError An integer is accepted and widened, since a configuration writing `5` rather than `5.0` means the same thing and refusing it would be pedantic. - mapping: The parsed mapping to read from. - name: The field name. - errorType: The exception raised when the field is absent or of the wrong type. - source: What to name in the error message, for example `Configuration`. - fallback: The value used when the field is absent, or `None` to make the field required. + Args: + mapping: The parsed mapping to read from. + name: The field name. + errorType: The exception raised when the field is absent or of the wrong type. + source: What to name in the error message, for example `Configuration`. + fallback: The value used when the field is absent, or `None` to make the field required. - Returns the field value. + Returns: + The field value. """ value: Any = _readRaw(mapping, name, errorType, source, fallback) @@ -91,12 +97,14 @@ def readStringList(mapping: Mapping[str, Any], name: str, errorType: Type[Docket An absent or null field reads as empty, since an empty list is the common case and refusing to load over it would be hostile. - mapping: The parsed mapping to read from. - name: The field name. - errorType: The exception raised when the field is of the wrong type. - source: What to name in the error message, for example `Frontmatter`. + Args: + mapping: The parsed mapping to read from. + name: The field name. + errorType: The exception raised when the field is of the wrong type. + source: What to name in the error message, for example `Frontmatter`. - Returns the field value. + Returns: + The field value. """ if name not in mapping or mapping[name] is None: @@ -121,12 +129,14 @@ def readDict(mapping: Mapping[str, Any], name: str, errorType: Type[DocketError] An absent or null field reads as empty, for the same reason as `readStringList`. - mapping: The parsed mapping to read from. - name: The field name. - errorType: The exception raised when the field is of the wrong type. - source: What to name in the error message, for example `Frontmatter`. + Args: + mapping: The parsed mapping to read from. + name: The field name. + errorType: The exception raised when the field is of the wrong type. + source: What to name in the error message, for example `Frontmatter`. - Returns the field value. + Returns: + The field value. """ if name not in mapping or mapping[name] is None: @@ -148,13 +158,15 @@ def _readRaw(mapping: Mapping[str, Any], name: str, errorType: Type[DocketError] """ Fetch a field, applying its fallback or raising when it is required and absent. - mapping: The parsed mapping to read from. - name: The field name. - errorType: The exception raised when a required field is absent. - source: What to name in the error message. - fallback: The value used when the field is absent, or `None` to make the field required. + Args: + mapping: The parsed mapping to read from. + name: The field name. + errorType: The exception raised when a required field is absent. + source: What to name in the error message. + fallback: The value used when the field is absent, or `None` to make the field required. - Returns the raw value, still untyped. + Returns: + The raw value, still untyped. """ if name in mapping: diff --git a/src/docket/core/graph.py b/src/docket/core/graph.py index e0703cc..84cc5da 100644 --- a/src/docket/core/graph.py +++ b/src/docket/core/graph.py @@ -97,7 +97,8 @@ def __len__(self) -> int: """ Count the nodes. - Returns the node count. + Returns: + The node count. """ return len(self.nodes) @@ -106,9 +107,11 @@ def __contains__(self, ticketId: str) -> bool: """ Report whether a node is present. - ticketId: The id to test. + Args: + ticketId: The id to test. - Returns `True` when the graph holds the node. + Returns: + `True` when the graph holds the node. """ return ticketId in self.nodes @@ -119,7 +122,8 @@ def keys(self) -> list[str]: """ List every key present, sorted. - Returns the keys. + Returns: + The keys. """ return sorted({node.key for node in self.nodes.values()}) @@ -128,9 +132,11 @@ def nodesForKey(self, key: str) -> list[GraphNode]: """ List the nodes carrying one key, ordered by ticket number. - key: The key to select. + Args: + key: The key to select. - Returns the ordered nodes. + Returns: + The ordered nodes. """ return sorted((node for node in self.nodes.values() if node.key == key), key=lambda node: parseId(node.id)[1]) @@ -161,9 +167,11 @@ def resolveGraph(ticketSet: TicketSet) -> ResolvedGraph: A `requires` entry naming an id that does not exist is dropped here rather than raising, because reporting it is `validate`'s job and the graph still has to render around it. - ticketSet: The loaded tickets. + Args: + ticketSet: The loaded tickets. - Returns the resolved graph. + Returns: + The resolved graph. """ tickets: dict[str, Ticket] = ticketSet.tickets @@ -201,9 +209,11 @@ def subgraphForId(graph: ResolvedGraph, ticketId: str) -> ResolvedGraph: """ Scope a graph to one ticket's transitive ancestors and descendants. - ticketId: The ticket at the centre. + Args: + ticketId: The ticket at the centre. - Returns the scoped graph. + Returns: + The scoped graph. """ if ticketId not in graph.nodes: @@ -223,9 +233,11 @@ def subgraphForKey(graph: ResolvedGraph, key: str) -> ResolvedGraph: The neighbors are marked external so a renderer can show where the key ends. - key: The key to scope to. + Args: + key: The key to scope to. - Returns the scoped graph. + Returns: + The scoped graph. """ members: set[str] = {node.id for node in graph.nodes.values() if node.key == key} @@ -253,10 +265,12 @@ def subgraphForStatus(graph: ResolvedGraph, status: str) -> ResolvedGraph: Nothing is borrowed from outside, unlike the key scope. A key has a boundary worth drawing, since the work either side of it is still related, but the tickets around a status are only the same work at a different moment, so pulling them in would put every other status back on the page. An edge therefore survives only when both of its ends carry the status, which is what lets the result read as the ordering within that status alone. - graph: The graph to scope. - status: The status to scope to. + Args: + graph: The graph to scope. + status: The status to scope to. - Returns the scoped graph. + Returns: + The scoped graph. """ members: set[str] = {node.id for node in graph.nodes.values() if node.status == status} @@ -270,12 +284,14 @@ def scopeGraph(graph: ResolvedGraph, ticketId: Optional[str] = None, key: Option The three remain exclusive, so the first one set is the one that applies and a caller passing two has already been refused by the grammar that read them. Checking that a key is registered or that a status is spelled correctly belongs to the caller, since the CLI and the server learn those from different places and report them differently. - graph: The graph to scope. - ticketId: The ticket to center on, or `None`. - key: The key to scope to, or `None`. - status: The status to scope to, or `None`. + Args: + graph: The graph to scope. + ticketId: The ticket to center on, or `None`. + key: The key to scope to, or `None`. + status: The status to scope to, or `None`. - Returns the scoped graph, or the same graph when nothing scoped it. + Returns: + The scoped graph, or the same graph when nothing scoped it. """ if ticketId is not None: @@ -300,10 +316,12 @@ def cullGraph(graph: ResolvedGraph, maxNodes: int) -> CulledGraph: A ring that will not fit whole is filled in id order, so the same graph always culls to the same nodes and a committed document does not churn between runs. - graph: The graph to narrow. - maxNodes: The node count to aim for, or zero and below for no ceiling at all. + Args: + graph: The graph to narrow. + maxNodes: The node count to aim for, or zero and below for no ceiling at all. - Returns the narrowed graph and the number of nodes it cost. + Returns: + The narrowed graph and the number of nodes it cost. """ # Nothing to do when no ceiling was asked for, or when the graph already sits under the one that was. @@ -348,10 +366,12 @@ def dependencyContext(ticketSet: TicketSet, ticketId: str) -> dict[str, list[dic The file stores bare ids in one direction only, so both the title and status of each dependency, and the entire reverse direction, have to be resolved here. This is what lets `read_ticket` be useful without the file duplicating anything. - ticketSet: The loaded tickets. - ticketId: The ticket to resolve context for. + Args: + ticketSet: The loaded tickets. + ticketId: The ticket to resolve context for. - Returns a mapping of `requires` and `requiredBy` to summary records. + Returns: + A mapping of `requires` and `requiredBy` to summary records. """ graph: ResolvedGraph = resolveGraph(ticketSet) @@ -377,10 +397,12 @@ def ticketReadiness(ticketSet: TicketSet, ticketId: str) -> Readiness: A dependency naming a ticket that does not exist blocks, since a link the reader cannot follow is not the same as clear road. A cycle blocks without a special case, because no ticket in one is ever done. - ticketSet: The loaded tickets. - ticketId: The ticket to judge. + Args: + ticketSet: The loaded tickets. + ticketId: The ticket to judge. - Returns the readiness of that ticket. + Returns: + The readiness of that ticket. """ return _readinessOf(ticketSet, ticketSet.get(ticketId)) @@ -392,10 +414,12 @@ def readyTickets(ticketSet: TicketSet, tickets: Iterable[Ticket]) -> list[Ticket The whole set is needed to judge any one ticket, so the candidates are passed separately from the set they are judged against. That is what lets a caller filter an already narrowed listing. - ticketSet: The loaded tickets, which every dependency is looked up in. - tickets: The candidates to filter. + Args: + ticketSet: The loaded tickets, which every dependency is looked up in. + tickets: The candidates to filter. - Returns the ready candidates. + Returns: + The ready candidates. """ return [ticket for ticket in tickets if _readinessOf(ticketSet, ticket).isReady] @@ -407,9 +431,11 @@ def findCycles(graph: ResolvedGraph) -> list[list[str]]: Tarjan's algorithm is used iteratively rather than recursively, so a deep chain cannot exhaust the interpreter stack. - graph: The graph to search. + Args: + graph: The graph to search. - Returns one sorted member list per cycle, ordered for stable output. + Returns: + One sorted member list per cycle, ordered for stable output. """ index: dict[str, int] = {} @@ -479,10 +505,12 @@ def _readinessOf(ticketSet: TicketSet, ticket: Ticket) -> Readiness: """ Judge one already-loaded ticket, which is what both public entry points do their work through. - ticketSet: The loaded tickets, which every dependency is looked up in. - ticket: The ticket to judge. + Args: + ticketSet: The loaded tickets, which every dependency is looked up in. + ticket: The ticket to judge. - Returns the readiness of that ticket. + Returns: + The readiness of that ticket. """ # A finished ticket has no work left to be ready for, so it is not ready and nothing is holding it back. @@ -498,9 +526,11 @@ def _isSatisfied(ticketSet: TicketSet, requiredId: str) -> bool: """ Report whether one dependency is met. - requiredId: The id the depending ticket names. + Args: + requiredId: The id the depending ticket names. - Returns `True` only when the id names a ticket that exists and is done. + Returns: + `True` only when the id names a ticket that exists and is done. """ required: Optional[Ticket] = ticketSet.tickets.get(requiredId) @@ -512,10 +542,12 @@ def _contextEntry(ticketSet: TicketSet, ticketId: str) -> dict[str, object]: """ Build one resolved dependency record. - ticketSet: The loaded tickets to look the ticket up in. - ticketId: The id to describe. + Args: + ticketSet: The loaded tickets to look the ticket up in. + ticketId: The id to describe. - Returns the record, flagged when the id names nothing that exists. + Returns: + The record, flagged when the id names nothing that exists. """ ticket: Optional[Ticket] = ticketSet.tickets.get(ticketId) @@ -531,11 +563,13 @@ def _reachable(graph: ResolvedGraph, startId: str, forward: bool) -> set[str]: The visited set makes this safe on a graph that already contains a cycle, which matters because `validate` has to render a broken graph in order to explain it. - graph: The graph to walk. - startId: The node to walk from. - forward: Walk `requires` when `True`, `requiredBy` when `False`. + Args: + graph: The graph to walk. + startId: The node to walk from. + forward: Walk `requires` when `True`, `requiredBy` when `False`. - Returns the reachable ids, excluding the start unless a cycle leads back to it. + Returns: + The reachable ids, excluding the start unless a cycle leads back to it. """ seen: set[str] = set() @@ -560,11 +594,13 @@ def _restrict(graph: ResolvedGraph, included: set[str], scope: Optional[str]) -> Each node keeps its full edge lists, so a caller can still see that a node has neighbors outside the scope. Only the rendered edges are narrowed. - graph: The graph to restrict. - included: The ids to keep. - scope: What the result is scoped to. + Args: + graph: The graph to restrict. + included: The ids to keep. + scope: What the result is scoped to. - Returns the restricted graph. + Returns: + The restricted graph. """ nodes: dict[str, GraphNode] = {ticketId: graph.nodes[ticketId] for ticketId in included if ticketId in graph.nodes} @@ -576,9 +612,11 @@ def _buildEdges(nodes: dict[str, GraphNode]) -> list[Edge]: """ Build the edge list for a node set, keeping only edges with both ends present. - nodes: The nodes to connect. + Args: + nodes: The nodes to connect. - Returns the edges, sorted for stable output. + Returns: + The edges, sorted for stable output. """ edges: list[Edge] = [] @@ -594,9 +632,11 @@ def _orderedIds(ids: Iterable[str]) -> list[str]: """ Sort ids by key and then numerically, so `CORE-2` precedes `CORE-10`. - ids: The ids to sort. + Args: + ids: The ids to sort. - Returns the sorted ids. + Returns: + The sorted ids. """ return sorted(ids, key=_idSortKey) @@ -606,9 +646,11 @@ def _idSortKey(ticketId: str) -> tuple[str, int]: """ Build the ordering key for one id. - ticketId: The id to order. + Args: + ticketId: The id to order. - Returns a `(key, number)` tuple. + Returns: + A `(key, number)` tuple. """ return parseId(ticketId) diff --git a/src/docket/core/handoff.py b/src/docket/core/handoff.py index 42c7ebd..78975c4 100644 --- a/src/docket/core/handoff.py +++ b/src/docket/core/handoff.py @@ -51,9 +51,11 @@ def buildKeyBriefings(store: Store) -> list[KeyBriefing]: The whole ticket set is loaded once and every key allocates against that one snapshot, so the numbers cannot disagree with each other. - store: The store naming the configuration and the ticket root. + Args: + store: The store naming the configuration and the ticket root. - Returns one briefing per registered key, ordered by key. + Returns: + One briefing per registered key, ordered by key. """ existingIds: list[str] = store.loadAll().ids() @@ -70,9 +72,11 @@ def buildContext(store: Optional[Store]) -> dict[str, Any]: Run outside a repository there is nothing to gather, so the defaults stand in and the brief tells its reader to propose keys instead of choosing from them. That is the same document either way rather than a second mode. - store: The store for the repository the brief is being written for, or `None` when none was found. + Args: + store: The store for the repository the brief is being written for, or `None` when none was found. - Returns the render context. + Returns: + The render context. """ # Without a configuration every value falls back to what a freshly deployed repository would have, since that is what the reader's tickets will eventually meet. @@ -98,9 +102,11 @@ def renderHandoff(store: Optional[Store] = None) -> str: """ Render the offsite authoring brief. - store: The store for the repository the brief is being written for, or `None` when none was found. + Args: + store: The store for the repository the brief is being written for, or `None` when none was found. - Returns the rendered document. + Returns: + The rendered document. """ return renderDocument(HANDOFF_TEMPLATE, buildContext(store)) diff --git a/src/docket/core/ids.py b/src/docket/core/ids.py index 230f119..4d232e7 100644 --- a/src/docket/core/ids.py +++ b/src/docket/core/ids.py @@ -38,9 +38,11 @@ def isValidKey(key: str) -> bool: """ Report whether a key matches the required form. - key: The key to test. + Args: + key: The key to test. - Returns `True` when the key is well formed. + Returns: + `True` when the key is well formed. """ return bool(KEY_PATTERN.match(key)) @@ -50,9 +52,14 @@ def requireValidKey(key: str) -> str: """ Return the key unchanged, raising when it is malformed. - key: The key to check. + Args: + key: The key to check. - Returns the same key. + Returns: + The same key. + + Raises: + InvalidKeyError: Key '{key}' is malformed. A key must be uppercase alphanumeric and start with a letter, for example 'CORE'. """ # Reject anything that is not uppercase alphanumeric starting with a letter. @@ -68,9 +75,11 @@ def isValidId(ticketId: str) -> bool: This is the asking half of `parseId`, for a caller deciding what a token is rather than one that already knows. - ticketId: The id to test. + Args: + ticketId: The id to test. - Returns `True` when the id is well formed. + Returns: + `True` when the id is well formed. """ return bool(ID_PATTERN.match(ticketId)) @@ -80,9 +89,14 @@ def parseId(ticketId: str) -> tuple[str, int]: """ Split a ticket id into its key and its number. - ticketId: The id to split, for example `CORE-14`. + Args: + ticketId: The id to split, for example `CORE-14`. + + Returns: + A `(key, number)` pair. - Returns a `(key, number)` pair. + Raises: + InvalidIdError: Id '{ticketId}' is malformed. An id must be a key, a hyphen, and a positive number, for example 'CORE-14'. """ # Match the whole id so a trailing or leading fragment cannot slip through. @@ -97,10 +111,15 @@ def formatId(key: str, number: int) -> str: """ Build a ticket id from a key and a number. - key: The key the ticket belongs to. - number: The sequential number within that key. + Args: + key: The key the ticket belongs to. + number: The sequential number within that key. + + Returns: + The formatted id. - Returns the formatted id. + Raises: + InvalidIdError: Number {number} is invalid. Ticket numbers start at 1. """ # Validate both halves here so a malformed id can never be constructed. @@ -115,9 +134,11 @@ def keyOf(ticketId: str) -> str: """ Extract the key from a ticket id. - ticketId: The id to read. + Args: + ticketId: The id to read. - Returns the key portion. + Returns: + The key portion. """ return parseId(ticketId)[0] @@ -129,10 +150,12 @@ def nextNumber(key: str, existingIds: Iterable[str]) -> int: The result is always one past the highest number seen, never the lowest unused gap, so a deleted ticket's number is not reused. - key: The key to allocate within. - existingIds: Every ticket id currently in the set, of any key. + Args: + key: The key to allocate within. + existingIds: Every ticket id currently in the set, of any key. - Returns the next number to use. + Returns: + The next number to use. """ requireValidKey(key) @@ -153,10 +176,12 @@ def nextId(key: str, existingIds: Iterable[str]) -> str: """ Derive the next available id for a key. - key: The key to allocate within. - existingIds: Every ticket id currently in the set. + Args: + key: The key to allocate within. + existingIds: Every ticket id currently in the set. - Returns the formatted next id. + Returns: + The formatted next id. """ return formatId(key, nextNumber(key, existingIds)) @@ -171,9 +196,11 @@ def slugify(title: str) -> str: The slug is cut at the cap wherever that lands, mid-word included, since the id prefix is what makes the filename unique. - title: The ticket title to convert. + Args: + title: The ticket title to convert. - Returns the slug, or `untitled` when nothing survives. + Returns: + The slug, or `untitled` when nothing survives. """ # Fold accented characters onto their ASCII bases, then drop anything still outside ASCII. @@ -200,10 +227,12 @@ def buildFilename(ticketId: str, title: str) -> str: The id prefix is what makes the filename unique, which is why a slug collision between two titles is harmless and why a slug matching a Windows reserved device name is harmless too. - ticketId: The ticket's id, which is validated here. - title: The title the slug derives from. + Args: + ticketId: The ticket's id, which is validated here. + title: The title the slug derives from. - Returns the filename, including the `.md` extension. + Returns: + The filename, including the `.md` extension. """ # Validate the id rather than trusting it, since this result is used as a path. diff --git a/src/docket/core/inputs.py b/src/docket/core/inputs.py index b01e227..a1f3ea0 100644 --- a/src/docket/core/inputs.py +++ b/src/docket/core/inputs.py @@ -22,10 +22,15 @@ def requireText(value: str, name: str) -> str: Whitespace counts as empty, since a title of spaces is as unusable as a title of nothing and would leave the same blank cell in every listing. - value: The value to check. - name: What to name in the error message, for example `title`. + Args: + value: The value to check. + name: What to name in the error message, for example `title`. - Returns the value unchanged, with its surrounding whitespace intact. + Returns: + The value unchanged, with its surrounding whitespace intact. + + Raises: + EmptyValueError: The {name} cannot be empty. """ if not value.strip(): @@ -40,10 +45,15 @@ def requireWritableFile(path: str, name: str) -> Path: The checks that can be made without touching the disk are made here, so a caller learns the destination is unusable before any work is done for it. A filesystem may still refuse the write afterwards for a reason no check can predict, which is why `writeFile` exists to catch that too. - path: The destination as the caller supplied it. - name: What to name in the error message, for example `--output path`. + Args: + path: The destination as the caller supplied it. + name: What to name in the error message, for example `--output path`. + + Returns: + The destination as a `Path`. - Returns the destination as a `Path`. + Raises: + OutputPathError: The {name} '{path}' is a directory, not a file. """ requireText(path, name) @@ -72,11 +82,16 @@ def writeFile(path: Path, text: str, name: str) -> Path: Every check `requireWritableFile` can make is a prediction, and a prediction can be wrong. A refusal escaping here as a bare `OSError` would reach the user as a traceback rather than as a message, so it is translated instead. - path: The destination, already checked. - text: The content to write. - name: What to name in the error message, for example `--output path`. + Args: + path: The destination, already checked. + text: The content to write. + name: What to name in the error message, for example `--output path`. + + Returns: + The path written. - Returns the path written. + Raises: + OutputPathError: Could not write the {name} '{path}': {error.strerror or error}. """ try: diff --git a/src/docket/core/lock.py b/src/docket/core/lock.py index d878df3..afb8001 100644 --- a/src/docket/core/lock.py +++ b/src/docket/core/lock.py @@ -26,9 +26,11 @@ def lockPath(repoRoot: Path) -> Path: """ Resolve where a repository's lock file belongs. - repoRoot: The directory holding `.docket.toml`. + Args: + repoRoot: The directory holding `.docket.toml`. - Returns the absolute path of the lock file. + Returns: + The absolute path of the lock file. """ # Resolve the path, since the lock is shared per resolved path and two spellings of one directory must not produce two locks. @@ -43,8 +45,9 @@ def sharedLock(repoRoot: Path, timeout: float) -> Iterator[None]: Any number of processes may read at once, and none of them may read while a writer holds the lock. This is what closes the window where a reader catches `setStatus` between writing the new file and removing the old one, and reports a duplicate id that never really existed. - repoRoot: The directory holding `.docket.toml`. - timeout: How long to wait for a writer to finish, in seconds. + Args: + repoRoot: The directory holding `.docket.toml`. + timeout: How long to wait for a writer to finish, in seconds. """ with _held(repoRoot, timeout, writing=False): @@ -59,8 +62,9 @@ def exclusiveLock(repoRoot: Path, timeout: float) -> Iterator[None]: One process writes at a time and no process reads while it does, so a read followed by a write back is indivisible from any other process's point of view. The whole read-modify-write span belongs inside the block, not just the write, because holding it for the write alone would still let two processes derive their changes from the same starting state. - repoRoot: The directory holding `.docket.toml`. - timeout: How long to wait for the current holder to finish, in seconds. + Args: + repoRoot: The directory holding `.docket.toml`. + timeout: How long to wait for the current holder to finish, in seconds. """ with _held(repoRoot, timeout, writing=True): @@ -77,9 +81,13 @@ def _held(repoRoot: Path, timeout: float, writing: bool) -> Iterator[None]: `filelock` raises its own `Timeout`, which no caller of `docket.core` should have to know about, so it is converted here into the error every other failure in this package already uses. - repoRoot: The directory holding `.docket.toml`. - timeout: How long to wait, in seconds. - writing: Whether to take the exclusive side rather than the shared one. + Args: + repoRoot: The directory holding `.docket.toml`. + timeout: How long to wait, in seconds. + writing: Whether to take the exclusive side rather than the shared one. + + Raises: + LockTimeoutError: Another docket process has been {'writing to' if writing else 'locking'} {repoRoot} for longer than {timeout} seconds. Nothing was changed. Retry the call. """ path: Path = lockPath(repoRoot) diff --git a/src/docket/core/mermaid.py b/src/docket/core/mermaid.py index fa95b24..a36eb50 100644 --- a/src/docket/core/mermaid.py +++ b/src/docket/core/mermaid.py @@ -72,9 +72,11 @@ def renderGraph(graph: ResolvedGraph) -> str: """ Render a resolved graph to mermaid source. - graph: The graph to render. + Args: + graph: The graph to render. - Returns the mermaid source, with a trailing newline and no code fence. + Returns: + The mermaid source, with a trailing newline and no code fence. """ lines: list[str] = [GRAPH_HEADER] @@ -102,9 +104,11 @@ def renderNode(node: GraphNode) -> str: Status and priority are each said twice, once in a way a bare renderer keeps and once in a way it drops. The shape and the label survive anywhere, while the fill and the border are the richer reading for a renderer that honors `classDef`. So nothing a reader needs is only ever a color. - node: The node to render. + Args: + node: The node to render. - Returns the declaration line. + Returns: + The declaration line. """ opening, closing = STATUS_SHAPES.get(node.status, DEFAULT_SHAPE) @@ -118,9 +122,11 @@ def renderLabel(node: GraphNode) -> str: The id, the title, and the priority and status sit on their own lines rather than running together, since the id is what a reader scans for and a title beside it buries the id. - node: The node to label. + Args: + node: The node to label. - Returns the label, with its lines joined by mermaid's line break. + Returns: + The label, with its lines joined by mermaid's line break. """ lines: list[str] = [escapeLabel(node.id), *wrapLabel(node.title), f"p{node.priority} {escapeLabel(node.status)}"] @@ -134,9 +140,11 @@ def wrapLabel(text: str) -> list[str]: Wrapping happens before escaping, so an entity the escape introduces can neither be counted toward the width nor be broken across two lines. A single word longer than the width is left whole, because breaking an id or a path mid-word costs the reader more than the width does. - text: The title to wrap. + Args: + text: The title to wrap. - Returns the escaped lines, empty when there is no title to show. + Returns: + The escaped lines, empty when there is no title to show. """ if not text.strip(): @@ -149,9 +157,11 @@ def renderEdge(edge: Edge) -> str: """ Render one edge. - edge: The edge to render. + Args: + edge: The edge to render. - Returns the edge line, pointing from dependency to dependent. + Returns: + The edge line, pointing from dependency to dependent. """ return f"{sanitizeId(edge.fromId)} --> {sanitizeId(edge.toId)}" @@ -163,9 +173,11 @@ def renderStyles(graph: ResolvedGraph) -> list[str]: A class carries the fill of a status and the border of a priority together, rather than a node taking one class for each. Combining them is what keeps every node to a single class, and it means only the combinations actually present are ever declared. - graph: The graph to style. + Args: + graph: The graph to style. - Returns the style lines, empty when there is nothing to style. + Returns: + The style lines, empty when there is nothing to style. """ if not graph.nodes: @@ -200,10 +212,12 @@ def statusClassName(status: str, priority: int) -> str: The priority is named by its band rather than by its number, so the class count stays bounded however high the configured ceiling goes. - status: The status the class fills for. - priority: The priority the class borders for. + Args: + status: The status the class fills for. + priority: The priority the class borders for. - Returns the class name. + Returns: + The class name. """ return f"{status}P{priorityBand(priority)}" @@ -213,9 +227,11 @@ def priorityStroke(priority: int) -> str: """ Select the border for one priority. - priority: The priority to style. + Args: + priority: The priority to style. - Returns the stroke declaration. + Returns: + The stroke declaration. """ return PRIORITY_STROKES[priorityBand(priority)] @@ -227,9 +243,11 @@ def priorityBand(priority: int) -> int: Everything past the end shares the lightest border, since the configured ceiling can sit anywhere above it and a band nobody can distinguish is not worth a class of its own. - priority: The priority to place. + Args: + priority: The priority to place. - Returns the index. + Returns: + The index. """ return min(max(priority, 0), len(PRIORITY_STROKES) - 1) @@ -241,9 +259,11 @@ def sanitizeId(ticketId: str) -> str: Mermaid dislikes a hyphen in an identifier, so it becomes an underscore. The hyphenated id stays in the label, which is what the reader sees. - ticketId: The id to convert. + Args: + ticketId: The id to convert. - Returns the identifier. + Returns: + The identifier. """ return ticketId.replace("-", "_") @@ -255,9 +275,11 @@ def escapeLabel(text: str) -> str: A title is free text, so it may hold a quote or an angle bracket that would otherwise end the label early or be read as markup. - text: The text to escape. + Args: + text: The text to escape. - Returns the escaped text. + Returns: + The escaped text. """ escaped: str = text diff --git a/src/docket/core/resources.py b/src/docket/core/resources.py index c3baf69..bb581e9 100644 --- a/src/docket/core/resources.py +++ b/src/docket/core/resources.py @@ -24,10 +24,12 @@ def readPackageText(directory: str, name: str) -> str: This goes through `importlib.resources` rather than a path derived from `__file__`, so it reads the same whether docket is installed as a wheel, run from a source checkout, or imported from a zip. - directory: The directory inside the package holding the file. - name: The filename. + Args: + directory: The directory inside the package holding the file. + name: The filename. - Returns the file text. + Returns: + The file text. """ return files(PACKAGE_NAME).joinpath(directory, name).read_text(encoding="utf-8") diff --git a/src/docket/core/roadmap.py b/src/docket/core/roadmap.py index c1b65f8..a141daf 100644 --- a/src/docket/core/roadmap.py +++ b/src/docket/core/roadmap.py @@ -53,9 +53,11 @@ def buildContext(graph: ResolvedGraph) -> dict[str, Any]: """ Gather what the document names about the graph it is drawn from. - graph: The graph the document draws, already scoped and culled. + Args: + graph: The graph the document draws, already scoped and culled. - Returns the render context. + Returns: + The render context. """ return { @@ -73,13 +75,15 @@ def buildRoadmap(store: Store, ticketId: Optional[str] = None, key: Optional[str Scoping happens before culling, so the ceiling is measured against what will actually be drawn rather than against the whole repository. - store: The store to read the tickets from. - ticketId: The ticket to center on, or `None`. - key: The key to scope to, or `None`. - status: The status to scope to, or `None`. - maxNodes: The node count to aim for, or zero for no ceiling. + Args: + store: The store to read the tickets from. + ticketId: The ticket to center on, or `None`. + key: The key to scope to, or `None`. + status: The status to scope to, or `None`. + maxNodes: The node count to aim for, or zero for no ceiling. - Returns the rendered document and what the ceiling cost. + Returns: + The rendered document and what the ceiling cost. """ culled: CulledGraph = cullGraph(scopeGraph(resolveGraph(store.loadAll()), ticketId, key, status), maxNodes) diff --git a/src/docket/core/store.py b/src/docket/core/store.py index ae17a63..cd80b1f 100644 --- a/src/docket/core/store.py +++ b/src/docket/core/store.py @@ -87,9 +87,11 @@ def __contains__(self, ticketId: str) -> bool: """ Report whether an id is present. - ticketId: The id to test. + Args: + ticketId: The id to test. - Returns `True` when a ticket carries the id. + Returns: + `True` when a ticket carries the id. """ return ticketId in self.tickets @@ -98,7 +100,8 @@ def __iter__(self) -> Iterator[Ticket]: """ Iterate every loaded ticket in sorted order. - Returns an iterator over tickets. + Returns: + An iterator over tickets. """ return iter(self.sorted()) @@ -107,7 +110,8 @@ def __len__(self) -> int: """ Count the loaded tickets. - Returns the ticket count. + Returns: + The ticket count. """ return len(self.tickets) @@ -118,9 +122,14 @@ def get(self, ticketId: str) -> Ticket: """ Fetch one ticket by id. - ticketId: The id to look up. + Args: + ticketId: The id to look up. - Returns the ticket. + Returns: + The ticket. + + Raises: + TicketNotFoundError: No ticket with id '{ticketId}'. """ if ticketId not in self.tickets: @@ -132,7 +141,8 @@ def ids(self) -> list[str]: """ List every loaded id. - Returns the ids, unsorted. + Returns: + The ids, unsorted. """ return list(self.tickets) @@ -143,7 +153,8 @@ def sorted(self) -> list[Ticket]: The number is compared numerically rather than as text, so `CORE-2` precedes `CORE-10`. - Returns the ordered tickets. + Returns: + The ordered tickets. """ return sorted(self.tickets.values(), key=_sortKey) @@ -152,11 +163,13 @@ def filtered(self, status: Optional[str] = None, key: Optional[str] = None, prio """ Select tickets matching every supplied filter, in sorted order. - status: Keep only tickets with this status. - key: Keep only tickets carrying this key. - priorityMax: Keep only tickets at or below this priority number, meaning at or above this urgency. + Args: + status: Keep only tickets with this status. + key: Keep only tickets carrying this key. + priorityMax: Keep only tickets at or below this priority number, meaning at or above this urgency. - Returns the matching tickets. + Returns: + The matching tickets. """ matches: list[Ticket] = self.sorted() @@ -183,7 +196,8 @@ def __init__(self, config: Config) -> None: """ Bind a store to a configuration. - config: The configuration naming the ticket root and the status directories. + Args: + config: The configuration naming the ticket root and the status directories. """ self.config: Config = config @@ -196,9 +210,11 @@ def directoryFor(self, status: str) -> Path: `done` is the only status that moves a file, so everything else shares the todo directory. That rule is what keeps the vocabulary fixed rather than configurable. - status: The status to resolve. + Args: + status: The status to resolve. - Returns the absolute directory path. + Returns: + The absolute directory path. """ return self.config.donePath if status == STATUS_DONE else self.config.todoPath @@ -209,9 +225,11 @@ def pathFor(self, ticket: Ticket) -> Path: Filenames are frozen at creation, so an already-written ticket keeps its existing filename even after a retitle. Only the directory follows the status. - ticket: The ticket to place. + Args: + ticket: The ticket to place. - Returns the absolute file path. + Returns: + The absolute file path. """ # Reuse the existing filename when there is one, since retitling must not rename the file. @@ -223,7 +241,8 @@ def discoverPaths(self) -> list[Path]: """ List every candidate ticket file under both status directories. - Returns the paths, sorted so results are deterministic across platforms. + Returns: + The paths, sorted so results are deterministic across platforms. """ paths: list[Path] = [] @@ -239,7 +258,8 @@ def loadAll(self) -> TicketSet: """ Load every ticket under the configured root, holding the repository's read lock while doing so. - Returns the loaded `TicketSet`. + Returns: + The loaded `TicketSet`. """ # A caller already inside a write lock must not come through here, since the lock refuses to downgrade from writing to reading. Those callers use the unlocked form directly. @@ -250,9 +270,11 @@ def load(self, ticketId: str) -> Ticket: """ Load one ticket by id. - ticketId: The id to look up. + Args: + ticketId: The id to look up. - Returns the ticket. + Returns: + The ticket. """ return self.loadAll().get(ticketId) @@ -263,9 +285,11 @@ def write(self, ticket: Ticket) -> Ticket: This does not move an existing file. `setStatus` owns that, so no caller can change a status without the move happening in the same operation. - ticket: The ticket to write. + Args: + ticket: The ticket to write. - Returns the ticket with its path recorded. + Returns: + The ticket with its path recorded. """ path: Path = self.pathFor(ticket) @@ -292,13 +316,15 @@ def create( The id is derived by scanning what already exists, so the scan and the write are held together under one lock. Without that, two processes minting under one key read the same set and allocate the same number. - key: The key to mint under, which must be registered. - title: The ticket title, converted to title case before anything derives from it. - body: Prose for the body, placed under a heading built from the title. - requires: Ids this ticket depends on. - priority: The priority, defaulting to the configuration's `defaultPriority`. + Args: + key: The key to mint under, which must be registered. + title: The ticket title, converted to title case before anything derives from it. + body: Prose for the body, placed under a heading built from the title. + requires: Ids this ticket depends on. + priority: The priority, defaulting to the configuration's `defaultPriority`. - Returns the written ticket and any warnings. + Returns: + The written ticket and any warnings. """ # The filename slug derives from the title once, here, so an empty one is frozen into the filename as well as the field. @@ -346,14 +372,19 @@ def update( The load and the write back are held together under one lock, since a second process changing a different field in the gap would have its change reverted by this write. - ticketId: The ticket to change. - title: A new title, converted to title case, if any. - priority: A new priority, if any. - requires: A replacement dependency list, if any. - requiresAdd: Ids to append to the existing list, if any. - requiresRemove: Ids to drop from the existing list, if any. + Args: + ticketId: The ticket to change. + title: A new title, converted to title case, if any. + priority: A new priority, if any. + requires: A replacement dependency list, if any. + requiresAdd: Ids to append to the existing list, if any. + requiresRemove: Ids to drop from the existing list, if any. + + Returns: + The written ticket and any warnings. - Returns the written ticket and any warnings. + Raises: + ConflictingArgumentsError: A replacement dependency list cannot be combined with adding to or removing from the existing one. Pass either the replacement or the edits. """ # Replacing the list and editing it in place at once names no order the caller actually asked for. @@ -395,11 +426,13 @@ def setMetadata(self, ticketId: str, key: str, value: Optional[Any]) -> TicketRe The whole file is rewritten to change the one entry, so the load and the write back are held together under one lock. Two consumers namespacing their keys correctly would still lose one of the two without it. - ticketId: The ticket to change. - key: The metadata key, which cannot be empty. - value: The value to store, or `None` to remove the key. + Args: + ticketId: The ticket to change. + key: The metadata key, which cannot be empty. + value: The value to store, or `None` to remove the key. - Returns the written ticket and any warnings. + Returns: + The written ticket and any warnings. """ requireText(key, "metadata key") @@ -423,10 +456,12 @@ def setStatus(self, ticketId: str, status: str) -> Ticket: The move is a write followed by a delete, which is two steps no matter how each one is performed, so the lock is what stops a reader from seeing both files at once and reporting a duplicate id. - ticketId: The ticket to change. - status: The new status. + Args: + ticketId: The ticket to change. + status: The new status. - Returns the updated ticket. + Returns: + The updated ticket. """ # Reject an unrecognized status at the boundary rather than writing it and leaving `validate` to find it later. @@ -451,7 +486,8 @@ def usedKeys(self) -> dict[str, list[str]]: This is what `key reject` needs in order to refuse loudly and name the tickets standing in the way. - Returns keys mapped to their ticket ids. + Returns: + Keys mapped to their ticket ids. """ used: dict[str, list[str]] = {} @@ -470,7 +506,8 @@ def __loadAllUnlocked(self) -> TicketSet: This exists because the lock refuses to downgrade from writing to reading, so a mutator already holding the write lock cannot call `loadAll`. Every caller of this is either inside a write lock or is `loadAll` itself. - Returns the loaded `TicketSet`. + Returns: + The loaded `TicketSet`. """ result: TicketSet = TicketSet() @@ -496,7 +533,11 @@ def __requireValidPriority(self, priority: int) -> None: """ Reject a priority outside the configured band. - priority: The priority to check. + Args: + priority: The priority to check. + + Raises: + InvalidPriorityError: Priority {priority} is outside 0 through {self.config.maxPriority}. 0 is most urgent. """ if not 0 <= priority <= self.config.maxPriority: @@ -508,10 +549,12 @@ def __danglingWarnings(self, ticket: Ticket, existing: TicketSet) -> list[str]: This is a warning rather than an error, so an agent writing a batch out of order completes the batch. `validate` reports the same condition as an error once the batch is done. - ticket: The ticket whose dependencies are being checked. - existing: The set loaded before the write. + Args: + ticket: The ticket whose dependencies are being checked. + existing: The set loaded before the write. - Returns one warning per unknown id. + Returns: + One warning per unknown id. """ # The ticket may legitimately require something written earlier in this same batch, so check against the set loaded before the write. @@ -527,9 +570,11 @@ def _sortKey(ticket: Ticket) -> tuple[int, str, int]: The id's number is compared numerically rather than as text, so `CORE-2` precedes `CORE-10` instead of following it. - ticket: The ticket to order. + Args: + ticket: The ticket to order. - Returns a `(priority, key, number)` tuple. + Returns: + A `(priority, key, number)` tuple. """ key, number = parseId(ticket.id) diff --git a/src/docket/core/templating.py b/src/docket/core/templating.py index 818293a..a04ddcf 100644 --- a/src/docket/core/templating.py +++ b/src/docket/core/templating.py @@ -26,7 +26,8 @@ def buildEnvironment() -> Environment: Autoescaping is off because the output is markdown a person reads, and escaping it would corrupt the very syntax a brief may be teaching. An undefined name raises rather than rendering as nothing, so a template naming something the context does not carry fails here instead of reaching the reader as a hole in a sentence. - Returns the environment. + Returns: + The environment. """ return Environment(undefined=StrictUndefined, trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True, autoescape=False) @@ -36,9 +37,11 @@ def readDocument(name: str) -> str: """ Read a document shipped inside the package. - name: The document filename. + Args: + name: The document filename. - Returns the document text. + Returns: + The document text. """ return readPackageText(DOCS_DIRECTORY, name) @@ -50,10 +53,12 @@ def renderDocument(name: str, context: dict[str, object]) -> str: Reading and rendering are one step here because no caller has ever wanted one without the other, and keeping them together is what leaves each document's module holding only its own context. - name: The document filename. - context: The names the template renders against. + Args: + name: The document filename. + context: The names the template renders against. - Returns the rendered document. + Returns: + The rendered document. """ template: Template = buildEnvironment().from_string(readDocument(name)) diff --git a/src/docket/core/ticket.py b/src/docket/core/ticket.py index 6da0bd9..e990320 100644 --- a/src/docket/core/ticket.py +++ b/src/docket/core/ticket.py @@ -104,7 +104,8 @@ def toFrontmatter(self) -> dict[str, Any]: The explicit ordering is the whole mechanism, since dumping with sorting disabled follows insertion order. - Returns the ordered mapping. + Returns: + The ordered mapping. """ # Place the recognized fields first, in the documented order. @@ -127,7 +128,8 @@ def summary(self) -> dict[str, Any]: """ Build the summary form used by listings, which never carries the body. - Returns the summary mapping. + Returns: + The summary mapping. """ return { @@ -146,10 +148,12 @@ def _representFlowList(dumper: yaml.SafeDumper, data: FlowList) -> yaml.Node: """ Represent a `FlowList` as an inline YAML sequence. - dumper: The active dumper. - data: The list being represented. + Args: + dumper: The active dumper. + data: The list being represented. - Returns the sequence node. + Returns: + The sequence node. """ return dumper.represent_sequence("tag:yaml.org,2002:seq", list(data), flow_style=True) @@ -164,9 +168,14 @@ def requireKnownStatus(status: str) -> str: The vocabulary is closed, so every surface that accepts a status has the same check to make. It lives here beside the vocabulary itself rather than at each surface, the same way `requireKnownKey` sits beside the registry. - status: The status to check. + Args: + status: The status to check. - Returns the same status. + Returns: + The same status. + + Raises: + InvalidStatusError: Status '{status}' is not one of {', '.join(STATUSES)}. """ if status not in STATUSES: @@ -181,9 +190,14 @@ def splitFrontmatter(text: str) -> tuple[str, str]: The body is returned verbatim, including the blank line that conventionally follows the closing delimiter, so a round-trip reproduces the file exactly. - text: The full file text, with newlines already normalized to `\\n`. + Args: + text: The full file text, with newlines already normalized to `\\n`. + + Returns: + A `(frontmatterText, body)` pair. - Returns a `(frontmatterText, body)` pair. + Raises: + TicketParseError: The frontmatter block is missing or malformed. """ lines: list[str] = text.split("\n") @@ -206,10 +220,15 @@ def parseTicket(text: str, path: Optional[Path] = None) -> Ticket: Structural problems raise here. Rule violations such as an out-of-range priority or an unrecognized status do not, because reporting those is `validate`'s job and it needs the ticket loaded to do it. - text: The full file text. - path: Where the text came from, recorded on the result. + Args: + text: The full file text. + path: Where the text came from, recorded on the result. + + Returns: + The parsed `Ticket`. - Returns the parsed `Ticket`. + Raises: + TicketParseError: Frontmatter is not valid YAML: {error} """ # Normalize line endings so a CRLF checkout parses identically to an LF one. Files are always written back as LF. @@ -246,9 +265,11 @@ def serializeTicket(ticket: Ticket) -> str: """ Serialize a `Ticket` back to markdown. - ticket: The ticket to serialize. + Args: + ticket: The ticket to serialize. - Returns the full file text, with `\\n` newlines. + Returns: + The full file text, with `\\n` newlines. """ # Sorting must stay off, since the explicit field order in the mapping is what keeps diffs clean. @@ -270,10 +291,12 @@ def buildBody(title: str, body: Optional[str] = None) -> str: The file always leads with an H1, because that is the shape the format documents. A supplied body that already opens with its own H1 is used as-is, so a caller writing a complete document does not end up with two headings. - title: The ticket title, used for the heading when one is needed. - body: The prose to place under the heading, if any. + Args: + title: The ticket title, used for the heading when one is needed. + body: The prose to place under the heading, if any. - Returns the body text, including the leading blank line that follows the closing frontmatter delimiter. + Returns: + The body text, including the leading blank line that follows the closing frontmatter delimiter. """ # Trim surrounding blank lines so spacing is decided here rather than by the caller. diff --git a/src/docket/core/titles.py b/src/docket/core/titles.py index 2adc85f..fcd453c 100644 --- a/src/docket/core/titles.py +++ b/src/docket/core/titles.py @@ -61,9 +61,11 @@ def _transformWords(words: Iterable[str]) -> Iterator[str]: `textcase.title` alone is not usable here, because it capitalizes through `str.capitalize`, which lowercases the rest of the word and would turn `CLI` into `Cli`. A word is therefore left exactly as written whenever it carries capitalization or a digit of its own, which is what lets an acronym, a ticket id, and a version number survive a title that is otherwise rewritten. - words: The words the case split produced, in order. + Args: + words: The words the case split produced, in order. - Returns each word in the case it should carry. + Returns: + Each word in the case it should carry. """ ordered: list[str] = list(words) @@ -94,9 +96,11 @@ def toTitleCase(title: str) -> str: Runs of whitespace collapse to a single space and surrounding whitespace is dropped, since the split that finds the words is what removes them. - title: The title to convert. + Args: + title: The title to convert. - Returns the title in title case. + Returns: + The title in title case. """ return titleCase(title, boundaries=[textcase.SPACE], strip_punctuation=False) @@ -106,9 +110,11 @@ def isTitleCase(title: str) -> bool: """ Report whether a title is already in title case. - title: The title to test. + Args: + title: The title to test. - Returns `True` when converting the title would change nothing. + Returns: + `True` when converting the title would change nothing. """ return toTitleCase(title) == title diff --git a/src/docket/core/validate.py b/src/docket/core/validate.py index 189cd03..d20c878 100644 --- a/src/docket/core/validate.py +++ b/src/docket/core/validate.py @@ -63,7 +63,8 @@ def toDict(self) -> dict[str, Optional[str]]: """ Build the serializable form, used by the MCP surface. - Returns the finding as plain data. + Returns: + The finding as plain data. """ return { @@ -115,7 +116,8 @@ def toDict(self) -> dict[str, object]: """ Build the serializable form, used by the MCP surface. - Returns the report as plain data. + Returns: + The report as plain data. """ return { @@ -133,10 +135,12 @@ def validate(store: Store, ticketSet: Optional[TicketSet] = None) -> ValidationR """ Run every rule against a ticket set. - store: The store naming the configuration and the ticket root. - ticketSet: An already-loaded set, loaded here when omitted. + Args: + store: The store naming the configuration and the ticket root. + ticketSet: An already-loaded set, loaded here when omitted. - Returns the report. + Returns: + The report. """ loaded: TicketSet = ticketSet if ticketSet is not None else store.loadAll() @@ -168,9 +172,11 @@ def _checkLoadFailures(ticketSet: TicketSet) -> list[Finding]: """ Report files under a status directory that could not be read as tickets. - ticketSet: The loaded set. + Args: + ticketSet: The loaded set. - Returns the findings. + Returns: + The findings. """ return [ @@ -185,9 +191,11 @@ def _checkDuplicateIds(ticketSet: TicketSet) -> list[Finding]: This is the collision two branches minting under the same key produce, and catching it here at merge time is the accepted cost of having no counter file. - ticketSet: The loaded set. + Args: + ticketSet: The loaded set. - Returns the findings. + Returns: + The findings. """ return [ @@ -206,10 +214,12 @@ def _checkDependencies(ticket: Ticket, ticketSet: TicketSet) -> list[Finding]: """ Report a `requires` entry naming an id that does not exist. - ticket: The ticket to check. - ticketSet: The loaded set to resolve against. + Args: + ticket: The ticket to check. + ticketSet: The loaded set to resolve against. - Returns the findings. + Returns: + The findings. """ return [ @@ -229,10 +239,12 @@ def _checkKey(ticket: Ticket, config: Config) -> list[Finding]: """ Report a ticket whose key is not registered. - ticket: The ticket to check. - config: The configuration holding the key registry. + Args: + ticket: The ticket to check. + config: The configuration holding the key registry. - Returns the findings. + Returns: + The findings. """ if config.isRegisteredKey(ticket.key): @@ -255,9 +267,11 @@ def _checkFilename(ticket: Ticket) -> list[Finding]: The slug is deliberately not checked, because filenames are frozen at creation and a retitle is expected to leave the slug stale. - ticket: The ticket to check. + Args: + ticket: The ticket to check. - Returns the findings. + Returns: + The findings. """ if ticket.path is None: @@ -285,10 +299,12 @@ def _checkStatusDirectory(ticket: Ticket, store: Store) -> list[Finding]: The status field is the truth and the directory is a projection of it, so this catches a file a human moved by hand. - ticket: The ticket to check. - store: The store resolving a status to its directory. + Args: + ticket: The ticket to check. + store: The store resolving a status to its directory. - Returns the findings. + Returns: + The findings. """ if ticket.path is None: @@ -317,10 +333,12 @@ def _checkPriority(ticket: Ticket, config: Config) -> list[Finding]: """ Report a priority outside the configured band. - ticket: The ticket to check. - config: The configuration holding the ceiling. + Args: + ticket: The ticket to check. + config: The configuration holding the ceiling. - Returns the findings. + Returns: + The findings. """ if 0 <= ticket.priority <= config.maxPriority: @@ -341,9 +359,11 @@ def _checkStatus(ticket: Ticket) -> list[Finding]: """ Report a status outside the fixed vocabulary. - ticket: The ticket to check. + Args: + ticket: The ticket to check. - Returns the findings. + Returns: + The findings. """ if ticket.status in STATUSES: @@ -368,9 +388,11 @@ def _checkTitleCase(ticket: Ticket) -> list[Finding]: Every write goes through the conversion, so what this catches is a file edited by hand and a ticket written before the rule existed. The corrected title travels in the message, which is what lets a human operator or an agent fix it without working out the convention first. - ticket: The ticket to check. + Args: + ticket: The ticket to check. - Returns the findings. + Returns: + The findings. """ if isTitleCase(ticket.title): @@ -391,9 +413,11 @@ def _checkCycles(graph: ResolvedGraph) -> list[Finding]: """ Report every dependency cycle. - graph: The resolved graph to search. + Args: + graph: The resolved graph to search. - Returns the findings. + Returns: + The findings. """ findings: list[Finding] = [] diff --git a/src/docket/server.py b/src/docket/server.py index af9ffb0..85f2837 100644 --- a/src/docket/server.py +++ b/src/docket/server.py @@ -75,9 +75,10 @@ async def listTickets(status: Optional[str] = None, key: Optional[str] = None, p Returns id, title, status, priority, and key for each match. Never returns bodies, so listing many tickets stays cheap. Call `read_ticket` for the body of one. - status: Keep only tickets with this status. One of todo, wip, done. - key: Keep only tickets carrying this key. - priority_max: Keep only tickets at or below this priority number. 0 is most urgent. + Args: + status: Keep only tickets with this status. One of todo, wip, done. + key: Keep only tickets carrying this key. + priority_max: Keep only tickets at or below this priority number. 0 is most urgent. """ store: Store = _store() @@ -93,7 +94,8 @@ async def readTicket(id: str) -> str: The raw file stores bare ids in one direction only, so this adds what the file deliberately does not duplicate: the title and status of everything this ticket requires, and the whole reverse direction of everything that requires it. A dependency naming a ticket that does not exist is returned with `exists` false rather than being hidden. - id: The ticket id, for example CORE-14. + Args: + id: The ticket id, for example CORE-14. """ store: Store = _store() @@ -121,7 +123,8 @@ async def checkReady(id: str) -> str: A ticket that is itself done is never ready, because there is no work left to be ready for. That case returns an empty `blocked_by`, so an empty list alongside `ready` false means finished rather than unblocked. - id: The ticket id, for example CORE-14. + Args: + id: The ticket id, for example CORE-14. """ store: Store = _store() @@ -147,11 +150,12 @@ async def createTicket( A `requires` entry naming a ticket that does not exist yet is a warning rather than a failure, so a batch written out of order still completes. Call `validate` once the batch is done. - key: The key to mint under, for example CORE. - title: The ticket title, which cannot be empty. The filename derives from this once, at creation, and never changes afterwards. - body: Markdown prose for the body, placed under a heading built from the title. - requires: Ids this ticket depends on. - priority: 0 is most urgent. Defaults to the repository's configured default. + Args: + key: The key to mint under, for example CORE. + title: The ticket title, which cannot be empty. The filename derives from this once, at creation, and never changes afterwards. + body: Markdown prose for the body, placed under a heading built from the title. + requires: Ids this ticket depends on. + priority: 0 is most urgent. Defaults to the repository's configured default. """ result: TicketResult = _store().create(key=key, title=title, body=body, requires=requires, priority=priority) @@ -175,12 +179,13 @@ async def updateTicket( The dependency list is edited either wholesale or in place, never both in one call. Passing `requires` alongside `requires_add` or `requires_remove` is refused, since it asks for two contradictory things at once. - id: The ticket id. - title: A new title, which cannot be empty. - priority: A new priority. 0 is most urgent. - requires: A replacement dependency list. Pass an empty list to clear it. - requires_add: Ids to append to the existing list. One already there is not duplicated. - requires_remove: Ids to drop from the existing list. One that is not there is ignored. + Args: + id: The ticket id. + title: A new title, which cannot be empty. + priority: A new priority. 0 is most urgent. + requires: A replacement dependency list. Pass an empty list to clear it. + requires_add: Ids to append to the existing list. One already there is not duplicated. + requires_remove: Ids to drop from the existing list. One that is not there is ignored. """ result: TicketResult = _store().update(ticketId=id, title=title, priority=priority, requires=requires, requiresAdd=requires_add, requiresRemove=requires_remove) @@ -196,9 +201,10 @@ async def setMetadata(id: str, key: str, value: Optional[Any] = None) -> str: `metadata` is free-form, shared by whatever tools or skills want to attach data to a ticket. Namespace your key, for example `video`, so your entries never collide with another consumer's. Only the named key is touched, every other entry is left as it was. - id: The ticket id. - key: The metadata key, which cannot be empty. - value: The value to store, any JSON-compatible type. Omit or pass null to remove the key instead. + Args: + id: The ticket id. + key: The metadata key, which cannot be empty. + value: The value to store, any JSON-compatible type. Omit or pass null to remove the key instead. """ result: TicketResult = _store().setMetadata(ticketId=id, key=key, value=value) @@ -213,8 +219,9 @@ async def setStatus(id: str, status: str) -> str: Never move a ticket file by hand. The status field is the truth and the directory is a projection of it, and only this tool keeps the two in step. - id: The ticket id. - status: One of todo, wip, done. Only done moves the file into the done directory. + Args: + id: The ticket id. + status: One of todo, wip, done. Only done moves the file into the done directory. """ ticket: Ticket = _store().setStatus(id, status) @@ -229,9 +236,10 @@ async def graphTool(id: Optional[str] = None, key: Optional[str] = None, status: Arrows point from a dependency to what depends on it, so an arrow reads as "must happen before". With no argument the whole set is rendered. - id: Scope to one ticket's transitive ancestors and descendants. - key: Scope to one key, plus its immediate cross-key neighbors, which are marked so the boundary is visible. - status: Scope to the tickets with this status alone, one of todo, wip, done. Nothing outside it is borrowed, so an edge survives only when both of its ends carry the status. + Args: + id: Scope to one ticket's transitive ancestors and descendants. + key: Scope to one key, plus its immediate cross-key neighbors, which are marked so the boundary is visible. + status: Scope to the tickets with this status alone, one of todo, wip, done. Nothing outside it is borrowed, so an edge survives only when both of its ends carry the status. """ store: Store = _store() @@ -264,9 +272,10 @@ async def addKey(key: str, description: str, rationale: str) -> str: The key is written into the repository's configuration, where it shows up in the git diff. - key: The new key. Uppercase alphanumeric, starting with a letter, for example META. - description: What this key groups, shown alongside the other keys. It cannot be empty. - rationale: Why a new key is needed. This is written as a comment above the key, so a later reader sees the reasoning. + Args: + key: The new key. Uppercase alphanumeric, starting with a letter, for example META. + description: What this key groups, shown alongside the other keys. It cannot be empty. + rationale: Why a new key is needed. This is written as a comment above the key, so a later reader sees the reasoning. """ _config().addKey(key=key, description=description, rationale=rationale) @@ -292,9 +301,11 @@ def main(argv: Optional[list[str]] = None) -> int: """ Entry point for the `docket-mcp` console script. - argv: Argument list, accepted for symmetry with the CLI and currently unused. + Args: + argv: Argument list, accepted for symmetry with the CLI and currently unused. - Returns the process exit code. + Returns: + The process exit code. """ # `MCPServer.run` owns the event loop, so no async runtime is imported here. @@ -312,7 +323,8 @@ def _config() -> Config: This resolves per call rather than once at startup, so a key approved while the server is running is picked up without a restart. - Returns the loaded `Config`. + Returns: + The loaded `Config`. """ return discoverConfig() @@ -322,7 +334,8 @@ def _store() -> Store: """ Build a store over the configuration governing the working directory. - Returns the store. + Returns: + The store. """ return Store(_config()) @@ -334,9 +347,11 @@ def _json(payload: Any) -> str: Every tool returns JSON as text, which is unambiguous for the model to parse and stable to assert on in tests. It is written compactly, since an agent pays for every token of it. - payload: The data to encode. + Args: + payload: The data to encode. - Returns the encoded text. + Returns: + The encoded text. """ return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) From ba8ab6a99761baa32052838dd0e47e31984021e8 Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Fri, 25 Sep 2026 19:20:17 -0400 Subject: [PATCH 3/5] Instructions modification --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index e0ec290..708aa53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ Match existing style exactly: - camelCase for functions, variables, params (`updateRootBranches`, `doTheThing`). NOT snake_case. Repo-wide, intentional, keep it. - `# MARK: Imports` / `# MARK: Constants` / `# MARK: Functions` / `# MARK: Classes` section headers in every module. - Module docstring: title line, blank line, one-line description. -- Function docstrings: description, blank line, then `paramName: description.` lines (no Sphinx/Google style), then `Returns ...` sentence. Backticks around code refs. +- Function docstrings are in Google Style. Backticks around code refs. Backticks around code refs. - Inline comment above nearly every logical block, short imperative ("# Stash the changes"). - Full type hints everywhere. `Optional[X]` / `Union[X, Y]` from typing, builtin generics (`list[str]`, `dict[str, Any]`). - Private helpers: `_name` or `__name` prefix. From f1923fbb37dbaf99c507821800c7a4bd452feeab Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Fri, 25 Sep 2026 19:27:16 -0400 Subject: [PATCH 4/5] Added pdoc for local API docs --- .gitignore | 1 + .vscode/tasks.json | 6 ++++++ CONTRIBUTING.md | 15 ++++++++++++++- pyproject.toml | 1 + uv.lock | 26 ++++++++++++++++++++++++++ 5 files changed, 48 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index eb617a5..68377a3 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ wheels/ .claude/skills/video-planner/ .videos/ graph.mmd +docs/pdoc/ scripts/*.ps1 scripts/*.cmd diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 2d50cc0..74625c6 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -8,6 +8,12 @@ "type": "shell", "command": "docket graph todo -o .\\graph.mmd; docket docs roadmap todo", "problemMatcher": [] + }, + { + "label": "Build API Docs", + "type": "shell", + "command": "uv run pdoc -d google -o docs/pdoc docket", + "problemMatcher": [] } ] } \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61bf3ac..06492be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,7 @@ Docket runs on Docket. * [Issues and Tickets](#issues-and-tickets) * [Getting Set Up](#getting-set-up) * [Code Style](#code-style) + * [API Docs](#api-docs) * [Naming Across Interfaces](#naming-across-interfaces) * [Versioning](#versioning) * [Tests](#tests) @@ -65,7 +66,7 @@ Match the surrounding code exactly: - **camelCase** for functions, variables, and parameters, never snake_case. - **`# MARK:` section headers** in every module: `Imports`, `Constants`, `Functions`, `Classes`, in that order. - **Module docstrings**: title line, blank line, one line description. -- **Function docstrings**: description, blank line, `paramName: description.` lines, then a `Returns ...` sentence. No Sphinx or Google style. Backticks around code references. +- **Function docstrings**: Google Style, with `Args:`, `Returns:`, and `Raises:` sections as needed. Backticks around code references. - **A short imperative comment above nearly every logical block.** "Stash the changes", not a paragraph. - **Full type hints everywhere.** `Optional[X]` and `Union[X, Y]` from `typing`, builtin generics like `list[str]`. - **Private helpers** are prefixed `_name` or `__name`. @@ -76,6 +77,18 @@ Match the surrounding code exactly: Scripts under `scripts/` are standalone and import nothing from `docket`. Keep them that way. +### API Docs + +The docstrings render into browsable API docs with [pdoc](https://pdoc.dev). +They are for contributors only, are never hosted, and are git ignored. + +```bash +uv run pdoc -d google docket # live server with reload +uv run pdoc -d google -o docs/pdoc docket # static HTML into docs/pdoc/ +``` + +The `Build API Docs` VS Code task runs the static build. + ## Naming Across Interfaces Two external interfaces deliberately break camelCase, and neither convention leaks into the other. diff --git a/pyproject.toml b/pyproject.toml index ff3aa83..ce9fa32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ testpaths = ["tests"] [dependency-groups] dev = [ + "pdoc>=16.0.0", "pytest>=9.1.1", "twine>=6.1.0", ] diff --git a/uv.lock b/uv.lock index 9e3d3f2..b2c204f 100644 --- a/uv.lock +++ b/uv.lock @@ -461,6 +461,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "markdown2" +version = "2.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/ae/07d4a5fcaa5509221287d289323d75ac8eda5a5a4ac9de2accf7bbcc2b88/markdown2-2.5.5.tar.gz", hash = "sha256:001547e68f6e7fcf0f1cb83f7e82f48aa7d48b2c6a321f0cd20a853a8a2d1664", size = 157249, upload-time = "2026-03-02T20:46:53.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/af/4b3891eb0a49d6cfd5cbf3e9bf514c943afc2b0f13e2c57cc57cd88ecc21/markdown2-2.5.5-py3-none-any.whl", hash = "sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941", size = 56250, upload-time = "2026-03-02T20:46:52.032Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -635,6 +644,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pdoc" +version = "16.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown2" }, + { name = "markupsafe" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/fe/ab3f34a5fb08c6b698439a2c2643caf8fef0d61a86dd3fdcd5501c670ab8/pdoc-16.0.0.tar.gz", hash = "sha256:fdadc40cc717ec53919e3cd720390d4e3bcd40405cb51c4918c119447f913514", size = 111890, upload-time = "2025-10-27T16:02:16.345Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/a1/56a17b7f9e18c2bb8df73f3833345d97083b344708b97bab148fdd7e0b82/pdoc-16.0.0-py3-none-any.whl", hash = "sha256:070b51de2743b9b1a4e0ab193a06c9e6c12cf4151cf9137656eebb16e8556628", size = 100014, upload-time = "2025-10-27T16:02:15.007Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1114,6 +1138,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pdoc" }, { name = "pytest" }, { name = "twine" }, ] @@ -1132,6 +1157,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "pdoc", specifier = ">=16.0.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "twine", specifier = ">=6.1.0" }, ] From df84379bc40c08ed8dcc1195e8eba73ac7dceaf9 Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Fri, 25 Sep 2026 19:29:25 -0400 Subject: [PATCH 5/5] Finished FEAT-19 --- .../FEAT-19_addGoogleStyleDocumentationComments.md | 2 +- src/docket/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename docs/tickets/{todo => done}/FEAT-19_addGoogleStyleDocumentationComments.md (95%) diff --git a/docs/tickets/todo/FEAT-19_addGoogleStyleDocumentationComments.md b/docs/tickets/done/FEAT-19_addGoogleStyleDocumentationComments.md similarity index 95% rename from docs/tickets/todo/FEAT-19_addGoogleStyleDocumentationComments.md rename to docs/tickets/done/FEAT-19_addGoogleStyleDocumentationComments.md index 1e24116..9a64f9d 100644 --- a/docs/tickets/todo/FEAT-19_addGoogleStyleDocumentationComments.md +++ b/docs/tickets/done/FEAT-19_addGoogleStyleDocumentationComments.md @@ -1,7 +1,7 @@ --- id: FEAT-19 title: Add Google Style Documentation Comments -status: todo +status: done priority: 1 requires: [] metadata: {} diff --git a/src/docket/__init__.py b/src/docket/__init__.py index 799bdaf..fffbf71 100644 --- a/src/docket/__init__.py +++ b/src/docket/__init__.py @@ -8,4 +8,4 @@ # No type check to comply with hatch's requirements. # Do not re-add. -__version__ = "1.5.0" +__version__ = "1.6.0"