diff --git a/.github/workflows/code-checks.yaml b/.github/workflows/code-checks.yaml index f5b8ee58..fe646021 100644 --- a/.github/workflows/code-checks.yaml +++ b/.github/workflows/code-checks.yaml @@ -24,7 +24,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - ref: ${{ inputs.branch_ref }} + ref: ${{ inputs.branch_ref || github.ref }} - name: Build containers run: ./bin/dev-build-containers.sh @@ -35,5 +35,8 @@ jobs: - name: Check types run: ./bin/dev-check-semantics.sh + - name: Run tests + run: ./bin/dev-tests.sh + - name: Check runtime run: ./bin/dev-check-runtime.sh diff --git a/AGENTS.md b/AGENTS.md index df47c7d7..92759060 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,104 @@ # Instructions for the AI Agents -This document provides instructions and guidelines for the AI agents working on this project. +This document provides instructions and guidelines for the AI agents working on `donna`. -Every agent MUST follow the rules and guidelines outlined in this document when performing their work. +Every agent MUST follow these instructions. -## Donna tool +## Project Overview -Since this is the repository that contains the Donna project itself, you have direct access to the Donna CLI tool via `./bin/donna.sh` script. I.e. you develop Donna using Donna. +`donna` is a CLI tool that helps agents run predefined workflows in deterministic way. -In all commands that use `donna`, you MUST replace `donna` with `./bin/donna.sh` when you run the command. +Workflow is a state-machine / non-linear graph of operations that guides the agent's work. Each operation can run code, output text, provide instructions for the agent (to execute them and report back to Donna), or do other things. The agent's task is to follow the instructions of the operations and report back to Donna about the next operation to run. -For example, instead of `donna artifacts list` you MUST run `./bin/donna.sh artifacts list`. +Donna maintains the state of the workflow and the stack of operations. + +So, you may look at `donna` as a Virtual Machine for agents, where agent is just one of the possible execution contexts, and workflows are programs that run in this VM. + +## Source Of Truth + +Project behavior and architecture are specified in `./specs/`. + +Agents MUST read the relevant specifications before making changes. + +Start from `./specs/intro.md` to find the relevant specification documents. + +When adding, deleting, or significantly changing a specification, agents MUST update `./specs/intro.md`. + +Agents MUST NOT create new specifications without explicit instructions. + +Agents MUST NOT delete or significantly change existing specifications without explicit instructions. + +## Development Environment + +All development-related operations MUST be performed in Docker containers. + +Agents MUST NOT perform development-related operations directly on the host machine. + +Allowed development commands: + +- `./bin/dev-tests.sh` — run all Python tests inside the container. +- `./bin/dev.sh` — run development utilities inside the container, for example `./bin/dev.sh uv run pytest`. +- `./bin/dev-build-containers.sh` — build base Docker images for development; use only after approved Docker or dependency changes. + +Searching, reading, and editing repository files MAY be done on the host machine. + +## Restricted Changes And Operations + +Agents MUST NOT perform these operations without explicit permission: + +- Change `docker-compose.yml` or Docker-related configuration. +- Change Docker runtime parameters such as resources or volumes. +- Change running Docker services unrelated to this project. +- Install new dependencies. +- Update lock files. +- Install new tools, utilities, or software on the host machine or in development containers. +- Change project structure by moving files or creating new top-level directories. + +If one of these operations seems necessary, agents MUST ask for explicit permission before doing it. + +## Implementation Guidance + +Follow existing specifications and local project patterns. + +Keep changes scoped to the requested task. + +Do not implement behavior that is only mentioned as future or possible functionality unless explicitly requested. + +When code is added, tests SHOULD follow `./specs/architecture/tests.md`. + +When entities, errors, warnings, or module layout are affected, agents MUST check the corresponding architecture specs. ## Top priority tools These tools MUST have the highest priority when an agent is deciding which tool to use for a given task: +### `donna` + +We use Donna to develop Donna. We use the current development version of Donna. For convinience, use shortcut `./bin/donna.sh` to run current development version of Donna. + +Use Donna to run workflows. + +You may need to read the usage intructions for `donna`: `./bin/donna.sh -p llm skill usage` in these cases: + +- You need to run a workflow first time in the session. +- You need to list available workflows first time in the session. + +You run workflows only when explicitly instructed to do so by a developer or Donna itself. You MUST run workflows in that cases. + +Donna is configured to log significant operation steps via `task` tool. + +### `depmesh` + +`depmesh` — a tool for discovering dependencies between project artifacts. + +Agents MUST use `depmesh` for dependency types supported by its configuration. + +At the start of each work session, read the `depmesh` usage instructions for details: + +```bash +depmesh skill usage +``` + ### `ast-grep` `ast-grep` — a tool for searching and manipulating Abstract Syntax Trees in code. Use it when you work with particular code patterns, structures, or constructs in the codebase. @@ -81,3 +164,15 @@ You MUST NOT log: - CLI commands you execute. - Elementary or trivial steps. + +You can read the logged journal with: + +```bash +./bin/journal-tail.py --lines 20 +``` + +### `rg` + +Use `rg` for text and file searches unless a structural code query is needed. + +`ast-grep` has a higher priority than `rg` whenever a structural code query is needed. diff --git a/README.md b/README.md index 9c2816a9..b2bf0cb7 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,111 @@ +# Donna -**Donna is in deep refactoring due to a rethink of the core concepts. You can safely use the `0.3.0` version, but later versions will introduce significant breaking changes. Be careful with introducing it into your core workflows.** +**A CLI tool that helps agents keep long-running work on a predefined path.** -# FSM Driven Development +Donna exists because agent work has a control-flow problem: -Your agent will generate [state machines](https://en.wikipedia.org/wiki/Finite-state_machine) while executing state machines that are generated by state machines. +1. Most development work is repetitive on the meta level: "run this tool, do something with the output, run another tool" or "implement function A, implement tests for function A, implement function B, …". +2. Some parts of that work require advanced reasoning, others do not. +3. Agents are ~~almost~~ good at reasoning, but not so good at keeping the whole process in mind, remembering what they did, etc. +4. Therefore, we should separate the reasoning part from the control flow part — let agents focus on what they are good at, and keep the control flow to traditional automation tools. -Donna allows your agent to execute hundreds of consecutive steps without swaying away from the defined workflow. Branching, loops, nested calls, and recursion — all possible. +**Donna runs predefined workflows as deterministic state machines, so the agent can focus on reasoning, code generation, and other agentic work.** -## What is Donna? +You define a workflow in a single readable Markdown file. The agent asks Donna to guide it through the workflow, and Donna keeps the session state, chooses the next operation, and tells the agent what to do or report next. -Donna is a CLI tool that helps coding agents like Codex focus on the task at hand by keeping high-level control flow in explicit Donna workflows. Donna dictates what should be done at each step of the work, so the agent can focus on the actual piece. +Workflows can start child workflows, be generated on the fly, or be modified while executing. For example, you can have a workflow that guides the agent through the planning process, and at the final step, the agent can generate a new workflow with a detailed plan to execute and run it immediately. -However, Donna is not an orchestrator; it is a tool for an agent — it can be used with any agent and does not require API keys or other credentials. You may look at Donna as the work diary or personal secretary for your agent. - -The core idea: - -- **Most high-level work is more algorithmic than it may seem at first glance.** -- **Most low-level work is less algorithmic than it may seem at first glance.** - -For example, it may be non-trivial to fix a particular unit test, but the overall process of polishing the codebase is quite linear: - -1. Ensure all tests pass. -2. Ensure the code is formatted correctly. -3. Ensure there are no linting errors. -4. Go to step 1 if you changed something in the process. -5. Finish. - -Coding agents show great results at each step of the process; however, they often struggle to manage meta-loops — they forget steps, misinterpret results, use the wrong approaches to run tools, etc. - -Donna executes such loops for the agents, thereby saving time, context, and tokens and simultaneously increasing the overall quality of work. +As a bonus, **Donna saves tokens** because the agent does not need to reason about control flow or how to execute particular CLI commands and other automation tools. ## Features -- **Deterministic workflows** — define fixed & validated control flow for agents to follow. -- **Saves context, tokens and time** — agents do not need to think when thinking is not required. -- **Readable artifacts** — all workflows and documentation are pure Markdown files with some [Jinja2](https://github.com/pallets/jinja) templating. -- **Artifact management** — non-fuzzy navigation and smart agent-focused rendering of artifacts. -- **Agent-centric behavior** — Donna guides agents through workflows, helps them be on the path, and fixes mistakes. -- **Extensible architecture** — implement your own operations, validators, renderers, and directives. -- **Project-local workflows** — keep reusable work procedures in your repository and adjust them to your team. +- **Pure CLI tool** — No API keys, hosted services, or separate agent instances required. +- **Deterministic control flow** — Donna follows explicit workflow transitions instead of relying on the agent's memory. +- **Agent-aware automation** — Scripted steps run automatically; agent work is requested when needed. +- **Nested workflows** — Workflows can start child workflows, generate new ones, or delegate workflow selection to the agent. +- **Readable workflow sources** — Each workflow is a single Markdown file with a clear structure. +- **Built-in help for agents** — Your agent can run `donna skill` to get detailed docs written for agents. +- **Local session state** — Workflow progress stays inspectable and resumable inside the project. +- **Progress journaling** — Workflow progress can be logged through a configured external command. ## Example -Donna is developed via Donna itself. You can find real-life workflow examples in [workflows](./workflows) and documentation in [specs](./specs). +I use Donna to develop Donna itself — you can find real examples of workflows in the [./workflows](./workflows) folder of this repository. You can start with [./workflows/polish.donna.md](./workflows/polish.donna.md) that loops over fixing issues found by formatters, linters, type checkers and tests until the codebase is polished. + +Below you'll find a simplified workflow that checks the current time, asks the agent whether it is time to drink tea, and branches according to the agent's answer. -The example below is a simplified version of the polishing workflow that formats code, runs linters, and fixes found problems until all checks pass. It uses the single operation type `donna.lib.request_action` to ask the agent to perform specific instructions. +Here is its schema: ``` - no issues -[ run_black ] ──▶ [ run_mypy ] ───────────▶ [ finish ] - ▲ │ - │ issues fixed │ - └────────────────┘ +[Get Current Time] + | + v + [Ask About Tea] + | + +----+----+ + | | + yes no + | | + v | + [Turn On | + Kettle] | + | | + +----+----+ + | + v + [Finish] + ``` -
-An example source and comments +Actual workflow code: + +````markdown +# Is it time to drink tea? + +This workflow checks the current time, asks the agent whether it is tea time, +and branches on the answer. -~~~ -# Polishing Workflow +## Get Current Time ```toml donna -kind = "donna.lib.workflow" -start_operation_id = "run_black" +id = "get_current_time" +kind = "donna.lib.run_script" +save_stdout_to = "current_time" +goto_on_success = "ask_about_tea" +goto_on_failure = "finish" ``` -Polish and refine the codebase. +```bash donna script +#!/usr/bin/env bash +date +%H:%M +``` -## Run Black +## Ask About Tea ```toml donna -id = "run_black" +id = "ask_about_tea" kind = "donna.lib.request_action" ``` -1. Run `black .` to format the codebase. -2. `{{ goto("run_mypy") }}` +The current time is: + +```text +{{ donna.lib.task_variable("current_time") }} +``` -## Run Mypy +Is it time to drink tea? + +1. If yes, `{{ donna.lib.goto("turn_on_kettle") }}`. +2. If no, `{{ donna.lib.goto("finish") }}`. + +## Turn On Kettle ```toml donna -id = "run_mypy" +id = "turn_on_kettle" kind = "donna.lib.request_action" ``` -1. Run `mypy .` to check the codebase for type annotation issues. -2. If there are issues found that you can fix, fix them. -3. Ask the developer to fix any remaining issues manually. -4. If you made changes `{{ goto("run_black") }}`. -5. If no issues are found `{{ goto("finish") }}`. +Turn on the kettle, then `{{ donna.lib.goto("finish") }}`. ## Finish @@ -97,315 +114,245 @@ id = "finish" kind = "donna.lib.finish" ``` -Polishing is complete. -~~~ +The workflow is complete. You are a good butler. +```` -What you may notice: - -- The workflow is described in a readable Markdown file. -- The workflow has a loop. -- Each H1 and H2 section has a config block, which is a TOML in code fences with `donna` marker. Those configs are invisible to the agent, but Donna uses them to understand the artifact structure. -- H1 section describes the workflow as a whole. -- H2 sections describe workflow operations. -- The workflow has two `donna.lib.request_action` operations (`run_black`, `run_mypy`) and one `donna.lib.finish` (`finish`). -- Transitions between operations are defined via `{{ goto("operation_id") }}` Jinja2 calls in the body of operations. -- `donna.lib.request_action` is an operation that tells Donna to display instructions to the agent and wait for the agent to complete them. That allows the agent to focus on short, precise instructions, execute them, and advance the workflow. -- `kind` attributes of sections are valid Python import paths, so you can easily extend Donna with your own code. +
+ How it works -Directives, like `{{ goto("operation_id") }}`, render itself depending on the context: +How to read workflow's source: -- For the agent, they render an exact CLI command to run, such as `donna -p llm complete-action-request '@/workflows/polish.donna.md:finish'`. -- For Donna, they render a specific marker that can be extracted and used to analyze an artifact. For example, Donna uses `goto` directives to build an FSM of the workflow and validate it before running: does each operation exist, can the workflow be completed, are there unreachable operations, etc. +- The H1 section is the workflow section. It gives Donna the workflow title and summary that appear in `donna list`. +- Each H2 section is an operation section. Donna runs operations in the order selected by the workflow state machine. +- `toml donna` blocks configure section id, type and behavior. Here, the operation types are `donna.lib.run_script`, `donna.lib.request_action`, and `donna.lib.finish`. +- `Get Current Time` is a `run_script` operation. Donna runs the shell script from the project root, saves stdout as `current_time`, and moves to `ask_about_tea` on success. No agent interaction required here, because it is pure deterministic work. +- `Ask About Tea` is a `request_action` operation. The agent will see a request for action with the rendered current time, the question, and the two allowed transitions: `turn_on_kettle` or `finish`. +- `donna.lib.task_variable("current_time")` is rendered as the value captured from the script output. +- `donna.lib.goto(...)` is rendered as a concrete CLI command to execute. +- `Turn On Kettle` is another `request_action` operation. The agent will see the instruction to turn on the kettle and then complete the action request with the `finish` transition. +- `Finish` is a `finish` operation. The agent will see the final workflow message, and Donna will complete the workflow task. -Generally speaking, **all you need is `donna.lib.request_action` operation** — it is enough to achieve a great deal of automation by delegating some decisions to the agent. However, there are some more specific operations that simplify things and make workflows more agile or performant. +Here is an example of action request for the `Get Current Time` operation: -
+````markdown +--DONNA-CELL OuP2T9brQYmvESMHsbrDlw BEGIN-- +kind=session_state_status +media_type=text/markdown +pending_action_requests=1 +queued_work_units=0 +tasks=1 -You can find a more complex implementation of the same workflow in the [polish.donna.md](./workflows/polish.donna.md) file. It demonstrates other Donna operations, such as running scripts directly and branching. +The session is AWAITING YOUR ACTION. You have pending action requests to address. -## Installation +- If the developer asked you to start working on a new task, you MUST ask if you should start a new session + or continue working on the current action requests. +- Otherwise, you MUST address the pending action requests before proceeding. +--DONNA-CELL OuP2T9brQYmvESMHsbrDlw END-- -1. Install `donna` package. +--DONNA-CELL fxh3PDZ1Qy-c3zvai_T5Qw BEGIN-- +kind=action_request +media_type=text/markdown +action_request_id=AR-11-l -```bash -uv tool install donna +**This is an action request for the agent. You MUST follow the instructions below.** -# pipx install donna -``` +The current time is: -2. Initialize Donna in your project. +```text +19:31 -```bash -cd -donna init ``` -Donna will create `donna.toml` in your project root. The configured session directory is created lazily by runtime commands. +Is it time to drink tea? -3. Ask your agent to do something like `$donna-do Add a button that …`. The agent will discover the appropriate workflow and execute it. +1. If yes, `donna -p llm --config '/home/tiendil/repos/mine/donna/.session/readme-example-test/donna.toml' complete-action-request '@/workflows/tea.donna.md:turn_on_kettle'`. +1. If no, `donna -p llm --config '/home/tiendil/repos/mine/donna/.session/readme-example-test/donna.toml' complete-action-request '@/workflows/tea.donna.md:finish'`. +--DONNA-CELL fxh3PDZ1Qy-c3zvai_T5Qw END-- -## Skills +```` -- `donna-do` — use Donna to perform a specific task in the current Donna session. Creates a new session if there is no one. -- `donna-start` — start a new Donna session and tell the agent to use Donna to perform all further work. Removes all content from the previous session. -- `donna-stop` — stop using Donna to perform work — the agent should switch to its own flow control. - -## Usage +
-**Donna is a CLI tool for agents.** You rarely need to use it directly. +Since Donna is a CLI tool, you can run the workflow manually in the terminal. -Commands you may need: +```bash +uv tool install donna -- `donna init` — Initialize Donna in your project. -- `donna start` — start a new working session, remove everything from the previous session. -- `donna list` — list workflows with short descriptions. +# Example workflow is a part of Donna repository +# git clone git@github.com:Tiendil/donna.git +# cd donna -Donna can send internal journal records to a third-party tool. Configure it in `donna.toml`: +donna run @/workflows/examples/time_to_drink_tea.donna.md -```toml -[journal] -cmd = ["cli-tool", "--message", "{message}"] +# Follow Donna commands to finish the workflow. ``` -`cmd` is a list of command arguments. If an argument starts with `{` and ends with `}`, Donna treats the whole argument as a `JournalRecord` attribute name and replaces it with that value. Donna validates placeholders when loading config. - -Supported attributes: - -- `timestamp` — record creation time, formatted as ISO-8601. -- `actor_id` — actor that created the record; empty string when unknown. -- `message` — single-line journal message. -- `current_task_id` — current task id; empty string when no task is active. -- `current_work_unit_id` — current work unit id; empty string when no work unit is active. -- `current_operation_id` — current operation artifact section id; empty string when no operation is active. - -By default `journal.cmd` is omitted and Donna treats it as `None`, so no journal writing is performed. - -Donna still prints newly created internal journal records immediately using the selected protocol formatter, so agents receive live feedback even when no external journal command is configured. - -Use `donna --help` for a quick reference. - -You find detailed documentation in the built-in skill documents — they are readable and always accurate: - -- `donna skill usage` — full list of commands and how to use them. -- `donna skill artifacts` — what Donna artifacts are and how to use them on the filesystem. -- `donna skill configuration` — how to configure `donna.toml`. -- `donna skill initialization` — how to initialize or refresh a Donna workspace. - -The documentation below covers aspects important to humans and partially duplicates the agent's instructions. - -## Batteries Included - -Donna comes with documentation that helps agents work in a smart way, while workflows live in project-local `workflows/` directories. - -However, **I encourage you to experiment and implement your own workflows**. Meta-programming is fun; specialized workflows are more efficient. - -By default, Donna uses the next approach to introduce changes in your project: - -1. Prepare a Request for Change (RFC) document that describes the required changes. -2. Create a workflow that implements the changes described in the RFC. -3. Execute new workflow. - -Additionally, Donna will: - -- choose fast or slow route depending on the complexity of the changes required; -- find and run (if any) polishing workflow to ensure the codebase is in a good state after the changes; - -Note that example Donna workflows are designed to be reliable and useful for a wide range of projects. They may not be optimal in terms of token usage or speed for your particular project. The intended use of Donna is to implement your own workflows that account for your project's specifics. - -Points of interest: - -- [@/workflows/rfc/specs/request_for_change.md](./workflows/rfc/specs/request_for_change.md) — documentation for the RFC document. -- [@/workflows/rfc/request.donna.md](./workflows/rfc/request.donna.md) — workflow to create a RFC document. -- [@/workflows/rfc/plan.donna.md](./workflows/rfc/plan.donna.md) — workflow to plan work on an RFC and create a new workflow. -- [@/workflows/rfc/do.donna.md](./workflows/rfc/do.donna.md) — meta workflow to automate the whole work from a developer request to final verification. - -## Artifacts on Filesystem - -- Artifacts are text files Donna reads and validates. In practice they are usually Markdown workflows stored as `.donna.md` files. General documentation uses normal `.md` files. -- Donna discovers workflow artifacts by recursively scanning the directories listed in `donna.toml:workflow_dirs`. - -By default, Donna uses these artifact areas: - -- `workflows/` — project-owned workflows. -- `.agents/donna/` — project-local Donna documentation, when present. -- `.session/donna/` — session artifacts and Donna runtime state. - -### Rendering - -Markdown artifacts are Jinja2 templates. Donna uses multiple rendering modes for different purposes. - -More about Jinja2 rendering is described a bit further. +## Installation -### Artifacts Discovery +```bash +uv tool install donna -Artifact ids are project-relative filepaths prefixed with `@/`. Section ids append `:section_id` to the artifact id. +# or +# python -m pip install donna +``` -Examples: +Ask your agent to initialize Donna in the project: -- `@/workflows/polish.donna.md` -- `@/workflows/rfc/request.donna.md` -- `@/.session/donna/execute_rfc.donna.md:review_changes` +``` +1. Run `donna skill` +2. Initialize Donna in this project. +3. Add instructions on when and how to use Donna to the AGENTS.md file. +``` -You and agents can `list` workflow artifacts and `validate` Donna artifacts. +Or install Donna manually: -- `donna -p llm list` — shows workflow descriptions from their h1 sections. -- `donna -p llm validate ...` — validates one or more artifacts. -- `donna -p llm validate --all` — validates every discovered artifact. +```bash +# from the root of your project +donna init +``` -Artifact inputs accept root-anchored paths like `@/workflows/polish.donna.md`, relative paths like `./workflows/polish.donna.md`, and absolute paths inside the project root. +`donna init` will create a configuration file `donna.toml`, review it and edit if needed. -You can find all workflows with `donna -p llm list`. +## Configuration -## Sessions +You can get detailed documentation by running `donna skill configuration` or asking your agent to do that. -`/.session/donna/` contains the current state of work performed by Donna: runtime state plus temporary documents and workflows created during the session. +A Donna project is configured by a TOML file named `donna.toml`. If `--config` is omitted, Donna searches upward from the current working directory until it finds `donna.toml`; the directory containing that file is the project root. -The developer is responsible for starting or resetting sessions with `donna -p human start` and `donna -p human reset`. +Minimal configuration: -- On session start, Donna removes everything from the previous session and creates a fresh session directory. -- On session reset donna resets the state of the current session (tasks, action requests, etc.), but keeps artifacts. +```toml +version = 1 +``` -The agent is encouraged not to manage sessions directly, because it doesn't have enough context to decide when session artifacts may be safely removed. +The starter configuration generated by `donna init` includes the default session directory and workflow discovery directories: -## Workflows +```toml +version = 1 -Workflows are [state machines](https://en.wikipedia.org/wiki/Finite-state_machine). They are defined in Markdown files with an h1 section of type `donna.lib.workflow` and multiple h2 sections that implement operations. +session_dir = ".session/donna" -Donna tracks dependencies between operations and validates the workflow before running it. So, if you or your agent do something wrong, you'll get a clear error message from Donna. +workflow_dirs = [ + "./workflows", + "./.session/donna", +] +``` -You can run workflow as `donna -p llm run `. +A coding agent can help create or adapt workflow files after the project layout and desired workflows are clear. -To execute a workflow, Donna uses a simplified virtual machine (VM) that maintains the current state of all workflows executed in the current session. +The session directory stores Donna runtime state. Workflow directories are scanned recursively for `.donna.md` files; missing workflow directories are ignored. -
-What you may want to know about workflows implementation -- `.session/donna/state.json` file contains the current state of all running workflows in the current session. -- Each workflow executes in the context of its own task, which is a distant analog of a call stack frame. So, we may look at workflows as functions. -- Of course, workflows may call other workflows as subroutines. At any moment, only last executed workflow is active. -- Task has a context that is accessible by operations. There is an issue [command to read/write task context](https://github.com/Tiendil/donna/issues/47) to allow agents and humans to edit task context. -- The internal VM operates in terms of work units. An operation can produce multiple work units, so it should be possible to implement different interesting scenarios; however. -
+Detailed configuration behavior is specified in [./specs/behavior/config.md](./specs/behavior/config.md). Command behavior is specified in [./specs/behavior/cli.md](./specs/behavior/cli.md). -### Operations +Project agent instructions can include a short Donna rule like this: -You can find detailed docs on built-in operations in `donna skill artifacts`. +```markdown +Use Donna only when explicitly instructed by a developer, by project instructions, or by Donna itself. +Before using Donna in a session, read `donna -p llm skill usage`. +Use `donna -p llm ...` for agent-facing command output. +``` -Here is a short list of them: +## Quick Usage -- `donna.lib.request_action` — request the agent to perform specific instructions. -- `donna.lib.run_script` — run a script from the environment Donna is running in. Choose the next operation based on the return code. Can store `stdout` and `stderr` in the task context. -- `donna.lib.output` — output a cell with specific content and immediately goes to the next operation. -- `donna.lib.finish` — finish the workflow. This operation MUST be present in the workflow and MUST end all possible execution paths. +**In most cases your agent should be capable of using Donna by itself without your intervention.** -### Error handling +You can get detailed documentation by running `donna skill usage` or asking your agent to do that. -Donna can detect errors (in artifacts, in execution, etc). If an error can be fixed by the agent or the developer, Donna will output a detailed error description with a list of ways to fix it. +Detailed CLI interface is described in [./specs/behavior/cli.md](./specs/behavior/cli.md). -
-An example of error message from Donna +Create a starter configuration: ```bash -$ donna -p llm run @/workflows/polish.donna.md - -kind=artifact_validation_error -media_type=text/markdown -artifact_id=@/workflows/polish.donna.md -error_code=donna.artifacts.section_not_found -section_id=run_autoflake_scriptx - -Error in artifact '@/workflows/polish.donna.md', section 'run_autoflake_scriptx': Section `run_autoflake_scriptx` is not available in artifact `@/workflows/polish.donna.md`. - -Ways to fix: - -- Check the section id for typos. -- Ensure the section exists in the artifact +donna init ``` -
- -### Generating workflows - -The power of Donna comes from the ability to create workflows on the fly and execute them immediately. So, you can create a workflow that creates a workflow that creates a workflow that does something useful :) - -You can even modify the workflow while it is executing; the only requirement is not to lose the ids of the operations referenced in action requests. +List discovered workflow artifacts: -The simplest example of such generation is currently used as a primary way for Donna to work on the current project: - -1. Create a document with a change description. -2. Generate a workflow that defines an order of applying changes. -3. Execute the generated workflow. - -### Discovering workflows +```bash +donna list +``` -If you want to run a child workflow from an operation, you can just instruct an agent like `Run the workflow @/workflows/my-cool-workflow.donna.md` and the agent will find it and run. +Start a new session: -However, it is not very agile. Instead, I suggest you describe the desired outcome and let the agent find the most suitable workflow. In that case, you'll be able to define customized workflows for specific types of changes and let the agent choose the best one for the current situation. +```bash +donna new-session +``` -For example, you can have two workflows `@/workflows/write-backend-test.donna.md` and `@/workflows/write-frontend-test.donna.md`, and your operation can say `Run the workflow that will write a test for the current change`, and the agent will choose the most suitable workflow based on the context and the workflow descriptions. +Start a workflow in the current session: -## Jinja2 rendering +```bash +donna run @/workflows/polish.donna.md +``` -Markdown artifacts are Jinja2 templates that are rendered immediately upon loading, before parsing Markdown. +Inspect the current session: -There are multiple rendering modes that Donna uses for different purposes: +```bash +donna status +``` -1. `view` — artifact is rendered for displaying to the agent or the developer. -2. `execute` — artifact is rendered for execution. This mode has access to the current task context, so we can add a variable from it into the operation instructions. -3. `analyze` — artifact is rendered to be analyzed by Donna itself. For example, to extract `goto` directives from operation bodies and validate the workflow structure. +Continue queued workflow execution: -All Jinja2 rendering is supported, except inheritance-related features. So, an artifact is self contained template. +```bash +donna continue +``` -The rendering is performed before processing Markdown, so you can use Jinja2 features (like loops, conditionals, etc) to generate complex artifacts. +Complete an action request with the id and next operation printed by Donna: -### Directives +```bash +donna complete-action-request @/workflows/example.donna.md:next_operation +``` -Donna defines a set of built-in Jinja2 functions that provide artifacts with their special capabilities. Such functions are called directives. +Read the built-in usage documentation: -Directives are used in the next way: `{{ python.import.path() }}`. +```bash +donna skill usage +``` -You can find detailed documentation of all built-in directives in `donna skill artifacts`. +## Workflow Files -Here they are: +**In most cases your agent should be capable of creating and managing workflows by itself without your intervention.** -1. `donna.lib.goto` — references the next workflow operation to execute. In `view`/`execute` modes, it renders an exact CLI command to advance the workflow. In `analysis` mode, it is used to construct and validate an FSM for the workflow. -2. `donna.lib.task_variable` — in `view` mode renders a placeholder with a note about task-variable substitution, in `execute` mode renders the actual task-context value. In `analysis`, it will be used to control a set of variables used in the artifact. +You can get detailed documentation by running `donna skill workflows` or asking your agent to do that. -## Documentation +Donna workflow artifacts are Markdown files ending with `.donna.md`. Donna discovers them by recursively scanning the configured `workflow_dirs`. -Specifications are plain Markdown documentation. Donna does not treat them as artifacts or validate them as workflows. +A workflow file has one H1 section for the workflow and H2 sections for operations. Section config is written in fenced `toml donna` code blocks. Transitions are declared in operation config or request text, depending on the operation kind, and validated before execution. -## Extending Donna +Common built-in operation kinds: -All Donna logic is referenced by Python import paths. That means: +- `donna.lib.request_action` pauses and asks the agent to perform work. +- `donna.lib.run_script` runs a deterministic shell script from the project root. +- `donna.lib.output` prints information and continues. +- `donna.lib.finish` finishes the workflow task. -- You can implement your own functionality and use it with Donna. -- You can enrich your Python packages with additional code to work with Donna. +## Specifications -What you can implement: +Project behavior and architecture are specified in [./specs](./specs/). Start with [./specs/intro.md](./specs/intro.md) for the index. -- Custom sections (including operations) for Donna artifacts. Check [./donna/primitives/artifacts](./donna/primitives/artifacts) and [./donna/primitives/sections](./donna/primitives/sections) subpackages for examples. -- Custom rendering directives. Check [./donna/primitives/directives](./donna/primitives/directives) subpackage for examples. +## Development -Donna workflow artifacts are Markdown files ending with `.donna.md` inside directories configured by `workflow_dirs` in your project `donna.toml`. +Development commands are run through Docker-backed project scripts. -Sections and directives are used directly in artifacts by their Python import paths. +Run the full test suite: -## Feedback wanted +```bash +./bin/dev-tests.sh +``` -Donna is still young and has multiple experimental features — I really appreciate any feedback, ideas, and contributions to make it better. +Run a specific development command inside the project container: -Especially, it would be nice to hear about +```bash +./bin/dev.sh uv run pytest +``` -- problems with configuration and usage; -- real-life use cases, especially with meta programming: fun workflows that generate workflows and so on; -- your particular needs that Donna potentially can cover, but lacks some functionality now. +Run Donna from the current development checkout: -How to reach me: +```bash +./bin/dev.sh uv run donna --help +``` -- Create an [issue](https://github.com/Tiendil/donna/issues). Any format and theme is welcome. -- Comment on one of the existing issues. Feedback, especially on [proposals](https://github.com/Tiendil/donna/issues?q=is%3Aissue%20state%3Aopen%20label%3Aproposal). -- Start a [discussion](https://github.com/Tiendil/donna/discussions). +The repository also uses Donna itself. Its local workflows live in [./workflows](./workflows/), and the current project configuration is [donna.toml](./donna.toml). -## Projects that use Donna +### Agent Harness -- [Feeds Fun](https://github.com/Tiendil/feeds.fun) — news reader with tags, scoring, and AI. +Check [AGENTS.md](./AGENTS.md) for the list of additional tools that agents will expect to be installed. diff --git a/bin/dev-check-formatting.sh b/bin/dev-check-formatting.sh index bf1ac8ef..c8658192 100755 --- a/bin/dev-check-formatting.sh +++ b/bin/dev-check-formatting.sh @@ -4,8 +4,8 @@ set -e echo "run isort" -./bin/dev.sh poetry run isort --check-only ./donna +./bin/dev.sh uv run isort --check-only ./donna echo "run black" -./bin/dev.sh poetry run black --check ./donna +./bin/dev.sh uv run black --check ./donna diff --git a/bin/dev-check-runtime.sh b/bin/dev-check-runtime.sh index e67c91e8..7834c078 100755 --- a/bin/dev-check-runtime.sh +++ b/bin/dev-check-runtime.sh @@ -4,4 +4,4 @@ set -e echo "cli works" -./bin/dev.sh poetry run donna --help +./bin/dev.sh uv run donna --help diff --git a/bin/dev-check-semantics.sh b/bin/dev-check-semantics.sh index 2c9f4a14..bf74b704 100755 --- a/bin/dev-check-semantics.sh +++ b/bin/dev-check-semantics.sh @@ -2,14 +2,18 @@ set -e +echo "run tach" + +./bin/dev.sh uv run tach check + echo "run autoflake" -./bin/dev.sh poetry run autoflake --check --quiet ./donna +./bin/dev.sh uv run autoflake --check --quiet ./donna echo "run flake8" -./bin/dev.sh poetry run flake8 ./donna +./bin/dev.sh uv run flake8 ./donna echo "run mypy" -./bin/dev.sh poetry run mypy --show-traceback ./donna +./bin/dev.sh uv run mypy --show-traceback ./donna diff --git a/bin/dev-tests.sh b/bin/dev-tests.sh new file mode 100755 index 00000000..f59b65c1 --- /dev/null +++ b/bin/dev-tests.sh @@ -0,0 +1,7 @@ +#!/usr/bin/bash + +set -e + +echo "run tests" + +./bin/dev.sh uv run pytest donna -o cache_dir=/tmp/donna-pytest-cache diff --git a/bin/dev.sh b/bin/dev.sh index 680a0cb8..8fb0e9c4 100755 --- a/bin/dev.sh +++ b/bin/dev.sh @@ -1,3 +1,3 @@ #!/usr/bin/bash -docker compose run --rm donna $@ +docker compose run --rm donna "$@" diff --git a/bin/donna.sh b/bin/donna.sh index 927ab150..11b02fe1 100755 --- a/bin/donna.sh +++ b/bin/donna.sh @@ -5,4 +5,4 @@ # exec .venv-donna/bin/donna "$@" ROOT_DIR="$(cd "$(dirname "$0")/.."; pwd)" -poetry -P "$ROOT_DIR" run donna "$@" +uv --project "$ROOT_DIR" run donna "$@" diff --git a/bin/journal-follow.py b/bin/journal-tail.py similarity index 93% rename from bin/journal-follow.py rename to bin/journal-tail.py index 9b1e5c59..b25dafb8 100755 --- a/bin/journal-follow.py +++ b/bin/journal-tail.py @@ -16,9 +16,10 @@ def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Follow project journal records.") + parser = argparse.ArgumentParser(description="Output project journal records.") parser.add_argument("-n", "--lines", type=int, default=20, help="number of existing records to show") - parser.add_argument("-i", "--interval", type=float, default=1.0, help="poll interval in seconds") + parser.add_argument("-f", "--follow", action="store_true", help="follow new records after output") + parser.add_argument("-i", "--interval", type=float, default=1.0, help="poll interval in seconds when following") return parser.parse_args() @@ -115,6 +116,9 @@ def main() -> int: if args.lines > 0: print_records(records[-args.lines :], formatter) + if not args.follow: + return 0 + while True: time.sleep(args.interval) diff --git a/bin/release-prepare.sh b/bin/release-prepare.sh index 423bc272..e95e7907 100755 --- a/bin/release-prepare.sh +++ b/bin/release-prepare.sh @@ -6,29 +6,29 @@ export BUMP_VERSION=$1 echo "Bumping version as $BUMP_VERSION" -export NEXT_VERSION=$(poetry version $BUMP_VERSION --short) +export NEXT_VERSION=$(uv version --bump $BUMP_VERSION --short) export NEXT_VERSION_TAG="release-$NEXT_VERSION" echo "Install dependencies" -poetry install +uv sync echo "Update change log" -poetry run changy version create $NEXT_VERSION +uv run changy version create $NEXT_VERSION echo "Generate changelog" -poetry run changy changelog create +uv run changy changelog create -export COMMIT_BODY=$(poetry run changy version show $NEXT_VERSION) +export COMMIT_BODY=$(uv run changy version show $NEXT_VERSION) echo "New version is $NEXT_VERSION" echo "New version tag $NEXT_VERSION_TAG" echo "Building Python package" -poetry build +uv build echo "Commit changes" diff --git a/bin/tach-map-query.py b/bin/tach-map-query.py new file mode 100644 index 00000000..5f5093f5 --- /dev/null +++ b/bin/tach-map-query.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from pathlib import PurePosixPath +import subprocess +import sys +from typing import Any + + +def main() -> int: + parser = argparse.ArgumentParser(description="Print Tach file relations for one Donna artifact.") + parser.add_argument("--direction", choices=("dependencies", "dependents"), required=True) + parser.add_argument("--artifact", required=True) + args = parser.parse_args() + + env = os.environ.copy() + env["COMPOSE_IGNORE_ORPHANS"] = "true" + + completed = subprocess.run( + [ + "./bin/dev.sh", + "uv", + "run", + "python", + "-m", + "tach", + "map", + "--direction", + args.direction, + "--output", + "-", + ], + capture_output=True, + check=False, + env=env, + text=True, + ) + + if completed.returncode != 0: + sys.stderr.write(completed.stderr) + return completed.returncode + + try: + dependency_map = json.loads(completed.stdout) + except json.JSONDecodeError as error: + print(f"could not parse tach map JSON: {error}", file=sys.stderr) + return 1 + + if not isinstance(dependency_map, dict): + print("tach map JSON must be an object", file=sys.stderr) + return 1 + + artifact = _normalize_artifact(args.artifact) + + for dependency in sorted(_dependencies_for(dependency_map, artifact)): + print(f"./{dependency}") + + return 0 + + +def _normalize_artifact(artifact: str) -> str: + normalized = artifact.strip() + + if normalized.startswith("@/"): + normalized = normalized[2:] + + if normalized.startswith("./"): + normalized = normalized[2:] + + return PurePosixPath(normalized).as_posix() + + +def _dependencies_for(dependency_map: dict[str, Any], artifact: str) -> list[str]: + values = dependency_map.get(artifact, dependency_map.get(f"./{artifact}", [])) + + if not isinstance(values, list): + print(f"tach map value for {artifact!r} must be a list", file=sys.stderr) + return [] + + return [_normalize_artifact(value) for value in values if isinstance(value, str)] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/changes/next_release.md b/changes/next_release.md new file mode 100644 index 00000000..69b549d2 --- /dev/null +++ b/changes/next_release.md @@ -0,0 +1,10 @@ + +**This release is a full rethinking of what Donna is and how it works.** + +Donna's scope has been reduced to interpreting state machines for agents. Artifact management, session management, and related functionality have been removed. + +Because of that, there is no reliable migration path and no useful item-by-item list of changes. + +**Treat this version of Donna as a new tool:** read the documentation from scratch and adjust your usage accordingly. + +Sorry for the disruption. Future releases should avoid breaking changes of this scale. diff --git a/changes/unreleased.md b/changes/unreleased.md deleted file mode 100644 index a4b6cfc0..00000000 --- a/changes/unreleased.md +++ /dev/null @@ -1,10 +0,0 @@ - -**This release is dedicated to a full rethinking of what Donna is and how it works.** - -The scope of tool functionality was reduced to interpreting state machines for agents (no artifact management, no session management, etc.). - -That's why it is difficult to provide migration instructions or a proper list of changes. - -**Treat this version of Donna as a totally new tool** => read the documentation from scratch and adjust your usage accordingly. - -Sorry for the inconvenience. There should be no such breaking changes in the future. diff --git a/depmesh.toml b/depmesh.toml new file mode 100644 index 00000000..e2338f99 --- /dev/null +++ b/depmesh.toml @@ -0,0 +1,375 @@ +version = 1 + +############################# +# Relation: tested_by / tests +############################# + +[[relations]] +id = "tested_by" +description = "Tests that verify the artifact." + +[[relations]] +id = "tests" +description = "Artifacts verified by the test." + +# Package initializers are verified by matching tests/test_init.py files. +[[rules]] +relation = "tested_by" +input = { type = "all", items = [ + { type = "glob", pattern = "@/donna/{**package_path}/__init__.py" }, + { type = "not", item = { type = "glob", pattern = "@/donna/**/tests/**/*.py" } }, +] } +output = { type = "files", pattern = "@/donna/{package_path}/tests/test_init.py" } + +# Regular Python modules are verified by sibling tests/test_.py files. +[[rules]] +relation = "tested_by" +input = { type = "all", items = [ + { type = "glob", pattern = "@/donna/{**package_path}/{*module}.py" }, + { type = "not", item = { type = "glob", pattern = "@/donna/**/__init__.py" } }, + { type = "not", item = { type = "glob", pattern = "@/donna/**/tests/**/*.py" } }, +] } +output = { type = "files", pattern = "@/donna/{package_path}/tests/test_{module}.py" } + +# tests/test_init.py maps back to the package initializer it verifies. +[[rules]] +relation = "tests" +input = { type = "glob", pattern = "@/donna/{**package_path}/tests/test_init.py" } +output = { type = "list", artifacts = ["@/donna/{package_path}/__init__.py"] } + +# tests/test_.py maps back to the module it verifies. +[[rules]] +relation = "tests" +input = { type = "all", items = [ + { type = "glob", pattern = "@/donna/{**package_path}/tests/test_{*module}.py" }, + { type = "not", item = { type = "glob", pattern = "@/donna/**/tests/test_init.py" } }, +] } +output = { type = "list", artifacts = ["@/donna/{package_path}/{module}.py"] } + +################################# +# Relation: imports / imported_by +################################# + +[[relations]] +id = "imports" +description = "Python files imported by the artifact." + +[[relations]] +id = "imported_by" +description = "Python files that import the artifact." + +# Tach provides direct Python imports for project modules. +[[rules]] +relation = "imports" +input = { type = "glob", pattern = "@/donna/{**module_path}.py" } +output = { + type = "command", + command = "python ./bin/tach-map-query.py --direction dependencies --artifact ./donna/{module_path}.py", +} + +# Tach provides reverse Python import lookups for project modules. +[[rules]] +relation = "imported_by" +input = { type = "glob", pattern = "@/donna/{**module_path}.py" } +output = { + type = "command", + command = "python ./bin/tach-map-query.py --direction dependents --artifact ./donna/{module_path}.py", +} + +################################ +# Relation: governed_by / governs +################################ + +[[relations]] +id = "governed_by" +description = "Specifications that apply to the artifact." + +[[relations]] +id = "governs" +description = "Artifacts governed by the specification." + +# Every specification document is governed by the general specification rules. +# Note: general.md governs itself too. +[[rules]] +relation = "governed_by" +input = { type = "any", items = [ + { type = "glob", pattern = "@/specs/*.md" }, + { type = "glob", pattern = "@/specs/**/*.md" }, +] } +output = { type = "list", artifacts = ["@/specs/meta/general.md"] } + +# The general specification rules govern every specification document. +# Note: general.md governs itself too. +[[rules]] +relation = "governs" +input = { type = "one_of", artifacts = ["@/specs/meta/general.md"] } +output = { type = "union", items = [ + { type = "files", pattern = "@/specs/*.md" }, + { type = "files", pattern = "@/specs/**/*.md" }, +] } + +# Python files are governed by every architecture spec. +[[rules]] +relation = "governed_by" +input = { type = "glob", pattern = "@/donna/**/*.py" } +output = { type = "files", pattern = "@/specs/architecture/*.md" } + +# Architecture specs govern every Python file. +[[rules]] +relation = "governs" +input = { type = "glob", pattern = "@/specs/architecture/*.md" } +output = { type = "files", pattern = "@/donna/**/*.py" } + +# CLI, protocol, and skill-facing code is governed by the CLI behavior spec. +[[rules]] +relation = "governed_by" +input = { type = "any", items = [ + { type = "glob", pattern = "@/donna/cli/**/*.py" }, + { type = "glob", pattern = "@/donna/protocol/**/*.py" }, + { type = "glob", pattern = "@/donna/skills/**/*.py" }, + { type = "glob", pattern = "@/donna/skills/**/*.md" }, + { type = "one_of", artifacts = [ + "@/donna/__init__.py", + "@/donna/cli/__main__.py", + ] }, +] } +output = { type = "list", artifacts = ["@/specs/behavior/cli.md"] } + +# The CLI behavior spec governs CLI, protocol, skill, and entrypoint code. +[[rules]] +relation = "governs" +input = { type = "one_of", artifacts = ["@/specs/behavior/cli.md"] } +output = { type = "union", items = [ + { type = "files", pattern = "@/donna/cli/**/*.py" }, + { type = "files", pattern = "@/donna/protocol/**/*.py" }, + { type = "files", pattern = "@/donna/skills/**/*.py" }, + { type = "files", pattern = "@/donna/skills/**/*.md" }, + { type = "list", artifacts = [ + "@/donna/__init__.py", + "@/donna/cli/__main__.py", + ] }, +] } + +# Configuration behavior governs configuration loading, initialization, fixtures, and project config artifacts. +[[rules]] +relation = "governed_by" +input = { type = "any", items = [ + { type = "one_of", artifacts = [ + "@/donna.toml", + "@/donna/workspaces/fixtures/base_config.toml", + ] }, + { type = "glob", pattern = "@/donna/workspaces/config.py" }, + { type = "glob", pattern = "@/donna/workspaces/initialization.py" }, + { type = "glob", pattern = "@/donna/workspaces/journal.py" }, +] } +output = { type = "list", artifacts = ["@/specs/behavior/config.md"] } + +# The configuration spec governs configuration loading, initialization, fixtures, and project config artifacts. +[[rules]] +relation = "governs" +input = { type = "one_of", artifacts = ["@/specs/behavior/config.md"] } +output = { type = "union", items = [ + { type = "list", artifacts = [ + "@/donna.toml", + "@/donna/workspaces/fixtures/base_config.toml", + ] }, + { type = "files", pattern = "@/donna/workspaces/config.py" }, + { type = "files", pattern = "@/donna/workspaces/initialization.py" }, + { type = "files", pattern = "@/donna/workspaces/journal.py" }, +] } + +# File path behavior governs project path and artifact id normalization code. +[[rules]] +relation = "governed_by" +input = { type = "any", items = [ + { type = "glob", pattern = "@/donna/domain/*paths.py" }, + { type = "glob", pattern = "@/donna/domain/artifact_ids.py" }, + { type = "glob", pattern = "@/donna/domain/id_paths.py" }, + { type = "glob", pattern = "@/donna/workspaces/paths.py" }, + { type = "glob", pattern = "@/donna/workspaces/files.py" }, + { type = "glob", pattern = "@/donna/workspaces/artifacts.py" }, + { type = "glob", pattern = "@/donna/cli/**/*.py" }, +] } +output = { type = "list", artifacts = ["@/specs/behavior/file_paths.md"] } + +# The file path behavior spec governs project path and artifact id normalization code. +[[rules]] +relation = "governs" +input = { type = "one_of", artifacts = ["@/specs/behavior/file_paths.md"] } +output = { type = "union", items = [ + { type = "files", pattern = "@/donna/domain/*paths.py" }, + { type = "files", pattern = "@/donna/domain/artifact_ids.py" }, + { type = "files", pattern = "@/donna/domain/id_paths.py" }, + { type = "files", pattern = "@/donna/workspaces/paths.py" }, + { type = "files", pattern = "@/donna/workspaces/files.py" }, + { type = "files", pattern = "@/donna/workspaces/artifacts.py" }, + { type = "files", pattern = "@/donna/cli/**/*.py" }, +] } + +# Built-in skill fixtures are governed by the skill fixture behavior spec. +[[rules]] +relation = "governed_by" +input = { type = "any", items = [ + { type = "glob", pattern = "@/donna/skills/fixtures.py" }, + { type = "glob", pattern = "@/donna/skills/fixtures/*.md" }, +] } +output = { type = "list", artifacts = ["@/specs/behavior/skill_fixtures.md"] } + +# The skill fixture behavior spec governs built-in skill fixtures. +[[rules]] +relation = "governs" +input = { type = "one_of", artifacts = ["@/specs/behavior/skill_fixtures.md"] } +output = { type = "union", items = [ + { type = "files", pattern = "@/donna/skills/fixtures.py" }, + { type = "files", pattern = "@/donna/skills/fixtures/*.md" }, +] } + +################################ +# Relation: changelog documentation +################################ + +# Changelog artifacts are governed by the changelog documentation spec. +[[rules]] +relation = "governed_by" +input = { type = "any", items = [ + { type = "one_of", artifacts = ["@/CHANGELOG.md"] }, + { type = "glob", pattern = "@/changes/*.md" }, +] } +output = { type = "list", artifacts = ["@/specs/documentation/changelog.md"] } + +# The changelog documentation spec governs changelog artifacts. +[[rules]] +relation = "governs" +input = { type = "one_of", artifacts = ["@/specs/documentation/changelog.md"] } +output = { type = "union", items = [ + { type = "list", artifacts = ["@/CHANGELOG.md"] }, + { type = "files", pattern = "@/changes/*.md" }, +] } + +################################ +# Relation: README documentation +################################ + +# README.md is governed by its documentation spec. +[[rules]] +relation = "governed_by" +input = { type = "one_of", artifacts = ["@/README.md"] } +output = { type = "list", artifacts = ["@/specs/documentation/readme.md"] } + +# The README documentation spec governs README.md. +[[rules]] +relation = "governs" +input = { type = "one_of", artifacts = ["@/specs/documentation/readme.md"] } +output = { type = "list", artifacts = ["@/README.md"] } + +# Dependency mesh and Tach configuration are governed by the module layout architecture spec. +[[rules]] +relation = "governed_by" +input = { type = "one_of", artifacts = [ + "@/AGENTS.md", + "@/bin/tach-map-query.py", + "@/depmesh.toml", + "@/tach.toml", +] } +output = { type = "list", artifacts = ["@/specs/architecture/modules_layout.md"] } + +# The module layout spec governs dependency mesh and Tach configuration. +[[rules]] +relation = "governs" +input = { type = "one_of", artifacts = ["@/specs/architecture/modules_layout.md"] } +output = { type = "list", artifacts = [ + "@/AGENTS.md", + "@/bin/tach-map-query.py", + "@/depmesh.toml", + "@/tach.toml", +] } + +############################################ +# Relation: terms_defined_by / defines_terms_for +############################################ + +[[relations]] +id = "terms_defined_by" +description = "Dictionaries that define terms used by the artifact." + +[[relations]] +id = "defines_terms_for" +description = "Artifacts that use terms from the dictionary." + +# Specs, code, workflows, documentation, and dependency metadata use terms defined in the project dictionary. +# Note: dictionary.md can define terms for itself too. +[[rules]] +relation = "terms_defined_by" +input = { type = "any", items = [ + { type = "glob", pattern = "@/specs/*.md" }, + { type = "glob", pattern = "@/specs/**/*.md" }, + { type = "glob", pattern = "@/donna/**/*.py" }, + { type = "glob", pattern = "@/donna/**/*.md" }, + { type = "glob", pattern = "@/workflows/**/*.donna.md" }, + { type = "glob", pattern = "@/changes/*.md" }, + { type = "one_of", artifacts = [ + "@/AGENTS.md", + "@/bin/tach-map-query.py", + "@/CHANGELOG.md", + "@/README.md", + "@/depmesh.toml", + "@/donna.toml", + "@/tach.toml", + ] }, +] } +output = { type = "list", artifacts = ["@/specs/dictionary.md"] } + +# The dictionary defines terms used by specs, code, workflows, documentation, and dependency metadata. +# Note: dictionary.md can define terms for itself too. +[[rules]] +relation = "defines_terms_for" +input = { type = "one_of", artifacts = ["@/specs/dictionary.md"] } +output = { type = "union", items = [ + { type = "files", pattern = "@/specs/*.md" }, + { type = "files", pattern = "@/specs/**/*.md" }, + { type = "files", pattern = "@/donna/**/*.py" }, + { type = "files", pattern = "@/donna/**/*.md" }, + { type = "files", pattern = "@/workflows/**/*.donna.md" }, + { type = "files", pattern = "@/changes/*.md" }, + { type = "list", artifacts = [ + "@/AGENTS.md", + "@/bin/tach-map-query.py", + "@/CHANGELOG.md", + "@/README.md", + "@/depmesh.toml", + "@/donna.toml", + "@/tach.toml", + ] }, +] } + +############################# +# Relation: indexed_by / indexes +############################# + +[[relations]] +id = "indexed_by" +description = "Indexes that list the specification." + +[[relations]] +id = "indexes" +description = "Specifications listed by the index." + +# Every specification document is listed by the spec index. +# Note: intro.md indexes itself too. +[[rules]] +relation = "indexed_by" +input = { type = "any", items = [ + { type = "glob", pattern = "@/specs/*.md" }, + { type = "glob", pattern = "@/specs/**/*.md" }, +] } +output = { type = "list", artifacts = ["@/specs/intro.md"] } + +# intro.md indexes every specification document. +# Note: intro.md indexes itself too. +[[rules]] +relation = "indexes" +input = { type = "one_of", artifacts = ["@/specs/intro.md"] } +output = { type = "union", items = [ + { type = "files", pattern = "@/specs/*.md" }, + { type = "files", pattern = "@/specs/**/*.md" }, +] } diff --git a/docker/Dockerfile b/docker/Dockerfile index c7aeba4e..a96897f6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.12 -RUN pip install poetry +COPY --from=ghcr.io/astral-sh/uv:0.11.14 /uv /uvx /bin/ ARG UID=1000 ARG GID=1000 @@ -12,11 +12,15 @@ USER $USER WORKDIR /repository -COPY README.md pyproject.toml poetry.lock* ./ -COPY donna/__init__.py donna/ +ENV UV_PROJECT_ENVIRONMENT=/home/donna/.venv +ENV UV_PYTHON_DOWNLOADS=never +ENV UV_LINK_MODE=copy +ENV PATH="/home/donna/.venv/bin:${PATH}" -RUN poetry install --no-interaction --no-ansi +COPY --chown=$USER:$USER . /repository -USER $USER +RUN uv sync --locked + +ENV UV_NO_SYNC=1 CMD [] diff --git a/docs/images/journal-demo.gif b/docs/images/journal-demo.gif deleted file mode 100644 index a31e5745..00000000 Binary files a/docs/images/journal-demo.gif and /dev/null differ diff --git a/donna.toml b/donna.toml index ddc6ee44..147af148 100644 --- a/donna.toml +++ b/donna.toml @@ -1,8 +1,12 @@ +version = 1 + session_dir = ".session/donna" -default_section_kind = "donna.lib.text" -default_primary_section_kind = "donna.lib.workflow" -default_primary_section_id = "primary" workflow_dirs = ["./workflows", "./.session/donna"] +[defaults] +tail_section_kind = "donna.lib.text" +primary_section_kind = "donna.lib.workflow" +primary_section_id = "primary" + [journal] cmd = ["./bin/taskwarior.sh", "log", "+journal", "+donna", "kind:event", "{message}"] diff --git a/donna/cli/__init__.py b/donna/cli/__init__.py index e69de29b..f3b64be9 100644 --- a/donna/cli/__init__.py +++ b/donna/cli/__init__.py @@ -0,0 +1,6 @@ +from donna.cli import application as application +from donna.cli import entities as entities +from donna.cli import errors as errors +from donna.cli import types as types + +__all__ = ("application", "entities", "errors", "types") diff --git a/donna/cli/application.py b/donna/cli/application.py index 1d96c477..71879c56 100644 --- a/donna/cli/application.py +++ b/donna/cli/application.py @@ -1,7 +1,7 @@ import typer from donna.cli.entities import GLOBAL_OPTIONS_CONTEXT_KEY, GlobalOptions -from donna.cli.types import ProtocolModeOption, RootOption +from donna.cli.types import ConfigOption, ProtocolModeOption from donna.domain.paths import UntrustedPath from donna.protocol.modes import Mode @@ -12,10 +12,10 @@ def initialize( context: typer.Context, protocol: ProtocolModeOption = Mode.human, - root_dir: RootOption = None, + config_path: ConfigOption = None, ) -> None: context.meta[GLOBAL_OPTIONS_CONTEXT_KEY] = GlobalOptions( - protocol=protocol, root_dir=None if root_dir is None else UntrustedPath(root_dir) + protocol=protocol, config_path=None if config_path is None else UntrustedPath(config_path) ) diff --git a/donna/cli/commands/artifacts.py b/donna/cli/commands/artifacts.py index 42d45217..1a117fb1 100644 --- a/donna/cli/commands/artifacts.py +++ b/donna/cli/commands/artifacts.py @@ -8,14 +8,14 @@ from donna.cli.types import ArtifactIdArgument, ArtifactIdsArgument, RenderModeOption, parse_artifact_id_argument from donna.cli.utils import command_context from donna.context.context import context -from donna.machine import journal as machine_journal from donna.protocol.cell_shortcuts import operation_succeeded +from donna.protocol.errors import environment_error_node from donna.workspaces.artifacts import RENDER_CONTEXT_VIEW, ArtifactRenderContext, fetch_artifact_bytes from donna.workspaces.templates import render as render_template def _log_artifact_operation(message: str) -> None: - machine_journal.add(message=message) + context().journal.add(message=message) @app.command(name="list", help="List available workflow artifacts and show their status summaries.") @@ -86,7 +86,7 @@ def validate( # noqa: CCR001 errors.extend(result.unwrap_err()) if errors: - command.write_cells(error.node().info() for error in errors) + command.write_cells(environment_error_node(error).info() for error in errors) return command.write_cells([operation_succeeded("All artifacts are valid")]) diff --git a/donna/cli/commands/sessions.py b/donna/cli/commands/sessions.py index 6d8eb31a..b5a24dd3 100644 --- a/donna/cli/commands/sessions.py +++ b/donna/cli/commands/sessions.py @@ -9,19 +9,13 @@ parse_artifact_section_id_argument, ) from donna.cli.utils import command_context -from donna.machine import sessions +from donna.runtime import sessions -@app.command(help="Start a new session, reset session state, remove all session artifacts.") -def start(context: typer.Context) -> None: +@app.command(name="new-session", help="Create fresh session state.") +def new_session(context: typer.Context) -> None: with command_context(context) as command: - command.write_cells(sessions.start().unwrap()) - - -@app.command(help="Reset the current session state, keeps session artifacts.") -def reset(context: typer.Context) -> None: - with command_context(context) as command: - command.write_cells(sessions.reset().unwrap()) + command.write_cells(sessions.new_session().unwrap()) @app.command( diff --git a/donna/cli/commands/workspaces.py b/donna/cli/commands/workspaces.py index 7d80705b..69374dd3 100644 --- a/donna/cli/commands/workspaces.py +++ b/donna/cli/commands/workspaces.py @@ -9,8 +9,8 @@ @app.command(help="Initialize Donna project config.") def init(context: typer.Context) -> None: with command_context(context, load_environment=False) as command: - target_dir = command.target_dir() + config_path = command.target_config_path() - initialize_workspace(target_dir).unwrap() + initialize_workspace(config_path).unwrap() command.write_cells([operation_succeeded("Donna project initialized successfully")]) diff --git a/donna/cli/entities.py b/donna/cli/entities.py index c67da7fc..c2b5a827 100644 --- a/donna/cli/entities.py +++ b/donna/cli/entities.py @@ -7,4 +7,4 @@ class GlobalOptions(BaseEntity): protocol: Mode - root_dir: UntrustedPath | None = None + config_path: UntrustedPath | None = None diff --git a/donna/cli/tests/__init__.py b/donna/cli/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/donna/cli/tests/conftest.py b/donna/cli/tests/conftest.py new file mode 100644 index 00000000..3d4213be --- /dev/null +++ b/donna/cli/tests/conftest.py @@ -0,0 +1,12 @@ +import pytest +from pytest_mock import MockerFixture + +from donna.workspaces import config as workspace_config + + +@pytest.fixture(autouse=True) +def isolated_workspace_globals(mocker: MockerFixture) -> None: + mocker.patch.object(workspace_config.project_dir, "_value", None) + mocker.patch.object(workspace_config.config_path, "_value", None) + mocker.patch.object(workspace_config.config, "_value", None) + mocker.patch.object(workspace_config.protocol, "_value", None) diff --git a/donna/cli/tests/helpers.py b/donna/cli/tests/helpers.py new file mode 100644 index 00000000..482b43bb --- /dev/null +++ b/donna/cli/tests/helpers.py @@ -0,0 +1,76 @@ +import importlib +import json +import pathlib + +from click.testing import Result as CliResult +from typer.testing import CliRunner + +from donna.cli.application import app + +COMMAND_MODULES = ( + "donna.cli.commands.artifacts", + "donna.cli.commands.sessions", + "donna.cli.commands.skills", + "donna.cli.commands.version", + "donna.cli.commands.workspaces", +) + + +def load_cli_commands() -> None: + for module_name in COMMAND_MODULES: + importlib.import_module(module_name) + + +def make_runner() -> CliRunner: + load_cli_commands() + return CliRunner() + + +def invoke(args: list[str]) -> CliResult: + return make_runner().invoke(app, args) + + +def json_lines(output: str) -> list[dict[str, object]]: + return [json.loads(line) for line in output.splitlines() if line.startswith("{")] + + +def write_config(project_dir: pathlib.Path, *, workflow_dirs: list[str] | None = None) -> pathlib.Path: + workflow_dirs = workflow_dirs or ["workflows"] + config_path = project_dir / "donna.toml" + workflow_dirs_text = ", ".join(f'"{path}"' for path in workflow_dirs) + config_path.write_text(f"version = 1\nworkflow_dirs = [{workflow_dirs_text}]\n", encoding="utf-8") + return config_path + + +def write_workflow( + project_dir: pathlib.Path, + *, + path: str = "workflows/test.donna.md", + title: str = "Test Workflow", + description: str = "Workflow description.", +) -> pathlib.Path: + artifact_path = project_dir / path + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text( + f"""# {title} + +```toml donna +id = "workflow" +kind = "donna.lib.workflow" +start_operation_id = "finish" +``` + +{description} + +## Finish + +```toml donna +id = "finish" +kind = "donna.lib.finish" +``` + +Done. +""", + encoding="utf-8", + ) + return artifact_path diff --git a/donna/cli/tests/test_artifacts.py b/donna/cli/tests/test_artifacts.py new file mode 100644 index 00000000..8118e00f --- /dev/null +++ b/donna/cli/tests/test_artifacts.py @@ -0,0 +1,90 @@ +import pathlib + +from donna.cli.tests import helpers + + +class TestList: + def test_lists_discovered_artifacts_with_selected_protocol(self, tmp_path: pathlib.Path) -> None: + config_path = helpers.write_config(tmp_path) + helpers.write_workflow(tmp_path) + + result = helpers.invoke(["--config", str(config_path), "-p", "llm", "list"]) + + assert result.exit_code == 0 + assert "kind=artifact_status" in result.output + assert "artifact_id=@/workflows/test.donna.md" in result.output + assert "artifact_title=Test Workflow" in result.output + + +class TestRender: + def test_renders_raw_markdown_without_cell_wrapping(self, tmp_path: pathlib.Path) -> None: + config_path = helpers.write_config(tmp_path) + helpers.write_workflow(tmp_path) + + result = helpers.invoke( + ["--config", str(config_path), "-p", "human", "render", "--mode", "view", "@/workflows/test.donna.md"] + ) + + assert result.exit_code == 0 + assert "# Test Workflow" in result.output + assert "----- DONNA CELL" not in result.output + assert "--DONNA-CELL" not in result.output + + +class TestValidate: + def test_all_option_validates_every_discovered_artifact(self, tmp_path: pathlib.Path) -> None: + config_path = helpers.write_config(tmp_path) + helpers.write_workflow(tmp_path) + + result = helpers.invoke(["--config", str(config_path), "-p", "automation", "validate", "--all"]) + + assert result.exit_code == 0 + records = helpers.json_lines(result.output) + assert records[0]["content"] == "All artifacts are valid" + + def test_explicit_artifact_argument_is_normalized(self, tmp_path: pathlib.Path) -> None: + config_path = helpers.write_config(tmp_path) + helpers.write_workflow(tmp_path) + + result = helpers.invoke( + [ + "--config", + str(config_path), + "-p", + "llm", + "validate", + "@/workflows/test.donna.md", + ] + ) + + assert result.exit_code == 0 + assert "kind=operation_succeeded" in result.output + + def test_rejects_all_option_combined_with_artifact_argument(self, tmp_path: pathlib.Path) -> None: + config_path = helpers.write_config(tmp_path) + helpers.write_workflow(tmp_path) + + result = helpers.invoke(["--config", str(config_path), "validate", "--all", "@/workflows/test.donna.md"]) + + assert result.exit_code == 2 + assert "Pass artifact ids or --all, not both." in result.output + + def test_rejects_missing_selection(self, tmp_path: pathlib.Path) -> None: + config_path = helpers.write_config(tmp_path) + + result = helpers.invoke(["--config", str(config_path), "validate"]) + + assert result.exit_code == 2 + assert "Pass artifact ids or --all." in result.output + + +class TestParseArtifactIdArgument: + def test_rejects_unsupported_artifact_extension(self, tmp_path: pathlib.Path) -> None: + config_path = helpers.write_config(tmp_path) + (tmp_path / "workflows").mkdir() + (tmp_path / "workflows" / "test.md").write_text("# Test\n", encoding="utf-8") + + result = helpers.invoke(["--config", str(config_path), "render", "--mode", "view", "@/workflows/test.md"]) + + assert result.exit_code != 0 + assert "Unsupported artifact extension" in result.output diff --git a/donna/cli/tests/test_sessions.py b/donna/cli/tests/test_sessions.py new file mode 100644 index 00000000..d5acc572 --- /dev/null +++ b/donna/cli/tests/test_sessions.py @@ -0,0 +1,28 @@ +import pathlib + +from donna.cli.tests import helpers + + +class TestNewSession: + def test_creates_fresh_session_state(self, tmp_path: pathlib.Path) -> None: + config_path = helpers.write_config(tmp_path) + + result = helpers.invoke(["--config", str(config_path), "-p", "llm", "new-session"]) + + assert result.exit_code == 0 + assert "kind=operation_succeeded" in result.output + assert (tmp_path / ".session" / "donna" / "state.json").is_file() + + +class TestStatus: + def test_reports_new_session_status(self, tmp_path: pathlib.Path) -> None: + config_path = helpers.write_config(tmp_path) + + result = helpers.invoke(["--config", str(config_path), "-p", "llm", "status"]) + + assert result.exit_code == 0 + assert "kind=session_state_status" in result.output + assert "tasks=0" in result.output + assert "queued_work_units=0" in result.output + assert "pending_action_requests=0" in result.output + assert "This is a new session" in result.output diff --git a/donna/cli/tests/test_skills.py b/donna/cli/tests/test_skills.py new file mode 100644 index 00000000..1f7dacd8 --- /dev/null +++ b/donna/cli/tests/test_skills.py @@ -0,0 +1,27 @@ +from donna.cli.tests import helpers + + +class TestSkill: + def test_default_document_outputs_usage_skill_without_workspace_config(self) -> None: + result = helpers.invoke(["-p", "llm", "skill"]) + + assert result.exit_code == 0 + assert "kind=skill" in result.output + assert "document=usage" in result.output + assert "# `donna` Usage" in result.output + + def test_document_argument_selects_skill_document(self) -> None: + result = helpers.invoke(["-p", "automation", "skill", "configuration"]) + + assert result.exit_code == 0 + records = helpers.json_lines(result.output) + assert records[0]["document"] == "configuration" + content = records[0]["content"] + assert isinstance(content, str) + assert content.startswith("# `donna` Configuration") + + def test_unknown_document_fails_as_invalid_cli_argument(self) -> None: + result = helpers.invoke(["skill", "missing"]) + + assert result.exit_code != 0 + assert "Invalid value" in result.output diff --git a/donna/cli/tests/test_version.py b/donna/cli/tests/test_version.py new file mode 100644 index 00000000..530a5042 --- /dev/null +++ b/donna/cli/tests/test_version.py @@ -0,0 +1,14 @@ +from pytest_mock import MockerFixture + +from donna.cli.tests import helpers + + +class TestVersion: + def test_prints_package_version_line(self, mocker: MockerFixture) -> None: + helpers.load_cli_commands() + mocker.patch("donna.cli.commands.version.importlib.metadata.version", return_value="9.8.7") + + result = helpers.invoke(["version"]) + + assert result.exit_code == 0 + assert result.output == "9.8.7\n" diff --git a/donna/cli/tests/test_workspaces.py b/donna/cli/tests/test_workspaces.py new file mode 100644 index 00000000..1b5e7806 --- /dev/null +++ b/donna/cli/tests/test_workspaces.py @@ -0,0 +1,28 @@ +import pathlib + +import pytest + +from donna.cli.tests import helpers + + +class TestInit: + def test_creates_config_in_current_directory_by_default( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path + ) -> None: + monkeypatch.chdir(tmp_path) + + result = helpers.invoke(["-p", "automation", "init"]) + + assert result.exit_code == 0 + assert (tmp_path / "donna.toml").is_file() + records = helpers.json_lines(result.output) + assert records[0]["content"] == "Donna project initialized successfully" + + def test_config_option_selects_target_file(self, tmp_path: pathlib.Path) -> None: + config_path = tmp_path / "custom.toml" + + result = helpers.invoke(["--config", str(config_path), "-p", "llm", "init"]) + + assert result.exit_code == 0 + assert config_path.is_file() + assert "kind=operation_succeeded" in result.output diff --git a/donna/cli/types.py b/donna/cli/types.py index b97be602..e8c2b4f1 100644 --- a/donna/cli/types.py +++ b/donna/cli/types.py @@ -15,14 +15,15 @@ from donna.domain.constants import DONNA_ARTIFACT_EXTENSION from donna.domain.internal_ids import ActionRequestId from donna.domain.paths import PathInput, UntrustedPath +from donna.machine.templates import RenderMode +from donna.protocol.errors import environment_error_node from donna.protocol.modes import Mode from donna.workspaces import paths as workspace_paths from donna.workspaces.artifacts import has_donna_artifact_extension -from donna.workspaces.templates import RenderMode def _exit_with_errors(errors: ErrorsList) -> NoReturn: - output_cells([error.node().info() for error in errors]) + output_cells([environment_error_node(error).info() for error in errors]) raise typer.Exit(code=0) @@ -169,19 +170,15 @@ def _parse_input_path(value: str) -> UntrustedPath: ] -RootOption = Annotated[ +ConfigOption = Annotated[ pathlib.Path | None, typer.Option( - "--root", - "-r", + "--config", resolve_path=True, - file_okay=False, - dir_okay=True, - exists=True, - help=( - "Optional project root directory. " - "If omitted, Donna discovers it by searching parent directories for donna.toml." - ), + file_okay=True, + dir_okay=False, + exists=False, + help="Optional project config file. If omitted, Donna discovers donna.toml by searching parent directories.", ), ] diff --git a/donna/cli/utils.py b/donna/cli/utils.py index ef7ffa50..5539f452 100644 --- a/donna/cli/utils.py +++ b/donna/cli/utils.py @@ -2,25 +2,51 @@ import sys from collections.abc import Iterable, Iterator from contextlib import contextmanager +from contextvars import Token import typer from donna.cli.entities import GLOBAL_OPTIONS_CONTEXT_KEY, GlobalOptions +from donna.context.context import Context from donna.core.errors import EnvironmentError, ErrorsList from donna.core.result import UnwrapError -from donna.domain.paths import PathInput, UntrustedPath +from donna.domain.constants import DONNA_CONFIG_NAME +from donna.domain.paths import PathInput, ProjectConfigPath, UntrustedPath from donna.protocol.cells import Cell +from donna.protocol.errors import environment_error_node +from donna.protocol.formatters import Formatter +from donna.protocol.journal import JournalRecord from donna.protocol.modes import Mode, get_cell_formatter from donna.workspaces import config as workspace_config from donna.workspaces.initialization import load_workspace +def instant_output(text: bytes) -> None: + if text.endswith(b"\n"): + sys.stdout.buffer.write(text) + else: + sys.stdout.buffer.write(text + b"\n") + sys.stdout.buffer.flush() + + +class CliEmitter: + __slots__ = ("_formatter",) + + def __init__(self, formatter: Formatter) -> None: + self._formatter = formatter + + def emit_cell(self, cell: Cell) -> None: + instant_output(self._formatter.format_cell(cell)) + + def emit_journal(self, record: JournalRecord) -> None: + instant_output(self._formatter.format_journal(record)) + + def output_cells(cells: Iterable[Cell]) -> None: - formatter = get_cell_formatter() + emitter = CliEmitter(get_cell_formatter(workspace_config.protocol())) for cell in cells: - output = formatter.format_cell(cell) - sys.stdout.buffer.write(output) + emitter.emit_cell(cell) def global_options(context: typer.Context) -> GlobalOptions: @@ -33,24 +59,31 @@ def global_options(context: typer.Context) -> GlobalOptions: class CommandContext: - __slots__ = ("global_options", "protocol") + __slots__ = ("emitter", "global_options", "protocol") def __init__(self, context: typer.Context) -> None: self.global_options = global_options(context) self.protocol = self.global_options.protocol + self.emitter = CliEmitter(get_cell_formatter(self.protocol)) def install_protocol(self) -> None: if not workspace_config.protocol.is_set(): workspace_config.protocol.set(self.protocol) def load_workspace(self) -> workspace_config.Workspace: - workspace = load_workspace(root_dir=self.global_options.root_dir).unwrap() + workspace = load_workspace(config_path=self.global_options.config_path).unwrap() workspace_config.install_workspace(workspace) return workspace + def target_config_path(self) -> ProjectConfigPath: + if self.global_options.config_path is not None: + return ProjectConfigPath(self.global_options.config_path) + + return ProjectConfigPath(pathlib.Path.cwd() / DONNA_CONFIG_NAME) + def target_dir(self) -> PathInput: - if self.global_options.root_dir is not None: - return self.global_options.root_dir + if self.global_options.config_path is not None: + return UntrustedPath(pathlib.Path(self.global_options.config_path).parent) if workspace_config.project_dir.is_set(): return workspace_config.project_dir() @@ -58,35 +91,47 @@ def target_dir(self) -> PathInput: return UntrustedPath(pathlib.Path.cwd()) def write_cells(self, cells: Iterable[Cell]) -> None: - output_cells(cells) + for cell in cells: + self.emitter.emit_cell(cell) @contextmanager def command_context(context: typer.Context, *, load_environment: bool = True) -> Iterator[CommandContext]: - from donna.context import Context, set_context + from donna.context import reset_context, set_context + from donna.machine import context as machine_context command = CommandContext(context) + context_token: Token[Context | None] | None = None + machine_context_token: Token[machine_context.MachineContext | None] | None = None try: command.install_protocol() if load_environment: command.load_workspace() - set_context(Context()) + runtime_context = Context(output=command.emitter) + context_token = set_context(runtime_context) + machine_context_token = machine_context.set_context(runtime_context) yield command except UnwrapError as error: command.write_cells(_cells_from_unwrap(error)) raise typer.Exit(code=0) from error + finally: + if machine_context_token is not None: + machine_context.reset_context(machine_context_token) + + if context_token is not None: + reset_context(context_token) def _write_errors_to_journal(errors: ErrorsList) -> None: - from donna.machine import journal as machine_journal + from donna.context.context import context for error in errors: - message = f"Error: {error.node().journal_message()} [{error.code}]" + message = f"Error: {environment_error_node(error).journal_message()} [{error.code}]" - machine_journal.add( + context().journal.add( message=message, actor_id="donna", ) @@ -110,4 +155,4 @@ def _cells_from_unwrap(error: UnwrapError) -> Iterable[Cell]: if workspace_config.config.is_set(): _write_errors_to_journal(errors) - return [item.node().info() for item in errors] + return [environment_error_node(item).info() for item in errors] diff --git a/donna/context/__init__.py b/donna/context/__init__.py index 1bb672b8..44a7f0ca 100644 --- a/donna/context/__init__.py +++ b/donna/context/__init__.py @@ -1,3 +1,4 @@ from donna.context.context import Context, context, reset_context, set_context +from donna.context.journal import Journal -__all__ = ("Context", "context", "set_context", "reset_context") +__all__ = ("Context", "Journal", "context", "set_context", "reset_context") diff --git a/donna/context/artifacts.py b/donna/context/artifacts.py index 14bf9323..61d38617 100644 --- a/donna/context/artifacts.py +++ b/donna/context/artifacts.py @@ -4,8 +4,9 @@ from donna.core.result import Err, Ok, Result, unwrap_to_error from donna.domain.artifact_ids import ArtifactId from donna.machine.artifacts import Artifact +from donna.machine.tasks import Task, WorkUnit +from donna.machine.templates import RenderMode from donna.workspaces import errors as workspace_errors -from donna.workspaces.templates import RenderMode if TYPE_CHECKING: from donna.workspaces.artifacts import ArtifactRenderContext, FilesystemRawArtifact @@ -78,6 +79,28 @@ def _get_cache_value(self, artifact_id: ArtifactId) -> Result[_ArtifactCacheValu def invalidate(self, artifact_id: ArtifactId) -> None: self._cache.pop(artifact_id, None) + def load_for_view(self, artifact_id: ArtifactId) -> Result[Artifact, ErrorsList]: + from donna.workspaces.artifacts import RENDER_CONTEXT_VIEW + + return self.load(artifact_id, RENDER_CONTEXT_VIEW) + + def load_for_execution( + self, + artifact_id: ArtifactId, + task: Task, + work_unit: WorkUnit, + ) -> Result[Artifact, ErrorsList]: + from donna.workspaces.artifacts import ArtifactRenderContext + + return self.load( + artifact_id, + ArtifactRenderContext( + primary_mode=RenderMode.execute, + current_task=task, + current_work_unit=work_unit, + ), + ) + @unwrap_to_error def load( # noqa: CCR001 self, diff --git a/donna/context/context.py b/donna/context/context.py index a10ba527..bf01c1c8 100644 --- a/donna/context/context.py +++ b/donna/context/context.py @@ -1,11 +1,13 @@ import contextvars from donna.context.artifacts import ArtifactsCache +from donna.context.journal import Journal +from donna.context.output import NoopEmitter, OutputEmitter from donna.context.primitives import PrimitivesCache from donna.context.state import StateCache -from donna.context.value_scope import ValueScope from donna.domain.artifact_ids import ArtifactSectionId from donna.domain.internal_ids import WorkUnitId +from donna.machine.context import ValueScope class Context: @@ -13,14 +15,18 @@ class Context: "_artifacts", "_state", "_primitives", + "_journal", + "output", "current_work_unit_id", "current_operation_id", ) - def __init__(self) -> None: + def __init__(self, output: OutputEmitter | None = None) -> None: self._artifacts = ArtifactsCache() self._state = StateCache() self._primitives = PrimitivesCache() + self._journal = Journal(self) + self.output = output if output is not None else NoopEmitter() self.current_work_unit_id: ValueScope[WorkUnitId] = ValueScope() self.current_operation_id: ValueScope[ArtifactSectionId] = ValueScope() @@ -36,6 +42,10 @@ def state(self) -> StateCache: def primitives(self) -> PrimitivesCache: return self._primitives + @property + def journal(self) -> Journal: + return self._journal + _context_var: contextvars.ContextVar[Context | None] = contextvars.ContextVar("donna_machine_context", default=None) diff --git a/donna/context/journal.py b/donna/context/journal.py new file mode 100644 index 00000000..e71a9495 --- /dev/null +++ b/donna/context/journal.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from donna.core.errors import ErrorsList +from donna.core.result import Ok, Result, unwrap_to_error +from donna.core.utils import now +from donna.domain.artifact_ids import ArtifactSectionId +from donna.domain.internal_ids import TaskId, WorkUnitId +from donna.protocol import errors as protocol_errors +from donna.protocol import journal as protocol_journal +from donna.protocol import modes as protocol_modes +from donna.workspaces import journal as workspace_journal +from donna.workspaces.config import protocol as protocol_mode + +if TYPE_CHECKING: + from donna.context.context import Context + + +class Journal: + __slots__ = ("_context",) + + def __init__(self, context: Context) -> None: + self._context = context + + def smart_actor_id(self) -> str: + match protocol_mode(): + case protocol_modes.Mode.human: + return "human" + case protocol_modes.Mode.llm: + return "agent" + case protocol_modes.Mode.automation: + return "automation" + case _: + raise protocol_errors.UnsupportedFormatterMode(mode=protocol_mode()) + + @unwrap_to_error + def add( + self, + message: str, + actor_id: str | None = None, + ) -> Result[protocol_journal.JournalRecord, ErrorsList]: + if actor_id is None: + actor_id = self.smart_actor_id() + + state = self._context.state.load().unwrap() + parsed_task_id: TaskId | None = state.current_task.id if state.current_task else None + parsed_work_unit_id: WorkUnitId | None = self._context.current_work_unit_id.get() + parsed_operation_id: ArtifactSectionId | None = self._context.current_operation_id.get() + + record = protocol_journal.JournalRecord( + timestamp=now(), + actor_id=actor_id, + message=message, + current_task_id=parsed_task_id, + current_work_unit_id=parsed_work_unit_id, + current_operation_id=parsed_operation_id, + ) + + workspace_journal.write_record(record).unwrap() + self._context.output.emit_journal(record) + + return Ok(record) diff --git a/donna/context/output.py b/donna/context/output.py new file mode 100644 index 00000000..3b38ca7e --- /dev/null +++ b/donna/context/output.py @@ -0,0 +1,22 @@ +from typing import Protocol + +from donna.protocol.cells import Cell +from donna.protocol.journal import JournalRecord + + +class OutputEmitter(Protocol): + def emit_cell(self, cell: Cell) -> None: + pass + + def emit_journal(self, record: JournalRecord) -> None: + pass + + +class NoopEmitter: + __slots__ = () + + def emit_cell(self, cell: Cell) -> None: + pass + + def emit_journal(self, record: JournalRecord) -> None: + pass diff --git a/donna/context/tests/__init__.py b/donna/context/tests/__init__.py new file mode 100644 index 00000000..76e4fa5c --- /dev/null +++ b/donna/context/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for donna.context.""" diff --git a/donna/context/tests/helpers.py b/donna/context/tests/helpers.py new file mode 100644 index 00000000..d9311cab --- /dev/null +++ b/donna/context/tests/helpers.py @@ -0,0 +1,27 @@ +from donna.core.errors import ErrorsList +from donna.core.result import Ok, Result +from donna.protocol.cells import Cell +from donna.protocol.journal import JournalRecord + + +class FakeOutputEmitter: + def __init__(self) -> None: + self.cells: list[Cell] = [] + self.journal_records: list[JournalRecord] = [] + + def emit_cell(self, cell: Cell) -> None: + self.cells.append(cell) + + def emit_journal(self, record: JournalRecord) -> None: + self.journal_records.append(record) + + +class FakeJournal: + def __init__(self) -> None: + self.messages: list[tuple[str | None, str]] = [] + self.records: list[dict[str, object]] = [] + + def add(self, message: str, actor_id: str | None = None) -> Result[object, ErrorsList]: + self.messages.append((actor_id, message)) + self.records.append({"message": message, "actor_id": actor_id}) + return Ok(None) diff --git a/donna/context/tests/make.py b/donna/context/tests/make.py new file mode 100644 index 00000000..ad96a3b5 --- /dev/null +++ b/donna/context/tests/make.py @@ -0,0 +1,18 @@ +import pathlib + +from donna.core.errors import ErrorsList +from donna.core.result import Ok, Result +from donna.machine.artifacts import Artifact +from donna.machine.templates import RenderMode +from donna.workspaces.artifacts import ArtifactRenderContext + + +class FakeRawArtifact: + def __init__(self, path: pathlib.Path, artifact: Artifact) -> None: + self.path = path + self.artifact = artifact + self.render_modes: list[RenderMode] = [] + + def render(self, artifact_id: object, render_context: ArtifactRenderContext) -> Result[Artifact, ErrorsList]: + self.render_modes.append(render_context.primary_mode) + return Ok(self.artifact) diff --git a/donna/context/tests/test_artifacts.py b/donna/context/tests/test_artifacts.py new file mode 100644 index 00000000..5b0204d5 --- /dev/null +++ b/donna/context/tests/test_artifacts.py @@ -0,0 +1,148 @@ +import pathlib + +from pytest_mock import MockerFixture + +from donna.context.artifacts import ArtifactsCache +from donna.context.tests import make +from donna.core.result import Ok +from donna.domain.artifact_ids import ArtifactId +from donna.machine.templates import RenderMode +from donna.machine.tests import make as machine_make +from donna.workspaces import artifacts as workspace_artifacts +from donna.workspaces import errors as workspace_errors +from donna.workspaces.files import FileFingerprint + + +def _write_artifact_file(tmp_path: pathlib.Path, name: str, content: str) -> pathlib.Path: + path = tmp_path / name + path.write_text(content, encoding="utf-8") + return path + + +class TestArtifactsCache: + def test_load__caches_view_rendered_artifact_by_render_mode( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + path = _write_artifact_file(tmp_path, "workflow.donna.md", "# Workflow") + raw_artifact = make.FakeRawArtifact(path, machine_make.artifact()) + mocker.patch("donna.workspaces.artifacts.fetch_raw_artifact", return_value=Ok(raw_artifact)) + mocker.patch( + "donna.workspaces.artifacts.artifact_fingerprint", return_value=Ok(FileFingerprint.from_path(path)) + ) + cache = ArtifactsCache() + + first_result = cache.load(machine_make.ARTIFACT_ID, workspace_artifacts.RENDER_CONTEXT_VIEW) + second_result = cache.load(machine_make.ARTIFACT_ID, workspace_artifacts.RENDER_CONTEXT_VIEW) + + assert first_result.is_ok() + assert second_result.is_ok() + assert first_result.unwrap() == second_result.unwrap() + assert raw_artifact.render_modes == [RenderMode.view] + + def test_load__renders_execute_mode_every_time(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + path = _write_artifact_file(tmp_path, "workflow.donna.md", "# Workflow") + raw_artifact = make.FakeRawArtifact(path, machine_make.artifact()) + mocker.patch("donna.workspaces.artifacts.fetch_raw_artifact", return_value=Ok(raw_artifact)) + mocker.patch( + "donna.workspaces.artifacts.artifact_fingerprint", return_value=Ok(FileFingerprint.from_path(path)) + ) + render_context = workspace_artifacts.ArtifactRenderContext(primary_mode=RenderMode.execute) + cache = ArtifactsCache() + + assert cache.load(machine_make.ARTIFACT_ID, render_context).is_ok() + assert cache.load(machine_make.ARTIFACT_ID, render_context).is_ok() + + assert raw_artifact.render_modes == [RenderMode.execute, RenderMode.execute] + + def test_load__refreshes_stale_raw_artifact(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + first_path = _write_artifact_file(tmp_path, "first.donna.md", "# First") + second_path = _write_artifact_file(tmp_path, "second.donna.md", "# Second with changed size") + first_raw_artifact = make.FakeRawArtifact(first_path, machine_make.artifact()) + second_raw_artifact = make.FakeRawArtifact(second_path, machine_make.artifact()) + mocker.patch( + "donna.workspaces.artifacts.fetch_raw_artifact", + side_effect=[Ok(first_raw_artifact), Ok(second_raw_artifact)], + ) + mocker.patch( + "donna.workspaces.artifacts.artifact_fingerprint", + return_value=Ok(FileFingerprint.from_path(second_path)), + ) + cache = ArtifactsCache() + + assert cache.load(machine_make.ARTIFACT_ID, workspace_artifacts.RENDER_CONTEXT_VIEW).is_ok() + result = cache.load(machine_make.ARTIFACT_ID, workspace_artifacts.RENDER_CONTEXT_VIEW) + + assert result.is_ok() + assert first_raw_artifact.render_modes == [RenderMode.view] + assert second_raw_artifact.render_modes == [RenderMode.view] + + def test_load__reports_missing_artifact_after_fetch(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + missing_path = tmp_path / "missing.donna.md" + raw_artifact = make.FakeRawArtifact(missing_path, machine_make.artifact()) + mocker.patch("donna.workspaces.artifacts.fetch_raw_artifact", return_value=Ok(raw_artifact)) + + result = ArtifactsCache().load(machine_make.ARTIFACT_ID, workspace_artifacts.RENDER_CONTEXT_VIEW) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, workspace_errors.ArtifactNotFound) + assert error.artifact_id == machine_make.ARTIFACT_ID + + def test_invalidate__removes_cached_rendered_artifact(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + path = _write_artifact_file(tmp_path, "workflow.donna.md", "# Workflow") + raw_artifact = make.FakeRawArtifact(path, machine_make.artifact()) + mocker.patch("donna.workspaces.artifacts.fetch_raw_artifact", return_value=Ok(raw_artifact)) + mocker.patch( + "donna.workspaces.artifacts.artifact_fingerprint", return_value=Ok(FileFingerprint.from_path(path)) + ) + cache = ArtifactsCache() + assert cache.load(machine_make.ARTIFACT_ID, workspace_artifacts.RENDER_CONTEXT_VIEW).is_ok() + + cache.invalidate(machine_make.ARTIFACT_ID) + assert cache.load(machine_make.ARTIFACT_ID, workspace_artifacts.RENDER_CONTEXT_VIEW).is_ok() + + assert raw_artifact.render_modes == [RenderMode.view, RenderMode.view] + + def test_list__returns_loaded_artifacts_in_workspace_order( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + first_id = machine_make.ARTIFACT_ID + second_id = ArtifactId("@/workflows/other.donna.md") + first_path = _write_artifact_file(tmp_path, "first.donna.md", "# First") + second_path = _write_artifact_file(tmp_path, "second.donna.md", "# Second") + first_artifact = machine_make.artifact() + second_artifact = machine_make.artifact().replace(id=second_id) + mocker.patch("donna.workspaces.artifacts.list_artifact_ids", return_value=[first_id, second_id]) + mocker.patch( + "donna.workspaces.artifacts.fetch_raw_artifact", + side_effect=[ + Ok(make.FakeRawArtifact(first_path, first_artifact)), + Ok(make.FakeRawArtifact(second_path, second_artifact)), + ], + ) + + result = ArtifactsCache().list(workspace_artifacts.RENDER_CONTEXT_VIEW) + + assert result.is_ok() + assert result.unwrap() == [first_artifact, second_artifact] + + def test_list__collects_artifact_loading_errors(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + missing_id = ArtifactId("@/workflows/missing.donna.md") + path = _write_artifact_file(tmp_path, "workflow.donna.md", "# Workflow") + mocker.patch( + "donna.workspaces.artifacts.list_artifact_ids", return_value=[machine_make.ARTIFACT_ID, missing_id] + ) + mocker.patch( + "donna.workspaces.artifacts.fetch_raw_artifact", + side_effect=[ + Ok(make.FakeRawArtifact(path, machine_make.artifact())), + Ok(make.FakeRawArtifact(tmp_path / "missing.donna.md", machine_make.artifact())), + ], + ) + + result = ArtifactsCache().list(workspace_artifacts.RENDER_CONTEXT_VIEW) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, workspace_errors.ArtifactNotFound) + assert error.artifact_id == missing_id diff --git a/donna/context/tests/test_context.py b/donna/context/tests/test_context.py new file mode 100644 index 00000000..19362821 --- /dev/null +++ b/donna/context/tests/test_context.py @@ -0,0 +1,75 @@ +import pytest + +from donna.context.artifacts import ArtifactsCache +from donna.context.context import Context, context, reset_context, set_context +from donna.context.journal import Journal +from donna.context.output import NoopEmitter +from donna.context.primitives import PrimitivesCache +from donna.context.state import StateCache +from donna.context.tests.helpers import FakeOutputEmitter + + +class TestContext: + def test_init__creates_invocation_local_caches_and_scopes(self) -> None: + runtime_context = Context() + + assert isinstance(runtime_context.artifacts, ArtifactsCache) + assert isinstance(runtime_context.state, StateCache) + assert isinstance(runtime_context.primitives, PrimitivesCache) + assert isinstance(runtime_context.journal, Journal) + assert isinstance(runtime_context.output, NoopEmitter) + assert runtime_context.current_work_unit_id.get() is None + assert runtime_context.current_operation_id.get() is None + + def test_init__uses_explicit_output_emitter(self) -> None: + output = FakeOutputEmitter() + + runtime_context = Context(output=output) + + assert runtime_context.output == output + + +class TestSetContext: + def test_sets_current_context(self) -> None: + runtime_context = Context() + token = set_context(runtime_context) + + try: + assert context() == runtime_context + finally: + reset_context(token) + + +class TestResetContext: + def test_restores_previous_context(self) -> None: + outer_context = Context() + inner_context = Context() + outer_token = set_context(outer_context) + + try: + inner_token = set_context(inner_context) + assert context() == inner_context + + reset_context(inner_token) + + assert context() == outer_context + finally: + reset_context(outer_token) + + +class TestContextFunction: + def test_context__raises_when_not_set(self) -> None: + with pytest.raises(RuntimeError): + context() + + def test_context__returns_current_context(self) -> None: + runtime_context = Context() + token = set_context(runtime_context) + + try: + assert context() == runtime_context + finally: + reset_context(token) + + with pytest.raises(RuntimeError): + context() diff --git a/donna/context/tests/test_journal.py b/donna/context/tests/test_journal.py new file mode 100644 index 00000000..f7521232 --- /dev/null +++ b/donna/context/tests/test_journal.py @@ -0,0 +1,89 @@ +import datetime +from typing import cast + +import pytest +from pytest_mock import MockerFixture + +from donna.context.context import Context +from donna.context.journal import Journal +from donna.context.tests.helpers import FakeOutputEmitter +from donna.core.result import Ok +from donna.domain.artifact_ids import ArtifactSectionId +from donna.domain.internal_ids import WorkUnitId +from donna.machine.context import ValueScope +from donna.machine.tests import make as machine_make +from donna.protocol import errors as protocol_errors +from donna.protocol import modes as protocol_modes + + +class _FakeStateCache: + def __init__(self) -> None: + self.state = machine_make.mutable_state(tasks=[machine_make.task()]).freeze() + + def load(self) -> object: + return Ok(self.state) + + +class _FakeContext: + def __init__(self) -> None: + self.state = _FakeStateCache() + self.current_work_unit_id: ValueScope[WorkUnitId] = ValueScope() + self.current_operation_id: ValueScope[ArtifactSectionId] = ValueScope() + self.output = FakeOutputEmitter() + + +class TestJournal: + @pytest.mark.parametrize( + ("mode", "actor_id"), + [ + (protocol_modes.Mode.human, "human"), + (protocol_modes.Mode.llm, "agent"), + (protocol_modes.Mode.automation, "automation"), + ], + ) + def test_smart_actor_id__depends_on_selected_protocol( + self, mocker: MockerFixture, mode: protocol_modes.Mode, actor_id: str + ) -> None: + mocker.patch("donna.context.journal.protocol_mode", return_value=mode) + + assert Journal(cast(Context, _FakeContext())).smart_actor_id() == actor_id + + def test_smart_actor_id__raises_for_unsupported_mode(self, mocker: MockerFixture) -> None: + mocker.patch("donna.context.journal.protocol_mode", return_value="unsupported") + + with pytest.raises(protocol_errors.UnsupportedFormatterMode): + Journal(cast(Context, _FakeContext())).smart_actor_id() + + def test_add__builds_writes_and_emits_journal_record(self, mocker: MockerFixture) -> None: + now = datetime.datetime(2026, 5, 18, 8, 30, tzinfo=datetime.UTC) + fake_context = _FakeContext() + mocker.patch("donna.context.journal.protocol_mode", return_value=protocol_modes.Mode.llm) + mocker.patch("donna.context.journal.now", return_value=now) + write_record = mocker.patch("donna.context.journal.workspace_journal.write_record", return_value=Ok(None)) + + with fake_context.current_work_unit_id.scope(machine_make.WORK_UNIT_ID): + with fake_context.current_operation_id.scope(machine_make.PRIMARY_OPERATION_ID): + result = Journal(cast(Context, fake_context)).add("message") + + assert result.is_ok() + record = result.unwrap() + assert record.timestamp == now + assert record.actor_id == "agent" + assert record.message == "message" + assert record.current_task_id == machine_make.TASK_ID + assert record.current_work_unit_id == machine_make.WORK_UNIT_ID + assert record.current_operation_id == machine_make.PRIMARY_OPERATION_ID + write_record.assert_called_once_with(record) + assert fake_context.output.journal_records == [record] + + def test_add__uses_explicit_actor_id(self, mocker: MockerFixture) -> None: + fake_context = _FakeContext() + mocker.patch("donna.context.journal.now", return_value=datetime.datetime(2026, 5, 18, tzinfo=datetime.UTC)) + mocker.patch("donna.context.journal.workspace_journal.write_record", return_value=Ok(None)) + protocol_mode = mocker.patch("donna.context.journal.protocol_mode") + + result = Journal(cast(Context, fake_context)).add("message", actor_id="donna") + + assert result.is_ok() + assert result.unwrap().actor_id == "donna" + protocol_mode.assert_not_called() diff --git a/donna/context/tests/test_primitives.py b/donna/context/tests/test_primitives.py new file mode 100644 index 00000000..e4296d98 --- /dev/null +++ b/donna/context/tests/test_primitives.py @@ -0,0 +1,67 @@ +from pytest_mock import MockerFixture + +from donna.context.primitives import PrimitivesCache +from donna.domain.python_path import PythonPath +from donna.machine import errors as machine_errors +from donna.machine.tests import make as machine_make +from donna.machine.tests.test_primitives import sample_primitive + + +def _python_path(value: str) -> PythonPath: + normalized = PythonPath.normalize_raw_value(value) + assert normalized is not None + return PythonPath(normalized) + + +class TestPrimitivesCache: + def test_resolve__returns_primitive_from_python_path(self) -> None: + cache = PrimitivesCache() + + result = cache.resolve(machine_make.PRIMITIVE_PATH) + + assert result.is_ok() + assert result.unwrap() == sample_primitive + + def test_resolve__uses_cached_primitive_on_repeated_calls(self, mocker: MockerFixture) -> None: + cache = PrimitivesCache() + assert cache.resolve(machine_make.PRIMITIVE_PATH).is_ok() + import_module = mocker.patch("donna.context.primitives.importlib.import_module") + + result = cache.resolve(machine_make.PRIMITIVE_PATH) + + assert result.is_ok() + assert result.unwrap() == sample_primitive + import_module.assert_not_called() + + def test_resolve__rejects_path_without_attribute_name(self) -> None: + result = PrimitivesCache().resolve(_python_path("primitive")) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, machine_errors.PrimitiveInvalidImportPath) + assert error.import_path == "primitive" + + def test_resolve__reports_not_importable_module(self) -> None: + result = PrimitivesCache().resolve(_python_path("donna.machine.tests.missing.sample_primitive")) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, machine_errors.PrimitiveModuleNotImportable) + assert error.module_path == "donna.machine.tests.missing" + + def test_resolve__reports_missing_primitive_attribute(self) -> None: + result = PrimitivesCache().resolve(_python_path("donna.machine.tests.test_primitives.missing_primitive")) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, machine_errors.PrimitiveNotAvailable) + assert error.import_path == "donna.machine.tests.test_primitives.missing_primitive" + assert error.module_path == "donna.machine.tests.test_primitives" + + def test_resolve__reports_non_primitive_object(self) -> None: + result = PrimitivesCache().resolve(_python_path("donna.machine.tests.test_primitives.sample_non_primitive")) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, machine_errors.PrimitiveNotPrimitive) + assert error.import_path == "donna.machine.tests.test_primitives.sample_non_primitive" diff --git a/donna/context/tests/test_state.py b/donna/context/tests/test_state.py new file mode 100644 index 00000000..d5e9d946 --- /dev/null +++ b/donna/context/tests/test_state.py @@ -0,0 +1,95 @@ +import pathlib + +from pytest_mock import MockerFixture + +from donna.context.state import StateCache +from donna.domain.constants import STATE_FILE_NAME +from donna.domain.paths import RelativeProjectPath +from donna.machine import errors as machine_errors +from donna.machine.tests import make as machine_make +from donna.workspaces import sessions as workspace_sessions +from donna.workspaces.config import Config + + +def _patch_session_globals(mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + mocker.patch("donna.workspaces.sessions.project_dir", return_value=tmp_path) + mocker.patch( + "donna.workspaces.sessions.config", + return_value=Config(session_dir=RelativeProjectPath(pathlib.Path(".session/donna"))), + ) + + +class TestStateCache: + def test_load__reports_not_initialized_without_state_file( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + _patch_session_globals(mocker, tmp_path) + + result = StateCache().load() + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], machine_errors.SessionStateNotInitialized) + + def test_load__reads_consistent_state_from_workspace(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + _patch_session_globals(mocker, tmp_path) + state = machine_make.mutable_state(tasks=[machine_make.task()]).freeze() + workspace_sessions.write_state(state.to_json().encode("utf-8")) + + result = StateCache().load() + + assert result.is_ok() + assert result.unwrap() == state + + def test_load__returns_cached_state_while_fingerprint_matches( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + _patch_session_globals(mocker, tmp_path) + state = machine_make.mutable_state(tasks=[machine_make.task()]).freeze() + workspace_sessions.write_state(state.to_json().encode("utf-8")) + cache = StateCache() + assert cache.load().is_ok() + read_state = mocker.patch("donna.workspaces.sessions.read_state") + + result = cache.load() + + assert result.is_ok() + assert result.unwrap() == state + read_state.assert_not_called() + + def test_load__reports_cached_state_changed_externally( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + _patch_session_globals(mocker, tmp_path) + state = machine_make.mutable_state(tasks=[machine_make.task()]).freeze() + workspace_sessions.write_state(state.to_json().encode("utf-8")) + cache = StateCache() + assert cache.load().is_ok() + (tmp_path / ".session" / "donna" / STATE_FILE_NAME).write_bytes(b"external change") + + result = cache.load() + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], machine_errors.SessionStateChangedExternally) + + def test_save__writes_state_and_updates_cache(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + _patch_session_globals(mocker, tmp_path) + state = machine_make.mutable_state(tasks=[machine_make.task()]).freeze() + + result = StateCache().save(state) + + assert result.is_ok() + assert workspace_sessions.read_state() == state.to_json().encode("utf-8") + + def test_save__reports_cached_state_changed_externally( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + _patch_session_globals(mocker, tmp_path) + state = machine_make.mutable_state(tasks=[machine_make.task()]).freeze() + cache = StateCache() + assert cache.save(state).is_ok() + (tmp_path / ".session" / "donna" / STATE_FILE_NAME).write_bytes(b"external change") + + result = cache.save(state) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], machine_errors.SessionStateChangedExternally) diff --git a/donna/context/value_scope.py b/donna/context/value_scope.py deleted file mode 100644 index beac9bfd..00000000 --- a/donna/context/value_scope.py +++ /dev/null @@ -1,24 +0,0 @@ -from collections.abc import Iterator -from contextlib import contextmanager -from typing import Generic, TypeVar - -V = TypeVar("V") - - -class ValueScope(Generic[V]): - __slots__ = ("_value",) - - def __init__(self, initial: V | None = None) -> None: - self._value: V | None = initial - - def get(self) -> V | None: - return self._value - - @contextmanager - def scope(self, value: V | None) -> Iterator[None]: - previous = self._value - self._value = value - try: - yield - finally: - self._value = previous diff --git a/donna/core/__init__.py b/donna/core/__init__.py index e69de29b..0ef470c6 100644 --- a/donna/core/__init__.py +++ b/donna/core/__init__.py @@ -0,0 +1,6 @@ +from donna.core import entities as entities +from donna.core import errors as errors +from donna.core import result as result +from donna.core import utils as utils + +__all__ = ("entities", "errors", "result", "utils") diff --git a/donna/core/entities.py b/donna/core/entities.py index 570d8710..efc263b0 100644 --- a/donna/core/entities.py +++ b/donna/core/entities.py @@ -1,4 +1,4 @@ -from typing import Any, TypeVar +from typing import TypeVar import pydantic @@ -15,7 +15,7 @@ class BaseEntity(pydantic.BaseModel): from_attributes=False, ) - def replace(self: BASE_ENTITY, **kwargs: Any) -> BASE_ENTITY: + def replace(self: BASE_ENTITY, **kwargs: object) -> BASE_ENTITY: return self.model_copy(update=kwargs, deep=True) def to_json(self) -> str: diff --git a/donna/core/errors.py b/donna/core/errors.py index 0f42938c..7d08b4cf 100644 --- a/donna/core/errors.py +++ b/donna/core/errors.py @@ -1,16 +1,12 @@ -from typing import Any - import pydantic from donna.core.entities import BaseEntity -from donna.protocol.cells import Cell, MetaValue, to_meta_value -from donna.protocol.nodes import Node class InternalError(Exception): message = "An internal error occurred" - def __init__(self, **kwargs: Any) -> None: + def __init__(self, **kwargs: object) -> None: self.arguments = kwargs def error_message(self) -> str: @@ -31,9 +27,6 @@ class EnvironmentError(BaseEntity): def content_intro(self) -> str: return "Error" - def node(self) -> "EnvironmentErrorNode": - return EnvironmentErrorNode(self) - class EnvironmentErrorsProxy(InternalError): message = "This is a technical exception to pass an environment error up the call stack." @@ -48,72 +41,4 @@ class CoreEnvironmentError(EnvironmentError): cell_kind: str = "core_environment_error" -class ProjectDirNotFound(CoreEnvironmentError): - code: str = "donna.core.project_dir_not_found" - message: str = "Could not find a project directory containing `{error.config_name}`." - ways_to_fix: list[str] = [ - "Run Donna from within a project directory that contains the Donna config file.", - "Create the Donna project config via CLI command if it does not exist yet.", - ] - config_name: str - - -class EnvironmentErrorNode(Node): - __slots__ = ("_error",) - - def __init__(self, environment_error: EnvironmentError) -> None: - self._error = environment_error - - def meta(self) -> dict[str, MetaValue]: - meta: dict[str, MetaValue] = { - "error_code": self._error.code, - } - - for field_name, _field in self._error.model_fields.items(): - if field_name in ("code", "message", "cell_kind", "cell_media_type", "ways_to_fix"): - continue - - value = getattr(self._error, field_name) - - if value is None: - continue - - meta[field_name] = to_meta_value(value) - - return meta - - def content(self) -> str: - intro = self._error.content_intro() - - message = self._error.message.format(error=self._error).strip() - - ways_to_fix = [fix.format(error=self._error).strip() for fix in self._error.ways_to_fix] - - if "\n" in self._error.message: - content = f"{intro}:\n\n{message}" - else: - content = f"{intro}: {message}" - - if not ways_to_fix: - return content - - if len(ways_to_fix) == 1: - return f"{content}\nWay to fix: {ways_to_fix[0]}" - - fixes = "\n".join(f"- {fix}" for fix in ways_to_fix) - - return f"{content}\n\nWays to fix:\n\n{fixes}" - - def status(self) -> Cell: - return Cell.build( - kind=self._error.cell_kind, - media_type=self._error.cell_media_type, - content=self.content(), - **self.meta(), - ) - - def journal_message(self) -> str: - return self._error.message.format(error=self._error).replace("\n", " ").strip() - - ErrorsList = list[EnvironmentError] diff --git a/donna/core/result.py b/donna/core/result.py index 02825d74..f2950354 100644 --- a/donna/core/result.py +++ b/donna/core/result.py @@ -5,10 +5,12 @@ from donna.core.errors import InternalError -T = TypeVar("T") +T = TypeVar("T", covariant=True) U = TypeVar("U") -E = TypeVar("E") +E = TypeVar("E", covariant=True) F = TypeVar("F") +TValue = TypeVar("TValue") +EValue = TypeVar("EValue") P = ParamSpec("P") @@ -27,7 +29,7 @@ class UnwrapErrError(ResultError): class Result(Generic[T, E]): __slots__ = ("_is_ok", "_value") - def __init__(self, is_ok: bool, value: T | E) -> None: + def __init__(self, is_ok: bool, value: object) -> None: self._is_ok = is_ok self._value = value @@ -75,11 +77,11 @@ def map_err(self, func: Callable[[E], F]) -> "Result[T, F]": return Result(True, cast(T, self._value)) -def Ok(value: T) -> Result[T, E]: +def Ok(value: TValue) -> Result[TValue, EValue]: return Result(True, value) -def Err(error: E) -> Result[T, E]: +def Err(error: EValue) -> Result[TValue, EValue]: return Result(False, error) diff --git a/donna/core/tests/__init__.py b/donna/core/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/donna/core/tests/test_entities.py b/donna/core/tests/test_entities.py new file mode 100644 index 00000000..86406923 --- /dev/null +++ b/donna/core/tests/test_entities.py @@ -0,0 +1,53 @@ +import pydantic +import pytest + +from donna.core.entities import BaseEntity + + +class _NestedEntity(BaseEntity): + values: list[int] + + +class _SampleEntity(BaseEntity): + name: str + nested: _NestedEntity + + +class TestBaseEntity: + def test_model_config__strips_strings_and_forbids_extra_fields(self) -> None: + entity = _SampleEntity(name=" value ", nested=_NestedEntity(values=[])) + + assert entity.name == "value" + + with pytest.raises(pydantic.ValidationError): + _SampleEntity.model_validate({"name": "value", "nested": {"values": []}, "extra": True}) + + def test_model_config__is_frozen(self) -> None: + entity = _SampleEntity(name="value", nested=_NestedEntity(values=[])) + + with pytest.raises(pydantic.ValidationError): + setattr(entity, "name", "changed") + + def test_replace__returns_deep_copy_with_changes(self) -> None: + entity = _SampleEntity(name="before", nested=_NestedEntity(values=[1])) + + replaced = entity.replace(name="after") + replaced.nested.values.append(2) + + assert entity.name == "before" + assert entity.nested.values == [1] + assert replaced.name == "after" + assert replaced.nested.values == [1, 2] + + def test_to_json__serializes_entity(self) -> None: + entity = _SampleEntity(name="value", nested=_NestedEntity(values=[1, 2])) + + assert ( + entity.to_json() + == '{\n "name": "value",\n "nested": {\n "values": [\n 1,\n 2\n ]\n }\n}' + ) + + def test_from_json__deserializes_entity(self) -> None: + entity = _SampleEntity.from_json('{"name": "value", "nested": {"values": [1]}}') + + assert entity == _SampleEntity(name="value", nested=_NestedEntity(values=[1])) diff --git a/donna/core/tests/test_errors.py b/donna/core/tests/test_errors.py new file mode 100644 index 00000000..32556e75 --- /dev/null +++ b/donna/core/tests/test_errors.py @@ -0,0 +1,47 @@ +from donna.core import errors + + +class _FormattedInternalError(errors.InternalError): + message = "Broken {thing}" + + +class _SampleEnvironmentError(errors.EnvironmentError): + cell_kind: str = "sample_error" + code: str = "sample.error" + message: str = "Sample failed." + + def content_intro(self) -> str: + return "Sample" + + +class TestInternalError: + def test_error_message__formats_keyword_arguments(self) -> None: + error = _FormattedInternalError(thing="state") + + assert error.arguments == {"thing": "state"} + assert error.error_message() == "Broken state" + assert str(error) == "_FormattedInternalError: Broken state" + + +class TestEnvironmentError: + def test_content_intro__uses_subclass_behavior(self) -> None: + error = _SampleEnvironmentError() + + assert error.content_intro() == "Sample" + + def test_ways_to_fix__uses_independent_default_list(self) -> None: + first = _SampleEnvironmentError() + second = _SampleEnvironmentError() + + first.ways_to_fix.append("Fix it.") + + assert first.ways_to_fix == ["Fix it."] + assert second.ways_to_fix == [] + + +class TestEnvironmentErrorsProxy: + def test_init__stores_errors_for_technical_unwrap_bridge(self) -> None: + error = _SampleEnvironmentError() + proxy = errors.EnvironmentErrorsProxy([error]) + + assert proxy.arguments == {"errors": [error]} diff --git a/donna/core/tests/test_result.py b/donna/core/tests/test_result.py new file mode 100644 index 00000000..940a61ff --- /dev/null +++ b/donna/core/tests/test_result.py @@ -0,0 +1,95 @@ +import pytest + +from donna.core.result import Err, Ok, Result, UnwrapErrError, UnwrapError, unwrap_to_error + + +class TestResult: + def test_ok__exposes_success_value(self) -> None: + result: Result[int, str] = Ok(2) + + assert result.is_ok() + assert not result.is_err() + assert result.ok() == 2 + assert result.err() is None + assert result.unwrap() == 2 + assert result.unwrap_or(3) == 2 + + def test_ok__maps_success_value(self) -> None: + result: Result[int, str] = Ok(2) + + mapped = result.map(lambda value: value + 1) + mapped_error = result.map_err(lambda error: f"{error}!") + + assert mapped.unwrap() == 3 + assert mapped_error.unwrap() == 2 + + def test_ok__unwrap_err_raises_internal_error(self) -> None: + result: Result[int, str] = Ok(2) + + with pytest.raises(UnwrapErrError) as exc_info: + result.unwrap_err() + + assert exc_info.value.arguments == {"value": 2} + + def test_err__exposes_error_value(self) -> None: + result: Result[int, str] = Err("failure") + + assert not result.is_ok() + assert result.is_err() + assert result.ok() is None + assert result.err() == "failure" + assert result.unwrap_or(3) == 3 + + def test_err__maps_error_value(self) -> None: + result: Result[int, str] = Err("failure") + + mapped = result.map(lambda value: value + 1) + mapped_error = result.map_err(lambda error: f"{error}!") + + assert mapped.err() == "failure" + assert mapped_error.err() == "failure!" + + def test_err__unwrap_raises_internal_error_with_error_value(self) -> None: + result: Result[int, str] = Err("failure") + + with pytest.raises(UnwrapError) as exc_info: + result.unwrap() + + assert exc_info.value.arguments == {"error": "failure"} + + +class TestOk: + def test_returns_success_result(self) -> None: + assert Ok("value").unwrap() == "value" + + +class TestErr: + def test_returns_error_result(self) -> None: + assert Err("error").unwrap_err() == "error" + + +class TestUnwrapToError: + def test_converts_unwrap_error_to_error_result(self) -> None: + @unwrap_to_error + def composed() -> Result[str, list[str]]: + return Err(["failure"]).unwrap() + + result = composed() + + assert result.is_err() + assert result.unwrap_err() == ["failure"] + + def test_preserves_success_result(self) -> None: + @unwrap_to_error + def composed() -> Result[str, list[str]]: + return Ok("value") + + assert composed().unwrap() == "value" + + def test_does_not_hide_arbitrary_exceptions(self) -> None: + @unwrap_to_error + def composed() -> Result[str, list[str]]: + raise RuntimeError("boom") + + with pytest.raises(RuntimeError): + composed() diff --git a/donna/core/tests/test_utils.py b/donna/core/tests/test_utils.py new file mode 100644 index 00000000..4b1c04a1 --- /dev/null +++ b/donna/core/tests/test_utils.py @@ -0,0 +1,11 @@ +import datetime + +from donna.core import utils + + +class TestNow: + def test_returns_timezone_aware_utc_datetime(self) -> None: + value = utils.now() + + assert isinstance(value, datetime.datetime) + assert value.tzinfo == datetime.UTC diff --git a/donna/core/utils.py b/donna/core/utils.py index 474c6c0f..d59479e0 100644 --- a/donna/core/utils.py +++ b/donna/core/utils.py @@ -1,35 +1,5 @@ import datetime -import pathlib - -from donna.core import errors as core_errors -from donna.core.result import Err, Ok, Result -from donna.domain.paths import ProjectRootPath def now() -> datetime.datetime: return datetime.datetime.now(datetime.UTC) - - -def first_project_dir_with_config(config_name: str) -> ProjectRootPath | None: - """Get the first parent directory containing the Donna config file. - - Search from the current working directory upwards for a folder with Donna config. - """ - current_dir = pathlib.Path.cwd().resolve() - - for parent in [current_dir] + list(current_dir.parents): - config_path = parent / config_name - if config_path.is_file(): - return ProjectRootPath(parent) - - return None - - -def discover_project_dir(config_name: str) -> Result[ProjectRootPath, core_errors.ErrorsList]: - """Discover the project directory by looking for the Donna config file in parent folders.""" - project_dir = first_project_dir_with_config(config_name) - - if project_dir is None: - return Err([core_errors.ProjectDirNotFound(config_name=config_name)]) - - return Ok(project_dir) diff --git a/donna/domain/__init__.py b/donna/domain/__init__.py index e69de29b..c3bfcd90 100644 --- a/donna/domain/__init__.py +++ b/donna/domain/__init__.py @@ -0,0 +1,19 @@ +from donna.domain import artifact_ids as artifact_ids +from donna.domain import constants as constants +from donna.domain import errors as errors +from donna.domain import id_paths as id_paths +from donna.domain import ids as ids +from donna.domain import internal_ids as internal_ids +from donna.domain import paths as paths +from donna.domain import python_path as python_path + +__all__ = ( + "artifact_ids", + "constants", + "errors", + "id_paths", + "ids", + "internal_ids", + "paths", + "python_path", +) diff --git a/donna/domain/artifact_ids.py b/donna/domain/artifact_ids.py index eab7867e..e977a682 100644 --- a/donna/domain/artifact_ids.py +++ b/donna/domain/artifact_ids.py @@ -25,7 +25,7 @@ def __init__(self, *, full_id: ArtifactSectionId, artifact_id: ArtifactId, secti self.section_id = section_id -def _raw_artifact_path(value: str) -> str | None: +def _raw_artifact_path(value: object) -> str | None: if not isinstance(value, str) or not value.startswith(ARTIFACT_ID_PREFIX): return None @@ -36,7 +36,7 @@ def _raw_artifact_path(value: str) -> str | None: return raw -def validate_artifact_id(value: str) -> bool: +def validate_artifact_id(value: object) -> bool: raw = _raw_artifact_path(value) if raw is None: return False @@ -51,7 +51,7 @@ def validate_artifact_id(value: str) -> bool: return bool(pathlib.PurePosixPath(parts[-1]).suffix) -def validate_artifact_section_id(value: str) -> bool: +def validate_artifact_section_id(value: object) -> bool: parts = split_artifact_section_id(value) return parts is not None @@ -74,7 +74,7 @@ def artifact_section_id(artifact_id: ArtifactId, local_id: SectionId | str) -> A return ArtifactSectionId(section_id) -def split_artifact_section_id(value: str | ArtifactSectionId) -> ArtifactSectionParts | None: +def split_artifact_section_id(value: object) -> ArtifactSectionParts | None: if not isinstance(value, str) or not value: return None diff --git a/donna/domain/id_paths.py b/donna/domain/id_paths.py index c3d03056..7e2b1e55 100644 --- a/donna/domain/id_paths.py +++ b/donna/domain/id_paths.py @@ -1,5 +1,6 @@ +from collections.abc import Callable from functools import total_ordering -from typing import Any, Self, Sequence, TypeVar +from typing import Self, Sequence, TypeVar from pydantic_core import PydanticCustomError, core_schema @@ -8,13 +9,13 @@ from donna.domain import errors as domain_errors -def _stringify_value(value: Any) -> str: +def _stringify_value(value: object) -> str: if isinstance(value, str): return value return repr(value) -def _pydantic_type_error(type_name: str, value: Any) -> PydanticCustomError: +def _pydantic_type_error(type_name: str, value: object) -> PydanticCustomError: return PydanticCustomError( "type_error", "{type_name} must be a str, got {actual_type}", @@ -22,7 +23,7 @@ def _pydantic_type_error(type_name: str, value: Any) -> PydanticCustomError: ) -def _pydantic_value_error(type_name: str, value: Any) -> PydanticCustomError: +def _pydantic_value_error(type_name: str, value: object) -> PydanticCustomError: return PydanticCustomError( "value_error", "Invalid {type_name}: {value}", @@ -33,7 +34,7 @@ def _pydantic_value_error(type_name: str, value: Any) -> PydanticCustomError: TParsed = TypeVar("TParsed") -def _invalid_format(id_type: str, value: Any) -> Result[TParsed, ErrorsList]: +def _invalid_format(id_type: str, value: object) -> Result[TParsed, ErrorsList]: return Err([domain_errors.InvalidIdFormat(id_type=id_type, value=_stringify_value(value))]) @@ -67,7 +68,7 @@ def _validate_parts(cls, parts: Sequence[str]) -> bool: return all(part.isidentifier() for part in parts) @classmethod - def validate(cls, value: str) -> bool: + def validate(cls, value: object) -> bool: if not isinstance(value, str) or not value: return False @@ -89,7 +90,7 @@ def raw_value(self) -> str: return self.delimiter.join(self.parts) @classmethod - def normalize_raw_value(cls, value: str) -> NormalizedRawIdPath | None: + def normalize_raw_value(cls, value: object) -> NormalizedRawIdPath | None: if not isinstance(value, str) or not value: return None @@ -126,15 +127,15 @@ def __lt__(self, other: object) -> bool: def __copy__(self) -> Self: return self - def __deepcopy__(self, memo: dict[int, Any]) -> Self: + def __deepcopy__(self, memo: dict[int, object]) -> Self: memo[id(self)] = self return self - def __setattr__(self, name: str, value: Any) -> None: + def __setattr__(self, name: str, value: object) -> None: raise AttributeError(f"{type(self).__name__} is immutable") @classmethod - def parse(cls, text: str) -> Result[Self, ErrorsList]: + def parse(cls, text: object) -> Result[Self, ErrorsList]: normalized = cls.normalize_raw_value(text) if normalized is None: return _invalid_format(cls.__name__, text) @@ -142,7 +143,7 @@ def parse(cls, text: str) -> Result[Self, ErrorsList]: return Ok(cls(normalized)) @classmethod - def _build_pydantic_schema(cls, validate_func: Any) -> core_schema.CoreSchema: + def _build_pydantic_schema(cls, validate_func: Callable[[object], "IdPath"]) -> core_schema.CoreSchema: str_then_validate = core_schema.no_info_after_validator_function( validate_func, core_schema.str_schema(), @@ -157,9 +158,9 @@ def _build_pydantic_schema(cls, validate_func: Any) -> core_schema.CoreSchema: ) @classmethod - def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> core_schema.CoreSchema: + def __get_pydantic_core_schema__(cls, source_type: object, handler: object) -> core_schema.CoreSchema: - def validate(v: Any) -> "IdPath": + def validate(v: object) -> "IdPath": if isinstance(v, cls): return v diff --git a/donna/domain/ids.py b/donna/domain/ids.py index 0858c53e..7666a5eb 100644 --- a/donna/domain/ids.py +++ b/donna/domain/ids.py @@ -1,4 +1,4 @@ -from typing import Any, TypeVar +from typing import TypeVar from pydantic_core import core_schema @@ -32,7 +32,7 @@ def __new__(cls, value: str) -> "Identifier": return super().__new__(cls, value) @classmethod - def validate(cls, value: str) -> bool: + def validate(cls, value: object) -> bool: if not isinstance(value, str): return False return value.isidentifier() @@ -48,9 +48,11 @@ def parse(cls: type[TIdentifier], text: str) -> Result[TIdentifier, ErrorsList]: return Ok(cls(text)) @classmethod - def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> core_schema.CoreSchema: # noqa: CCR001 + def __get_pydantic_core_schema__( + cls, source_type: object, handler: object + ) -> core_schema.CoreSchema: # noqa: CCR001 - def validate(v: Any) -> "Identifier": + def validate(v: object) -> "Identifier": if isinstance(v, cls): return v @@ -73,7 +75,7 @@ class SectionId(Identifier): __slots__ = () @classmethod - def validate(cls, value: str) -> bool: + def validate(cls, value: object) -> bool: if not isinstance(value, str): return False diff --git a/donna/domain/internal_ids.py b/donna/domain/internal_ids.py index 3c8d7fe0..763e8150 100644 --- a/donna/domain/internal_ids.py +++ b/donna/domain/internal_ids.py @@ -1,5 +1,3 @@ -from typing import Any - from pydantic_core import core_schema from donna.domain import errors as domain_errors @@ -37,7 +35,7 @@ def build(cls, prefix: str, value: int) -> "InternalId": return cls(f"{prefix}-{value}-{_id_crc(value)}") @classmethod - def validate(cls, id: str) -> bool: + def validate(cls, id: object) -> bool: if not isinstance(id, str): return False @@ -58,9 +56,11 @@ def short(self) -> str: return self.split("-")[1] @classmethod - def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> core_schema.CoreSchema: # noqa: CCR001 + def __get_pydantic_core_schema__( + cls, source_type: object, handler: object + ) -> core_schema.CoreSchema: # noqa: CCR001 - def validate(v: Any) -> "InternalId": + def validate(v: object) -> "InternalId": if isinstance(v, cls): return v diff --git a/donna/domain/paths.py b/donna/domain/paths.py index 2e948087..eb873af1 100644 --- a/donna/domain/paths.py +++ b/donna/domain/paths.py @@ -9,4 +9,4 @@ RelativeProjectPath = NewType("RelativeProjectPath", Path) ResolvedProjectPath = NewType("ResolvedProjectPath", Path) UntrustedPath = NewType("UntrustedPath", Path) -PathInput = UntrustedPath | ProjectRootPath +PathInput = Path | UntrustedPath | ProjectRootPath | ProjectConfigPath diff --git a/donna/domain/tests/__init__.py b/donna/domain/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/donna/domain/tests/test_artifact_ids.py b/donna/domain/tests/test_artifact_ids.py new file mode 100644 index 00000000..ad339e96 --- /dev/null +++ b/donna/domain/tests/test_artifact_ids.py @@ -0,0 +1,114 @@ +import pytest + +from donna.domain import errors +from donna.domain.artifact_ids import ( + ArtifactId, + ArtifactSectionId, + artifact_path_parts, + artifact_section_id, + split_artifact_section_id, + validate_artifact_id, + validate_artifact_section_id, +) +from donna.domain.ids import SectionId + + +class TestValidateArtifactId: + @pytest.mark.parametrize( + "value", + [ + "@/README.md", + "@/workflows/polish.donna.md", + "@/.session/donna/plans/feature.donna.md", + ], + ) + def test_valid_canonical_artifact_id(self, value: str) -> None: + assert validate_artifact_id(value) + + @pytest.mark.parametrize( + "value", + [ + "@", + "@/", + "@/workflows/../README.md", + "@/workflows//polish.donna.md", + "@/workflows/", + "@/---/polish.donna.md", + "@/README", + "/home/user/project/workflows/polish.donna.md", + None, + ], + ) + def test_invalid_canonical_artifact_id(self, value: object) -> None: + assert not validate_artifact_id(value) + + +class TestValidateArtifactSectionId: + def test_valid_section_id(self) -> None: + assert validate_artifact_section_id("@/workflows/polish.donna.md:section-1") + + @pytest.mark.parametrize( + "value", + [ + "@/workflows/polish.donna.md", + "@/workflows/polish.donna.md:", + "@/workflows/polish.donna.md:---", + "@/workflows//polish.donna.md:section", + None, + ], + ) + def test_invalid_section_id(self, value: object) -> None: + assert not validate_artifact_section_id(value) + + +class TestArtifactPathParts: + def test_valid_artifact_id(self) -> None: + assert artifact_path_parts(ArtifactId("@/workflows/polish.donna.md")) == ("workflows", "polish.donna.md") + + def test_invalid_artifact_id(self) -> None: + with pytest.raises(ValueError): + artifact_path_parts(ArtifactId("workflows/polish.donna.md")) + + +class TestArtifactSectionId: + def test_builds_full_section_id(self) -> None: + full_id = artifact_section_id(ArtifactId("@/workflows/polish.donna.md"), SectionId("start")) + + assert full_id == ArtifactSectionId("@/workflows/polish.donna.md:start") + + def test_builds_from_string_section_id(self) -> None: + full_id = artifact_section_id(ArtifactId("@/workflows/polish.donna.md"), "finish") + + assert full_id == ArtifactSectionId("@/workflows/polish.donna.md:finish") + + def test_invalid_artifact_id(self) -> None: + with pytest.raises(ValueError): + artifact_section_id(ArtifactId("workflows/polish.donna.md"), "start") + + def test_invalid_local_section_id(self) -> None: + with pytest.raises(errors.InvalidIdentifier): + artifact_section_id(ArtifactId("@/workflows/polish.donna.md"), "///") + + +class TestSplitArtifactSectionId: + def test_valid_section_id(self) -> None: + parts = split_artifact_section_id("@/workflows/polish.donna.md:section-1") + + assert parts is not None + assert parts.full_id == ArtifactSectionId("@/workflows/polish.donna.md:section-1") + assert parts.artifact_id == ArtifactId("@/workflows/polish.donna.md") + assert parts.section_id == SectionId("section-1") + + @pytest.mark.parametrize( + "value", + [ + "", + "@/workflows/polish.donna.md", + "@/workflows/polish.donna.md:", + "@/workflows/polish.donna.md:---", + "@/workflows//polish.donna.md:section", + 1, + ], + ) + def test_invalid_section_id(self, value: object) -> None: + assert split_artifact_section_id(value) is None diff --git a/donna/domain/tests/test_id_paths.py b/donna/domain/tests/test_id_paths.py new file mode 100644 index 00000000..b95fefb1 --- /dev/null +++ b/donna/domain/tests/test_id_paths.py @@ -0,0 +1,104 @@ +import copy + +import pydantic +import pytest + +from donna.core.entities import BaseEntity +from donna.domain import errors +from donna.domain.id_paths import IdPath, NormalizedRawIdPath +from donna.domain.python_path import PythonPath + + +class _SlashPath(IdPath): + __slots__ = () + prefix = "$/" + delimiter = "/" + min_parts = 2 + + +class _PathEntity(BaseEntity): + path: PythonPath + + +class TestIdPath: + def test_normalize_raw_value__removes_prefix_and_validates_parts(self) -> None: + assert _SlashPath.normalize_raw_value("$/alpha/beta") == NormalizedRawIdPath("alpha/beta") + assert _SlashPath.normalize_raw_value("alpha/beta") == NormalizedRawIdPath("alpha/beta") + assert _SlashPath.normalize_raw_value("$/alpha") is None + assert _SlashPath.normalize_raw_value("$/alpha//beta") is None + assert _SlashPath.normalize_raw_value("$/alpha/not-valid") is None + + def test_parse__returns_normalized_path(self) -> None: + result = _SlashPath.parse("$/alpha/beta") + + assert result.is_ok() + assert result.unwrap() == _SlashPath(NormalizedRawIdPath("alpha/beta")) + + def test_parse__returns_environment_error_for_invalid_format(self) -> None: + result = _SlashPath.parse("$/alpha") + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, errors.InvalidIdFormat) + assert error.code == "donna.domain.invalid_id_format" + assert error.id_type == "_SlashPath" + assert error.value == "$/alpha" + + def test_init__raises_internal_error_for_invalid_normalized_value(self) -> None: + with pytest.raises(errors.InvalidIdPath): + _SlashPath(NormalizedRawIdPath("alpha")) + + def test_string_representations__use_prefix_and_raw_value(self) -> None: + path = _SlashPath(NormalizedRawIdPath("alpha/beta")) + + assert path.raw_value == "alpha/beta" + assert str(path) == "$/alpha/beta" + assert repr(path) == "_SlashPath('alpha/beta')" + + def test_equality_hashing_and_ordering__are_type_specific_and_part_based(self) -> None: + first = _SlashPath(NormalizedRawIdPath("alpha/beta")) + second = _SlashPath(NormalizedRawIdPath("alpha/gamma")) + + assert first == _SlashPath(NormalizedRawIdPath("alpha/beta")) + assert first != PythonPath(NormalizedRawIdPath("alpha.beta")) + assert sorted([second, first]) == [first, second] + assert {first, _SlashPath(NormalizedRawIdPath("alpha/beta"))} == {first} + + def test_copy__preserves_value_semantics(self) -> None: + path = _SlashPath(NormalizedRawIdPath("alpha/beta")) + + assert copy.copy(path) == path + assert copy.deepcopy(path) == path + + def test_setattr__rejects_mutation(self) -> None: + path = _SlashPath(NormalizedRawIdPath("alpha/beta")) + + with pytest.raises(AttributeError): + path.parts = ("changed",) + + +class TestPythonPath: + def test_parse__accepts_dotted_python_path(self) -> None: + result = PythonPath.parse("donna.domain.ids") + + assert result.is_ok() + assert result.unwrap().parts == ("donna", "domain", "ids") + + def test_parse__rejects_empty_and_malformed_parts(self) -> None: + assert PythonPath.parse("").is_err() + assert PythonPath.parse("donna..ids").is_err() + assert PythonPath.parse("donna.domain.1ids").is_err() + + def test_pydantic_validation__accepts_and_serializes_python_path(self) -> None: + entity = _PathEntity.model_validate({"path": "donna.domain.ids"}) + + assert entity.path == PythonPath(NormalizedRawIdPath("donna.domain.ids")) + assert entity.model_dump() == {"path": PythonPath(NormalizedRawIdPath("donna.domain.ids"))} + assert entity.model_dump_json() == '{"path":"donna.domain.ids"}' + + def test_pydantic_validation__rejects_invalid_python_value(self) -> None: + with pytest.raises(pydantic.ValidationError): + _PathEntity.model_validate({"path": "donna..ids"}) + + with pytest.raises(pydantic.ValidationError): + _PathEntity.model_validate({"path": 123}) diff --git a/donna/domain/tests/test_ids.py b/donna/domain/tests/test_ids.py new file mode 100644 index 00000000..0247186d --- /dev/null +++ b/donna/domain/tests/test_ids.py @@ -0,0 +1,95 @@ +import pydantic +import pytest + +from donna.core.entities import BaseEntity +from donna.domain import errors +from donna.domain.ids import Identifier, SectionId + + +class _IdentifierEntity(BaseEntity): + identifier: Identifier + + +class _SectionEntity(BaseEntity): + section_id: SectionId + + +class TestIdentifier: + def test_validate__accepts_python_identifier(self) -> None: + assert Identifier.validate("valid_name") + + @pytest.mark.parametrize("value", ["", "1invalid", "not-valid", None]) + def test_validate__rejects_non_identifier(self, value: object) -> None: + assert not Identifier.validate(value) + + def test_init__raises_internal_error_for_invalid_value(self) -> None: + with pytest.raises(errors.InvalidIdentifier): + Identifier("not-valid") + + def test_parse__returns_identifier(self) -> None: + result = Identifier.parse("valid_name") + + assert result.is_ok() + assert result.unwrap() == Identifier("valid_name") + + def test_parse__returns_environment_error_for_invalid_format(self) -> None: + result = Identifier.parse("not-valid") + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, errors.InvalidIdFormat) + assert error.code == "donna.domain.invalid_id_format" + assert error.id_type == "Identifier" + assert error.value == "not-valid" + + def test_pydantic_validation__accepts_identifier_value(self) -> None: + entity = _IdentifierEntity.model_validate({"identifier": "valid_name"}) + + assert entity.identifier == Identifier("valid_name") + assert entity.model_dump_json() == '{"identifier":"valid_name"}' + + def test_pydantic_validation__rejects_invalid_identifier_value(self) -> None: + with pytest.raises(pydantic.ValidationError): + _IdentifierEntity.model_validate({"identifier": "not-valid"}) + + with pytest.raises(pydantic.ValidationError): + _IdentifierEntity.model_validate({"identifier": 123}) + + +class TestSectionId: + @pytest.mark.parametrize("value", ["section", "section-1", "section.name", "section_name"]) + def test_validate__accepts_artifact_slug_part(self, value: str) -> None: + assert SectionId.validate(value) + + @pytest.mark.parametrize("value", ["", "---", "...", "section/id", "section id", None]) + def test_validate__rejects_invalid_artifact_slug_part(self, value: object) -> None: + assert not SectionId.validate(value) + + def test_parse__returns_section_id(self) -> None: + result = SectionId.parse("section-1") + + assert result.is_ok() + assert result.unwrap() == SectionId("section-1") + + def test_parse__returns_environment_error_for_invalid_format(self) -> None: + result = SectionId.parse("---") + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, errors.InvalidIdFormat) + assert error.code == "donna.domain.invalid_id_format" + assert error.id_type == "SectionId" + assert error.value == "---" + + def test_pydantic_validation__accepts_section_id_value(self) -> None: + entity = _SectionEntity.model_validate({"section_id": "section-1"}) + + assert entity.section_id == SectionId("section-1") + assert entity.model_dump_json() == '{"section_id":"section-1"}' + + def test_pydantic_validation__rejects_invalid_section_id_value(self) -> None: + with pytest.raises(pydantic.ValidationError): + _SectionEntity.model_validate({"section_id": "---"}) + + with pytest.raises(pydantic.ValidationError): + _SectionEntity.model_validate({"section_id": 123}) diff --git a/donna/domain/tests/test_internal_ids.py b/donna/domain/tests/test_internal_ids.py new file mode 100644 index 00000000..ac8f3608 --- /dev/null +++ b/donna/domain/tests/test_internal_ids.py @@ -0,0 +1,73 @@ +import pydantic +import pytest + +from donna.core.entities import BaseEntity +from donna.domain import errors +from donna.domain.internal_ids import ActionRequestId, InternalId, TaskId, WorkUnitId + + +class _InternalIdEntity(BaseEntity): + internal_id: InternalId + + +class TestInternalId: + def test_build__creates_crc_protected_identifier(self) -> None: + identifier = InternalId.build("WU", 0) + + assert identifier == InternalId("WU-0-a") + assert identifier.short == "0" + assert InternalId.validate(identifier) + + @pytest.mark.parametrize( + "value", + [ + "WU-0-b", + "WU-zero-a", + "WU", + "", + None, + ], + ) + def test_validate__rejects_invalid_identifier(self, value: object) -> None: + assert not InternalId.validate(value) + + def test_init__raises_internal_error_for_invalid_value(self) -> None: + with pytest.raises(errors.InvalidInternalId): + InternalId("WU-0-b") + + def test_pydantic_validation__accepts_internal_id_value(self) -> None: + entity = _InternalIdEntity.model_validate({"internal_id": "WU-0-a"}) + + assert entity.internal_id == InternalId("WU-0-a") + assert entity.model_dump_json() == '{"internal_id":"WU-0-a"}' + + def test_pydantic_validation__rejects_invalid_internal_id_value(self) -> None: + with pytest.raises(pydantic.ValidationError): + _InternalIdEntity.model_validate({"internal_id": "WU-0-b"}) + + with pytest.raises(pydantic.ValidationError): + _InternalIdEntity.model_validate({"internal_id": 123}) + + +class TestWorkUnitId: + def test_inherits_internal_id_behavior(self) -> None: + identifier = WorkUnitId.build("WU", 1) + + assert identifier == WorkUnitId("WU-1-b") + assert WorkUnitId.validate(identifier) + + +class TestActionRequestId: + def test_inherits_internal_id_behavior(self) -> None: + identifier = ActionRequestId.build("AR", 1) + + assert identifier == ActionRequestId("AR-1-b") + assert ActionRequestId.validate(identifier) + + +class TestTaskId: + def test_inherits_internal_id_behavior(self) -> None: + identifier = TaskId.build("T", 1) + + assert identifier == TaskId("T-1-b") + assert TaskId.validate(identifier) diff --git a/donna/lib/__init__.py b/donna/lib/__init__.py index 9d5bc56e..89dc1bad 100644 --- a/donna/lib/__init__.py +++ b/donna/lib/__init__.py @@ -1,13 +1,8 @@ """Shared instances for standard library kind definitions.""" -from donna.primitives.artifacts.workflow import Workflow -from donna.primitives.directives.goto import GoTo -from donna.primitives.directives.task_variable import TaskVariable -from donna.primitives.sections.finish_workflow import FinishWorkflow -from donna.primitives.sections.output import Output -from donna.primitives.sections.request_action import RequestAction -from donna.primitives.sections.run_script import RunScript -from donna.primitives.sections.text import Text +from donna.primitives.artifacts import Workflow +from donna.primitives.directives import GoTo, TaskVariable +from donna.primitives.sections import FinishWorkflow, Output, RequestAction, RunScript, Text workflow = Workflow() text = Text() diff --git a/donna/lib/tests/__init__.py b/donna/lib/tests/__init__.py new file mode 100644 index 00000000..148e68a7 --- /dev/null +++ b/donna/lib/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for donna.lib.""" diff --git a/donna/lib/tests/test_init.py b/donna/lib/tests/test_init.py new file mode 100644 index 00000000..8c50a3a1 --- /dev/null +++ b/donna/lib/tests/test_init.py @@ -0,0 +1,20 @@ +import donna.lib as lib +from donna.primitives.artifacts import Workflow +from donna.primitives.directives import GoTo, TaskVariable +from donna.primitives.sections import FinishWorkflow, Output, RequestAction, RunScript, Text + + +class TestPrimitiveInitialization: + def test_section_and_artifact_primitives_are_initialized(self) -> None: + assert isinstance(lib.workflow, Workflow) + assert isinstance(lib.text, Text) + assert isinstance(lib.request_action, RequestAction) + assert isinstance(lib.finish, FinishWorkflow) + assert isinstance(lib.output, Output) + assert isinstance(lib.run_script, RunScript) + + def test_directive_primitives_are_initialized_with_analyze_ids(self) -> None: + assert isinstance(lib.goto, GoTo) + assert lib.goto.analyze_id == "goto" + assert isinstance(lib.task_variable, TaskVariable) + assert lib.task_variable.analyze_id == "task_variable" diff --git a/donna/machine/__init__.py b/donna/machine/__init__.py index e69de29b..7537a028 100644 --- a/donna/machine/__init__.py +++ b/donna/machine/__init__.py @@ -0,0 +1,23 @@ +from donna.machine import action_requests as action_requests +from donna.machine import artifacts as artifacts +from donna.machine import changes as changes +from donna.machine import context as context +from donna.machine import errors as errors +from donna.machine import operations as operations +from donna.machine import primitives as primitives +from donna.machine import state as state +from donna.machine import tasks as tasks +from donna.machine import templates as templates + +__all__ = ( + "action_requests", + "artifacts", + "changes", + "context", + "errors", + "operations", + "primitives", + "state", + "tasks", + "templates", +) diff --git a/donna/machine/artifacts.py b/donna/machine/artifacts.py index 9a081ab3..432a8538 100644 --- a/donna/machine/artifacts.py +++ b/donna/machine/artifacts.py @@ -1,4 +1,4 @@ -from typing import Any +from collections.abc import Mapping from donna.core.entities import BaseEntity from donna.core.errors import ErrorsList @@ -6,8 +6,10 @@ from donna.domain.artifact_ids import ArtifactId from donna.domain.ids import SectionId from donna.domain.python_path import PythonPath +from donna.machine.context import context from donna.machine.errors import ArtifactPrimarySectionMissing, ArtifactSectionNotFound, MultiplePrimarySectionsError -from donna.protocol.cells import Cell +from donna.protocol.cells import Cell, MetaValue +from donna.protocol.errors import environment_error_node from donna.protocol.nodes import Node @@ -17,7 +19,7 @@ class ArtifactSectionConfig(BaseEntity): class ArtifactSectionMeta(BaseEntity): - def cells_meta(self) -> dict[str, Any]: + def cells_meta(self) -> Mapping[str, MetaValue]: return {} @@ -62,8 +64,6 @@ def primary_section(self) -> Result[ArtifactSection, ErrorsList]: return Ok(primary_sections[0]) def validate_artifact(self) -> Result[None, ErrorsList]: # noqa: CCR001 - from donna.context.context import context - primary_sections = self._primary_sections() errors: ErrorsList = [] @@ -137,7 +137,7 @@ def __init__(self, artifact: Artifact) -> None: def status(self) -> Cell: primary_section_result = self._artifact.primary_section() if primary_section_result.is_err(): - return primary_section_result.unwrap_err()[0].node().status() + return environment_error_node(primary_section_result.unwrap_err()[0]).info() primary_section = primary_section_result.unwrap() return Cell.build_markdown( @@ -151,12 +151,12 @@ def status(self) -> Cell: def info(self) -> Cell: primary_section_result = self._artifact.primary_section() if primary_section_result.is_err(): - return primary_section_result.unwrap_err()[0].node().info() + return environment_error_node(primary_section_result.unwrap_err()[0]).info() primary_section = primary_section_result.unwrap() blocks_result = self._artifact.markdown_blocks() if blocks_result.is_err(): - return blocks_result.unwrap_err()[0].node().info() + return environment_error_node(blocks_result.unwrap_err()[0]).info() return Cell.build_markdown( kind="artifact_info", diff --git a/donna/machine/context.py b/donna/machine/context.py new file mode 100644 index 00000000..ca379e47 --- /dev/null +++ b/donna/machine/context.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import contextvars +from collections.abc import Iterator +from contextlib import contextmanager +from typing import TYPE_CHECKING, Generic, Protocol, TypeVar + +from donna.core.errors import ErrorsList +from donna.core.result import Result +from donna.domain.artifact_ids import ArtifactId, ArtifactSectionId +from donna.domain.internal_ids import WorkUnitId +from donna.domain.python_path import PythonPath +from donna.machine import errors as machine_errors + +if TYPE_CHECKING: + from donna.machine.artifacts import Artifact + from donna.machine.primitives import Primitive + from donna.machine.tasks import Task, WorkUnit + +TScopedValue = TypeVar("TScopedValue") + + +class ValueScope(Generic[TScopedValue]): + __slots__ = ("_value",) + + def __init__(self, initial: TScopedValue | None = None) -> None: + self._value: TScopedValue | None = initial + + def get(self) -> TScopedValue | None: + return self._value + + @contextmanager + def scope(self, value: TScopedValue | None) -> Iterator[None]: + previous = self._value + self._value = value + try: + yield + finally: + self._value = previous + + +class MachineArtifacts(Protocol): + def load_for_view(self, artifact_id: ArtifactId) -> Result["Artifact", ErrorsList]: + pass + + def load_for_execution( + self, + artifact_id: ArtifactId, + task: "Task", + work_unit: "WorkUnit", + ) -> Result["Artifact", ErrorsList]: + pass + + +class MachinePrimitives(Protocol): + def resolve(self, primitive_id: PythonPath) -> Result["Primitive", ErrorsList]: + pass + + +class MachineJournal(Protocol): + def add(self, message: str, actor_id: str | None = None) -> Result[object, ErrorsList]: + pass + + +class MachineContext(Protocol): + @property + def artifacts(self) -> MachineArtifacts: + pass + + @property + def primitives(self) -> MachinePrimitives: + pass + + @property + def journal(self) -> MachineJournal: + pass + + @property + def current_work_unit_id(self) -> ValueScope[WorkUnitId]: + pass + + @property + def current_operation_id(self) -> ValueScope[ArtifactSectionId]: + pass + + +_context_var: contextvars.ContextVar[MachineContext | None] = contextvars.ContextVar( + "donna_machine_context", + default=None, +) + + +def set_context(new_context: MachineContext) -> contextvars.Token[MachineContext | None]: + return _context_var.set(new_context) + + +def reset_context(token: contextvars.Token[MachineContext | None]) -> None: + _context_var.reset(token) + + +def context() -> MachineContext: + current = _context_var.get() + if current is None: + raise machine_errors.MachineContextNotSet() + + return current diff --git a/donna/machine/errors.py b/donna/machine/errors.py index 68c0d6b0..4f720edd 100644 --- a/donna/machine/errors.py +++ b/donna/machine/errors.py @@ -18,6 +18,10 @@ class SessionStateStatusInvalid(InternalError): message: str = "Session state status is invalid." +class MachineContextNotSet(InternalError): + message: str = "Machine context is not initialized." + + class UnsupportedFormatterMode(InternalError): message: str = "Formatter for mode '{mode}' is not implemented." @@ -31,7 +35,7 @@ class EnvironmentError(core_errors.EnvironmentError): class SessionStateNotInitialized(EnvironmentError): code: str = "donna.machine.session_state_not_initialized" message: str = "Session state is not initialized." - ways_to_fix: list[str] = ["Run Donna session start to initialize session state."] + ways_to_fix: list[str] = ["Run `donna new-session` to create fresh session state."] class SessionStateChangedExternally(EnvironmentError): @@ -55,7 +59,7 @@ class JournalMessageContainsNewlines(EnvironmentError): class ActionRequestNotFound(EnvironmentError): code: str = "donna.machine.action_request_not_found" message: str = "Action request `{error.request_id}` was not found in the current session state." - ways_to_fix: list[str] = ["Use an action request id from `sessions details` output."] + ways_to_fix: list[str] = ["Use an action request id from `donna details` output."] request_id: ActionRequestId diff --git a/donna/machine/journal.py b/donna/machine/journal.py deleted file mode 100644 index d2a2ccbd..00000000 --- a/donna/machine/journal.py +++ /dev/null @@ -1,93 +0,0 @@ -import datetime -import json - -import pydantic - -from donna.core.entities import BaseEntity -from donna.core.errors import ErrorsList -from donna.core.result import Err, Ok, Result, unwrap_to_error -from donna.core.utils import now -from donna.domain.artifact_ids import ArtifactSectionId -from donna.domain.internal_ids import TaskId, WorkUnitId -from donna.machine import errors as machine_errors -from donna.workspaces import journal as workspace_journal -from donna.workspaces.config import protocol as protocol_mode - - -def message_has_newlines(message: str) -> bool: - return "\n" in message or "\r" in message - - -class JournalRecord(BaseEntity): - timestamp: datetime.datetime - actor_id: str | None - message: str - current_task_id: TaskId | None - current_work_unit_id: WorkUnitId | None - current_operation_id: ArtifactSectionId | None - - @pydantic.field_validator("message", mode="after") - @classmethod - def validate_message_no_newlines(cls, value: str) -> str: - if message_has_newlines(value): - raise ValueError("Journal message must not contain newline characters.") - - return value - - -def serialize_record(record: JournalRecord) -> bytes: - return json.dumps( - record.model_dump(mode="json"), - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - - -def smart_agent_id() -> str: - from donna.protocol.modes import Mode as ProtocolMode - - match protocol_mode(): - case ProtocolMode.human: - return "human" - case ProtocolMode.llm: - return "agent" - case ProtocolMode.automation: - return "automation" - case _: - raise machine_errors.UnsupportedFormatterMode(mode=protocol_mode()) - - -@unwrap_to_error -def add( # noqa: CCR001 - message: str, - actor_id: str | None = None, -) -> Result[JournalRecord, ErrorsList]: - from donna.context.context import context - from donna.protocol.utils import instant_output_journal - - if message_has_newlines(message): - return Err([machine_errors.JournalMessageContainsNewlines()]) - - if actor_id is None: - actor_id = smart_agent_id() - - ctx = context() - state = ctx.state.load().unwrap() - parsed_task_id: TaskId | None = state.current_task.id if state.current_task else None - parsed_work_unit_id: WorkUnitId | None = ctx.current_work_unit_id.get() - parsed_operation_id: ArtifactSectionId | None = ctx.current_operation_id.get() - - record = JournalRecord( - timestamp=now(), - actor_id=actor_id, - message=message, - current_task_id=parsed_task_id, - current_work_unit_id=parsed_work_unit_id, - current_operation_id=parsed_operation_id, - ) - - workspace_journal.write_record(record).unwrap() - instant_output_journal(record) - - return Ok(record) diff --git a/donna/machine/operations.py b/donna/machine/operations.py index feb15b8f..b9f3655e 100644 --- a/donna/machine/operations.py +++ b/donna/machine/operations.py @@ -1,9 +1,11 @@ import enum -from typing import TYPE_CHECKING, Any +from collections.abc import Mapping +from typing import TYPE_CHECKING from donna.domain.ids import SectionId from donna.machine.artifacts import ArtifactSectionConfig, ArtifactSectionMeta from donna.machine.primitives import Primitive +from donna.protocol.cells import MetaValue if TYPE_CHECKING: pass @@ -25,7 +27,7 @@ class OperationConfig(ArtifactSectionConfig): class OperationMeta(ArtifactSectionMeta): fsm_mode: FsmMode = FsmMode.normal - allowed_transtions: set[SectionId] + allowed_transitions: set[SectionId] - def cells_meta(self) -> dict[str, Any]: - return {"fsm_mode": self.fsm_mode.value, "allowed_transtions": [str(t) for t in self.allowed_transtions]} + def cells_meta(self) -> Mapping[str, MetaValue]: + return {"fsm_mode": self.fsm_mode.value, "allowed_transitions": [str(t) for t in self.allowed_transitions]} diff --git a/donna/machine/primitives.py b/donna/machine/primitives.py index f5748db7..7c0e22ec 100644 --- a/donna/machine/primitives.py +++ b/donna/machine/primitives.py @@ -1,7 +1,5 @@ import importlib -from typing import TYPE_CHECKING, Any, ClassVar - -from jinja2.runtime import Context +from typing import TYPE_CHECKING, ClassVar from donna.core.entities import BaseEntity from donna.core.errors import ErrorsList @@ -10,6 +8,7 @@ from donna.domain.python_path import PythonPath from donna.machine import errors as machine_errors from donna.machine.artifacts import ArtifactSectionConfig +from donna.machine.templates_context import DirectiveContext if TYPE_CHECKING: from donna.machine.artifacts import Artifact @@ -32,19 +31,17 @@ def execute_section( primitive_name=self.__class__.__name__, method_name="execute_section()" ) - def apply_directive(self, context: Context, *argv: Any, **kwargs: Any) -> Result[Any, ErrorsList]: + def apply_directive( + self, context: DirectiveContext, *argv: object, **kwargs: object + ) -> Result[object, ErrorsList]: raise machine_errors.PrimitiveMethodUnsupported( primitive_name=self.__class__.__name__, method_name="apply_directive()" ) @unwrap_to_error -def resolve_primitive(primitive_id: PythonPath | str) -> Result[Primitive, ErrorsList]: # noqa: CCR001 - if isinstance(primitive_id, PythonPath): - import_path = str(primitive_id) - else: - import_path = str(PythonPath.parse(primitive_id).unwrap()) - +def resolve_primitive(primitive_id: PythonPath) -> Result[Primitive, ErrorsList]: # noqa: CCR001 + import_path = str(primitive_id) if "." not in import_path: return Err([machine_errors.PrimitiveInvalidImportPath(import_path=import_path)]) diff --git a/donna/machine/state.py b/donna/machine/state.py index 2a2098b1..ab7a5cb9 100644 --- a/donna/machine/state.py +++ b/donna/machine/state.py @@ -4,14 +4,12 @@ import pydantic -from donna.context.context import context from donna.core.entities import BaseEntity from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error from donna.domain.artifact_ids import ArtifactSectionId, split_artifact_section_id from donna.domain.internal_ids import ActionRequestId, InternalId, TaskId, WorkUnitId from donna.machine import errors as machine_errors -from donna.machine import journal as machine_journal from donna.machine.action_requests import ActionRequest from donna.machine.changes import ( Change, @@ -21,10 +19,10 @@ ChangeRemoveTask, ChangeRemoveWorkUnit, ) +from donna.machine.context import context from donna.machine.tasks import Task, WorkUnit from donna.protocol.cells import Cell from donna.protocol.nodes import Node -from donna.workspaces.artifacts import RENDER_CONTEXT_VIEW class BaseState(BaseEntity): @@ -124,7 +122,7 @@ def mark_started(self) -> None: def add_action_request(self, action_request: ActionRequest) -> None: full_request = action_request.replace(id=self.next_action_request_id()) - machine_journal.add( + context().journal.add( actor_id="donna", message=f"Request agent action `{full_request.title}`", ).unwrap() @@ -162,7 +160,7 @@ def complete_action_request( assert current_task is not None action_request = self.get_action_request(request_id).unwrap() - machine_journal.add( + context().journal.add( message=f"Complete agent action `{action_request.title}`", ).unwrap() @@ -177,10 +175,10 @@ def complete_action_request( def start_workflow(self, full_operation_id: ArtifactSectionId) -> Result[None, ErrorsList]: operation_parts = split_artifact_section_id(full_operation_id) assert operation_parts is not None - artifact = context().artifacts.load(operation_parts.artifact_id, RENDER_CONTEXT_VIEW).unwrap() + artifact = context().artifacts.load_for_view(operation_parts.artifact_id).unwrap() workflow = artifact.get_section(operation_parts.section_id).unwrap() - machine_journal.add( + context().journal.add( message=f"Start workflow `{workflow.title}`", ).unwrap() @@ -193,10 +191,10 @@ def finish_workflow(self, task_id: TaskId) -> None: assert task is not None workflow_parts = split_artifact_section_id(task.workflow_id) assert workflow_parts is not None - artifact = context().artifacts.load(workflow_parts.artifact_id, RENDER_CONTEXT_VIEW).unwrap() + artifact = context().artifacts.load_for_view(workflow_parts.artifact_id).unwrap() workflow = artifact.get_section(workflow_parts.section_id).unwrap() - machine_journal.add( + context().journal.add( message=f"Finish workflow `{workflow.title}`", ).unwrap() diff --git a/donna/machine/tasks.py b/donna/machine/tasks.py index 13c08c10..e6ebbd83 100644 --- a/donna/machine/tasks.py +++ b/donna/machine/tasks.py @@ -1,11 +1,13 @@ import copy -from typing import TYPE_CHECKING, Any +from collections.abc import Mapping +from typing import TYPE_CHECKING from donna.core.entities import BaseEntity from donna.core.errors import ErrorsList from donna.core.result import Ok, Result, unwrap_to_error from donna.domain.artifact_ids import ArtifactSectionId, split_artifact_section_id from donna.domain.internal_ids import TaskId, WorkUnitId +from donna.machine.context import context if TYPE_CHECKING: from donna.machine.changes import Change @@ -14,7 +16,7 @@ class Task(BaseEntity): id: TaskId workflow_id: ArtifactSectionId - context: dict[str, Any] + context: dict[str, object] @classmethod def build(cls, id: TaskId, workflow_id: ArtifactSectionId) -> "Task": @@ -29,7 +31,7 @@ class WorkUnit(BaseEntity): id: WorkUnitId task_id: TaskId operation_id: ArtifactSectionId - context: dict[str, Any] + context: dict[str, object] @classmethod def build( @@ -37,7 +39,7 @@ def build( id: WorkUnitId, task_id: TaskId, operation_id: ArtifactSectionId, - context: dict[str, Any] | None = None, + context: Mapping[str, object] | None = None, ) -> "WorkUnit": if context is None: @@ -47,32 +49,22 @@ def build( task_id=task_id, id=id, operation_id=operation_id, - context=copy.deepcopy(context), + context=copy.deepcopy(dict(context)), ) return unit @unwrap_to_error def run(self, task: Task) -> Result[list["Change"], ErrorsList]: - from donna.context.context import context - from donna.machine import journal as machine_journal - from donna.workspaces.artifacts import ArtifactRenderContext - from donna.workspaces.templates import RenderMode - - render_context = ArtifactRenderContext( - primary_mode=RenderMode.execute, - current_task=task, - current_work_unit=self, - ) ctx = context() with ctx.current_operation_id.scope(self.operation_id): operation_parts = split_artifact_section_id(self.operation_id) assert operation_parts is not None - artifact = ctx.artifacts.load(operation_parts.artifact_id, render_context).unwrap() + artifact = ctx.artifacts.load_for_execution(operation_parts.artifact_id, task, self).unwrap() operation = artifact.get_section(operation_parts.section_id).unwrap() operation_kind = ctx.primitives.resolve(operation.kind).unwrap() - machine_journal.add( + ctx.journal.add( actor_id="donna", message=operation.title, ).unwrap() diff --git a/donna/machine/templates.py b/donna/machine/templates.py index 608a245f..526aea54 100644 --- a/donna/machine/templates.py +++ b/donna/machine/templates.py @@ -1,23 +1,43 @@ +import enum from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, TypeAlias - -from jinja2.runtime import Context +from typing import TYPE_CHECKING, TypeAlias from donna.core.errors import ErrorsList from donna.core.result import Ok, Result from donna.machine import errors as machine_errors from donna.machine.primitives import Primitive +from donna.machine.templates_context import DirectiveContext if TYPE_CHECKING: pass -PreparedDirectiveArguments: TypeAlias = tuple[Any, ...] +PreparedDirectiveArguments: TypeAlias = tuple[object, ...] PreparedDirectiveResult: TypeAlias = Result[PreparedDirectiveArguments, ErrorsList] +class RenderMode(enum.StrEnum): + """Modes for rendering artifacts. + + Donna can render artifacts for different purposes, for example: + + - to be displayed to the agent when Donna is used via CLI. + - to be used for execution by Donna itself. + - to be used for analysis by Donna itself. + + In each mode Donna can produce different outputs. + + For example, it can output CLI commands in view/execute mode, + tool specifications in tool mode, special markup in analyze mode, etc. + """ + + view = "view" + execute = "execute" + analysis = "analysis" + + class DirectiveUnsupportedRenderMode(machine_errors.InternalError): message: str = "Render mode {render_mode} not implemented in directive {directive_name}." - render_mode: Any + render_mode: object directive_name: str @@ -26,12 +46,10 @@ class Directive(Primitive, ABC): def apply_directive( # noqa: E704 self, - context: Context, - *argv: Any, - **kwargs: Any, - ) -> Result[Any, ErrorsList]: - from donna.workspaces import templates as world_templates - + context: DirectiveContext, + *argv: object, + **kwargs: object, + ) -> Result[object, ErrorsList]: render_mode = context["render_mode"] arguments_result = self._prepare_arguments(context, *argv, **kwargs) if arguments_result.is_err(): @@ -40,42 +58,42 @@ def apply_directive( # noqa: E704 argv = arguments_result.unwrap() match render_mode: - case world_templates.RenderMode.view: + case RenderMode.view: return self.render_view(context, *argv) - case world_templates.RenderMode.execute: + case RenderMode.execute: return self.render_execute(context, *argv) - case world_templates.RenderMode.analysis: + case RenderMode.analysis: return self.render_analyze(context, *argv) case _: raise DirectiveUnsupportedRenderMode(render_mode=render_mode, directive_name=self.__class__.__name__) def _prepare_arguments( self, - context: Context, - *argv: Any, - **kwargs: Any, + context: DirectiveContext, + *argv: object, + **kwargs: object, ) -> PreparedDirectiveResult: return Ok(argv) @abstractmethod def render_view( # noqa: E704 self, - context: Context, - *argv: Any, - ) -> Result[Any, ErrorsList]: ... + context: DirectiveContext, + *argv: object, + ) -> Result[object, ErrorsList]: ... def render_execute( self, - context: Context, - *argv: Any, - ) -> Result[Any, ErrorsList]: + context: DirectiveContext, + *argv: object, + ) -> Result[object, ErrorsList]: return self.render_view(context, *argv) def render_analyze( self, - context: Context, - *argv: Any, - ) -> Result[str, ErrorsList]: + context: DirectiveContext, + *argv: object, + ) -> Result[object, ErrorsList]: parts = [str(arg) for arg in argv] arguments = " ".join(parts) diff --git a/donna/machine/templates_context.py b/donna/machine/templates_context.py new file mode 100644 index 00000000..a40df803 --- /dev/null +++ b/donna/machine/templates_context.py @@ -0,0 +1,4 @@ +from collections.abc import Mapping +from typing import TypeAlias + +DirectiveContext: TypeAlias = Mapping[str, object] diff --git a/donna/machine/tests/__init__.py b/donna/machine/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/donna/machine/tests/helpers.py b/donna/machine/tests/helpers.py new file mode 100644 index 00000000..4dfc6b86 --- /dev/null +++ b/donna/machine/tests/helpers.py @@ -0,0 +1,87 @@ +from donna.context.tests.helpers import FakeJournal +from donna.core.errors import ErrorsList +from donna.core.result import Err, Ok, Result +from donna.domain.artifact_ids import ArtifactId, ArtifactSectionId +from donna.domain.internal_ids import WorkUnitId +from donna.domain.python_path import PythonPath +from donna.machine.artifacts import Artifact +from donna.machine.context import ValueScope +from donna.machine.primitives import Primitive +from donna.machine.tasks import Task, WorkUnit + + +class FakeArtifacts: + def __init__(self, artifact: Artifact | None = None, error: ErrorsList | None = None) -> None: + self.artifact = artifact + self.error = error + self.viewed: list[ArtifactId] = [] + self.executed: list[tuple[ArtifactId, Task, WorkUnit]] = [] + + def load_for_view(self, artifact_id: ArtifactId) -> Result[Artifact, ErrorsList]: + self.viewed.append(artifact_id) + if self.error is not None: + return Err(self.error) + assert self.artifact is not None + return Ok(self.artifact) + + def load_for_execution( + self, + artifact_id: ArtifactId, + task: Task, + work_unit: WorkUnit, + ) -> Result[Artifact, ErrorsList]: + self.executed.append((artifact_id, task, work_unit)) + if self.error is not None: + return Err(self.error) + assert self.artifact is not None + return Ok(self.artifact) + + +class FakePrimitives: + def __init__(self, primitive: Primitive | None = None, error: ErrorsList | None = None) -> None: + self.primitive = primitive + self.error = error + self.resolved: list[PythonPath] = [] + + def resolve(self, primitive_id: PythonPath) -> Result[Primitive, ErrorsList]: + self.resolved.append(primitive_id) + if self.error is not None: + return Err(self.error) + assert self.primitive is not None + return Ok(self.primitive) + + +class FakeMachineContext: + def __init__( + self, + *, + artifact: Artifact | None = None, + primitive: Primitive | None = None, + artifact_error: ErrorsList | None = None, + primitive_error: ErrorsList | None = None, + ) -> None: + self._artifacts = FakeArtifacts(artifact=artifact, error=artifact_error) + self._primitives = FakePrimitives(primitive=primitive, error=primitive_error) + self._journal = FakeJournal() + self._current_work_unit_id: ValueScope[WorkUnitId] = ValueScope() + self._current_operation_id: ValueScope[ArtifactSectionId] = ValueScope() + + @property + def artifacts(self) -> FakeArtifacts: + return self._artifacts + + @property + def primitives(self) -> FakePrimitives: + return self._primitives + + @property + def journal(self) -> FakeJournal: + return self._journal + + @property + def current_work_unit_id(self) -> ValueScope[WorkUnitId]: + return self._current_work_unit_id + + @property + def current_operation_id(self) -> ValueScope[ArtifactSectionId]: + return self._current_operation_id diff --git a/donna/machine/tests/make.py b/donna/machine/tests/make.py new file mode 100644 index 00000000..e5e18219 --- /dev/null +++ b/donna/machine/tests/make.py @@ -0,0 +1,88 @@ +from donna.domain.artifact_ids import ArtifactId, ArtifactSectionId +from donna.domain.id_paths import NormalizedRawIdPath +from donna.domain.ids import SectionId +from donna.domain.internal_ids import ActionRequestId, TaskId, WorkUnitId +from donna.domain.python_path import PythonPath +from donna.machine.action_requests import ActionRequest +from donna.machine.artifacts import Artifact, ArtifactSection, ArtifactSectionMeta +from donna.machine.state import MutableState +from donna.machine.tasks import Task, WorkUnit + +ARTIFACT_ID = ArtifactId("@/workflows/test.donna.md") +PRIMARY_SECTION_ID = SectionId("workflow") +SECONDARY_SECTION_ID = SectionId("next") +PRIMITIVE_PATH = PythonPath(NormalizedRawIdPath("donna.machine.tests.test_primitives.sample_primitive")) +PRIMARY_OPERATION_ID = ArtifactSectionId("@/workflows/test.donna.md:workflow") +SECONDARY_OPERATION_ID = ArtifactSectionId("@/workflows/test.donna.md:next") +TASK_ID = TaskId("T-1-b") +WORK_UNIT_ID = WorkUnitId("WU-2-c") +ACTION_REQUEST_ID = ActionRequestId("AR-3-d") + + +def artifact_section( # noqa: CFQ002 + *, + id: SectionId = PRIMARY_SECTION_ID, + artifact_id: ArtifactId = ARTIFACT_ID, + kind: PythonPath = PRIMITIVE_PATH, + title: str = "Workflow", + description: str = "Workflow description", + primary: bool = False, + meta: ArtifactSectionMeta | None = None, +) -> ArtifactSection: + return ArtifactSection( + id=id, + artifact_id=artifact_id, + kind=kind, + title=title, + description=description, + primary=primary, + meta=meta or ArtifactSectionMeta(), + ) + + +def artifact(sections: list[ArtifactSection] | None = None) -> Artifact: + if sections is None: + sections = [artifact_section(primary=True)] + + return Artifact(id=ARTIFACT_ID, sections=sections) + + +def task(*, id: TaskId = TASK_ID, workflow_id: ArtifactSectionId = PRIMARY_OPERATION_ID) -> Task: + return Task.build(id=id, workflow_id=workflow_id) + + +def work_unit( + *, + id: WorkUnitId = WORK_UNIT_ID, + task_id: TaskId = TASK_ID, + operation_id: ArtifactSectionId = PRIMARY_OPERATION_ID, + context: dict[str, object] | None = None, +) -> WorkUnit: + return WorkUnit.build(id=id, task_id=task_id, operation_id=operation_id, context=context) + + +def action_request( + *, + id: ActionRequestId | None = ACTION_REQUEST_ID, + title: str = "Action title", + request: str = "Do the thing", + operation_id: ArtifactSectionId = PRIMARY_OPERATION_ID, +) -> ActionRequest: + return ActionRequest(id=id, title=title, request=request, operation_id=operation_id) + + +def mutable_state( + *, + tasks: list[Task] | None = None, + work_units: list[WorkUnit] | None = None, + action_requests: list[ActionRequest] | None = None, + started: bool = True, + last_id: int = 0, +) -> MutableState: + return MutableState( + tasks=tasks or [], + work_units=work_units or [], + action_requests=action_requests or [], + started=started, + last_id=last_id, + ) diff --git a/donna/machine/tests/test_action_requests.py b/donna/machine/tests/test_action_requests.py new file mode 100644 index 00000000..70e8aecf --- /dev/null +++ b/donna/machine/tests/test_action_requests.py @@ -0,0 +1,36 @@ +from donna.machine.action_requests import ActionRequest, ActionRequestNode +from donna.machine.tests import make + + +class TestActionRequest: + def test_build__creates_pending_request_without_id(self) -> None: + request = ActionRequest.build( + title="Need input", + request="Choose next step", + operation_id=make.PRIMARY_OPERATION_ID, + ) + + assert request.id is None + assert request.title == "Need input" + assert request.request == "Choose next step" + assert request.operation_id == make.PRIMARY_OPERATION_ID + + def test_node__returns_action_request_node(self) -> None: + request = make.action_request() + + node = request.node() + + assert isinstance(node, ActionRequestNode) + + +class TestActionRequestNode: + def test_status__returns_action_request_status_cell(self) -> None: + request = make.action_request() + + cell = ActionRequestNode(request).status() + + assert cell.kind == "action_request" + assert cell.media_type == "text/markdown" + assert cell.content is not None + assert "Do the thing" in cell.content + assert cell.meta == {"action_request_id": str(make.ACTION_REQUEST_ID)} diff --git a/donna/machine/tests/test_artifacts.py b/donna/machine/tests/test_artifacts.py new file mode 100644 index 00000000..9444bd7e --- /dev/null +++ b/donna/machine/tests/test_artifacts.py @@ -0,0 +1,212 @@ +from donna.core.errors import ErrorsList +from donna.core.result import Err, Result +from donna.domain.ids import SectionId +from donna.machine import errors as machine_errors +from donna.machine.artifacts import Artifact, ArtifactNode, ArtifactSectionMeta, ArtifactSectionNode +from donna.machine.context import reset_context, set_context +from donna.machine.primitives import Primitive +from donna.machine.tests import make +from donna.machine.tests.helpers import FakeMachineContext + + +class _Meta(ArtifactSectionMeta): + def cells_meta(self) -> dict[str, str]: + return {"custom": "value"} + + +class _RejectingPrimitive(Primitive): + def validate_section(self, artifact: Artifact, section_id: SectionId) -> Result[None, ErrorsList]: + return Err([machine_errors.PrimitiveInvalidImportPath(import_path="bad")]) + + +class TestArtifactSectionMeta: + def test_cells_meta__returns_empty_metadata(self) -> None: + assert ArtifactSectionMeta().cells_meta() == {} + + +class TestArtifactSection: + def test_markdown_blocks__uses_h2_title_and_description(self) -> None: + section = make.artifact_section(title="Step", description="Description") + + assert section.markdown_blocks() == ["## Step", "Description"] + + def test_node__returns_artifact_section_node(self) -> None: + section = make.artifact_section() + + assert isinstance(section.node(), ArtifactSectionNode) + + +class TestArtifact: + def test_primary_section__returns_single_primary_section(self) -> None: + primary = make.artifact_section(primary=True) + secondary = make.artifact_section(id=make.SECONDARY_SECTION_ID) + + result = make.artifact([secondary, primary]).primary_section() + + assert result.is_ok() + assert result.unwrap() == primary + + def test_primary_section__reports_missing_primary_section(self) -> None: + result = make.artifact([make.artifact_section(primary=False)]).primary_section() + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, machine_errors.ArtifactPrimarySectionMissing) + assert error.artifact_id == make.ARTIFACT_ID + + def test_primary_section__reports_multiple_primary_sections(self) -> None: + first = make.artifact_section(id=SectionId("z"), primary=True) + second = make.artifact_section(id=SectionId("a"), primary=True) + + result = make.artifact([first, second]).primary_section() + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, machine_errors.MultiplePrimarySectionsError) + assert error.primary_sections == [SectionId("a"), SectionId("z")] + + def test_get_section__returns_primary_section_for_none(self) -> None: + artifact = make.artifact() + + result = artifact.get_section(None) + + assert result.is_ok() + assert result.unwrap().id == make.PRIMARY_SECTION_ID + + def test_get_section__returns_requested_section(self) -> None: + secondary = make.artifact_section(id=make.SECONDARY_SECTION_ID) + artifact = make.artifact([make.artifact_section(primary=True), secondary]) + + result = artifact.get_section(make.SECONDARY_SECTION_ID) + + assert result.is_ok() + assert result.unwrap() == secondary + + def test_get_section__reports_missing_section(self) -> None: + artifact = make.artifact() + + result = artifact.get_section(make.SECONDARY_SECTION_ID) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, machine_errors.ArtifactSectionNotFound) + assert error.section_id == make.SECONDARY_SECTION_ID + + def test_get_section_number__returns_zero_based_index(self) -> None: + artifact = make.artifact( + [ + make.artifact_section(primary=True), + make.artifact_section(id=make.SECONDARY_SECTION_ID), + ] + ) + + assert artifact.get_section_number(make.SECONDARY_SECTION_ID) == 1 + assert artifact.get_section_number(SectionId("missing")) is None + + def test_markdown_blocks__uses_primary_as_h1_and_other_sections_as_h2(self) -> None: + artifact = make.artifact( + [ + make.artifact_section(primary=True, title="Workflow", description="Intro"), + make.artifact_section(id=make.SECONDARY_SECTION_ID, title="Next", description="Body"), + ] + ) + + result = artifact.markdown_blocks() + + assert result.is_ok() + assert result.unwrap() == ["# Workflow", "Intro", "## Next", "Body"] + + def test_markdown_blocks__returns_primary_section_error(self) -> None: + result = make.artifact([make.artifact_section(primary=False)]).markdown_blocks() + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], machine_errors.ArtifactPrimarySectionMissing) + + def test_validate_artifact__accepts_valid_artifact(self) -> None: + artifact = make.artifact() + machine_context = FakeMachineContext(primitive=Primitive()) + token = set_context(machine_context) + + try: + result = artifact.validate_artifact() + finally: + reset_context(token) + + assert result.is_ok() + assert machine_context.primitives.resolved == [make.PRIMITIVE_PATH] + + def test_validate_artifact__collects_primary_and_section_errors(self) -> None: + artifact = make.artifact([make.artifact_section(primary=False)]) + machine_context = FakeMachineContext(primitive=_RejectingPrimitive()) + token = set_context(machine_context) + + try: + result = artifact.validate_artifact() + finally: + reset_context(token) + + assert result.is_err() + errors = result.unwrap_err() + assert [type(error) for error in errors] == [ + machine_errors.ArtifactPrimarySectionMissing, + machine_errors.PrimitiveInvalidImportPath, + ] + + def test_node__returns_artifact_node(self) -> None: + assert isinstance(make.artifact().node(), ArtifactNode) + + +class TestArtifactNode: + def test_status__returns_primary_section_summary(self) -> None: + cell = make.artifact().node().status() + + assert cell.kind == "artifact_status" + assert cell.content == "Workflow description" + assert cell.meta == { + "artifact_id": str(make.ARTIFACT_ID), + "artifact_kind": str(make.PRIMITIVE_PATH), + "artifact_title": "Workflow", + } + + def test_info__returns_artifact_markdown(self) -> None: + cell = ( + make.artifact( + [ + make.artifact_section(primary=True), + make.artifact_section(id=make.SECONDARY_SECTION_ID, title="Next", description="Body"), + ] + ) + .node() + .info() + ) + + assert cell.kind == "artifact_info" + assert cell.content == "# Workflow\nWorkflow description\n## Next\nBody" + assert cell.meta == { + "artifact_id": str(make.ARTIFACT_ID), + "artifact_kind": str(make.PRIMITIVE_PATH), + } + + def test_components__returns_section_nodes(self) -> None: + components = make.artifact().node().components() + + assert len(components) == 1 + assert isinstance(components[0], ArtifactSectionNode) + + +class TestArtifactSectionNode: + def test_status__returns_section_status_cell(self) -> None: + section = make.artifact_section(primary=True, meta=_Meta()) + + cell = ArtifactSectionNode(section).status() + + assert cell.kind == "artifact_section_status" + assert cell.media_type == "text/markdown" + assert cell.content == "## Workflow\nWorkflow description" + assert cell.meta == { + "artifact_id": str(make.ARTIFACT_ID), + "section_id": str(make.PRIMARY_SECTION_ID), + "section_kind": str(make.PRIMITIVE_PATH), + "section_primary": True, + "custom": "value", + } diff --git a/donna/machine/tests/test_changes.py b/donna/machine/tests/test_changes.py new file mode 100644 index 00000000..42add80b --- /dev/null +++ b/donna/machine/tests/test_changes.py @@ -0,0 +1,121 @@ +import pytest + +from donna.domain.internal_ids import TaskId +from donna.machine.changes import ( + ChangeAddActionRequest, + ChangeAddTask, + ChangeAddWorkUnit, + ChangeFinishTask, + ChangeRemoveActionRequest, + ChangeRemoveTask, + ChangeRemoveWorkUnit, + ChangeSetTaskContext, +) +from donna.machine.context import reset_context, set_context +from donna.machine.tests import make +from donna.machine.tests.helpers import FakeMachineContext + + +class TestChangeAddTask: + def test_apply_to__adds_task_initial_work_unit_and_marks_started(self) -> None: + state = make.mutable_state(started=False) + + ChangeAddTask(operation_id=make.PRIMARY_OPERATION_ID).apply_to(state) + + assert state.started + assert len(state.tasks) == 1 + assert state.tasks[0].id == "T-1-b" + assert len(state.work_units) == 1 + assert state.work_units[0].id == "WU-2-c" + assert state.work_units[0].task_id == state.tasks[0].id + assert state.work_units[0].operation_id == make.PRIMARY_OPERATION_ID + + +class TestChangeFinishTask: + def test_apply_to__finishes_workflow_for_task(self) -> None: + task = make.task() + state = make.mutable_state(tasks=[task]) + machine_context = FakeMachineContext(artifact=make.artifact()) + token = set_context(machine_context) + + try: + ChangeFinishTask(task_id=task.id).apply_to(state) + finally: + reset_context(token) + + assert state.tasks == [] + assert machine_context.journal.records == [{"message": "Finish workflow `Workflow`", "actor_id": None}] + + +class TestChangeAddWorkUnit: + def test_apply_to__adds_work_unit_with_next_id(self) -> None: + state = make.mutable_state(last_id=2) + + ChangeAddWorkUnit(task_id=make.TASK_ID, operation_id=make.SECONDARY_OPERATION_ID).apply_to(state) + + assert len(state.work_units) == 1 + assert state.work_units[0].id == "WU-3-d" + assert state.work_units[0].task_id == make.TASK_ID + assert state.work_units[0].operation_id == make.SECONDARY_OPERATION_ID + + +class TestChangeAddActionRequest: + def test_apply_to__delegates_to_state_action_request_addition(self) -> None: + state = make.mutable_state(last_id=2) + request = make.action_request(id=None) + machine_context = FakeMachineContext() + token = set_context(machine_context) + + try: + ChangeAddActionRequest(action_request=request).apply_to(state) + finally: + reset_context(token) + + assert state.action_requests == [request.replace(id=make.ACTION_REQUEST_ID)] + + +class TestChangeRemoveActionRequest: + def test_apply_to__removes_matching_action_request(self) -> None: + state = make.mutable_state(action_requests=[make.action_request()]) + + ChangeRemoveActionRequest(action_request_id=make.ACTION_REQUEST_ID).apply_to(state) + + assert state.action_requests == [] + + +class TestChangeRemoveWorkUnit: + def test_apply_to__removes_matching_work_unit(self) -> None: + state = make.mutable_state(work_units=[make.work_unit()]) + + ChangeRemoveWorkUnit(work_unit_id=make.WORK_UNIT_ID).apply_to(state) + + assert state.work_units == [] + + +class TestChangeRemoveTask: + def test_apply_to__removes_matching_task(self) -> None: + state = make.mutable_state(tasks=[make.task()]) + + ChangeRemoveTask(task_id=make.TASK_ID).apply_to(state) + + assert state.tasks == [] + + +class TestChangeSetTaskContext: + def test_apply_to__updates_matching_task_context(self) -> None: + target = make.task(id=make.TASK_ID) + other = make.task(id=TaskId("T-2-c")) + state = make.mutable_state(tasks=[target, other]) + + ChangeSetTaskContext(task_id=make.TASK_ID, key="answer", value=42).apply_to(state) + + assert state.tasks[0].context == {"answer": 42} + assert state.tasks[1].context == {} + + def test_apply_to__raises_when_task_is_missing(self) -> None: + state = make.mutable_state(tasks=[make.task(id=TaskId("T-2-c"))]) + + with pytest.raises(AssertionError): + ChangeSetTaskContext(task_id=make.TASK_ID, key="answer", value=42).apply_to(state) + + assert state.tasks[0].context == {} diff --git a/donna/machine/tests/test_context.py b/donna/machine/tests/test_context.py new file mode 100644 index 00000000..be89a985 --- /dev/null +++ b/donna/machine/tests/test_context.py @@ -0,0 +1,47 @@ +import pytest + +from donna.machine import errors as machine_errors +from donna.machine.context import ValueScope, context, reset_context, set_context +from donna.machine.tests.helpers import FakeMachineContext + + +class TestValueScope: + def test_get__returns_initial_value(self) -> None: + scope = ValueScope("initial") + + assert scope.get() == "initial" + + def test_scope__restores_previous_value(self) -> None: + scope = ValueScope("outer") + + with scope.scope("inner"): + assert scope.get() == "inner" + + assert scope.get() == "outer" + + def test_scope__restores_previous_value_after_exception(self) -> None: + scope = ValueScope("outer") + + with pytest.raises(ValueError): + with scope.scope("inner"): + raise ValueError + + assert scope.get() == "outer" + + +class TestContext: + def test_context__raises_when_not_set(self) -> None: + with pytest.raises(machine_errors.MachineContextNotSet): + context() + + def test_context__returns_current_context_until_reset(self) -> None: + machine_context = FakeMachineContext() + token = set_context(machine_context) + + try: + assert context() == machine_context + finally: + reset_context(token) + + with pytest.raises(machine_errors.MachineContextNotSet): + context() diff --git a/donna/machine/tests/test_errors.py b/donna/machine/tests/test_errors.py new file mode 100644 index 00000000..ba7baeeb --- /dev/null +++ b/donna/machine/tests/test_errors.py @@ -0,0 +1,15 @@ +from donna.domain.ids import SectionId +from donna.machine.errors import ArtifactPrimarySectionMissing +from donna.machine.tests import make + + +class TestArtifactValidationError: + def test_content_intro__describes_artifact_error(self) -> None: + error = ArtifactPrimarySectionMissing(artifact_id=make.ARTIFACT_ID) + + assert error.content_intro() == "Error in artifact '@/workflows/test.donna.md'" + + def test_content_intro__describes_section_error(self) -> None: + error = ArtifactPrimarySectionMissing(artifact_id=make.ARTIFACT_ID, section_id=SectionId("section")) + + assert error.content_intro() == "Error in artifact '@/workflows/test.donna.md', section 'section'" diff --git a/donna/machine/tests/test_operations.py b/donna/machine/tests/test_operations.py new file mode 100644 index 00000000..5bad3969 --- /dev/null +++ b/donna/machine/tests/test_operations.py @@ -0,0 +1,14 @@ +from donna.domain.ids import SectionId +from donna.machine.operations import FsmMode, OperationMeta + + +class TestOperationMeta: + def test_cells_meta__serializes_fsm_mode_and_allowed_transitions(self) -> None: + meta = OperationMeta(fsm_mode=FsmMode.final, allowed_transitions={SectionId("next"), SectionId("done")}) + + cell_meta = meta.cells_meta() + + assert cell_meta["fsm_mode"] == "final" + transitions = cell_meta["allowed_transitions"] + assert isinstance(transitions, list) + assert set(transitions) == {"next", "done"} diff --git a/donna/machine/tests/test_primitives.py b/donna/machine/tests/test_primitives.py new file mode 100644 index 00000000..025a8d11 --- /dev/null +++ b/donna/machine/tests/test_primitives.py @@ -0,0 +1,76 @@ +import pytest + +from donna.domain.id_paths import NormalizedRawIdPath +from donna.domain.python_path import PythonPath +from donna.machine import errors as machine_errors +from donna.machine.primitives import Primitive, resolve_primitive +from donna.machine.tests import make + +sample_primitive = Primitive() +sample_non_primitive = object() + + +class TestPrimitive: + def test_validate_section__allows_section_by_default(self) -> None: + result = sample_primitive.validate_section(make.artifact(), make.PRIMARY_SECTION_ID) + + assert result.is_ok() + + def test_execute_section__raises_unsupported_method(self) -> None: + with pytest.raises(machine_errors.PrimitiveMethodUnsupported) as exception_info: + sample_primitive.execute_section(make.task(), make.work_unit(), make.artifact(), make.PRIMARY_SECTION_ID) + + error = exception_info.value + assert error.arguments == {"primitive_name": "Primitive", "method_name": "execute_section()"} + + def test_apply_directive__raises_unsupported_method(self) -> None: + with pytest.raises(machine_errors.PrimitiveMethodUnsupported) as exception_info: + sample_primitive.apply_directive({}) + + error = exception_info.value + assert error.arguments == {"primitive_name": "Primitive", "method_name": "apply_directive()"} + + +class TestResolvePrimitive: + def test_success_from_python_path(self) -> None: + result = resolve_primitive(make.PRIMITIVE_PATH) + + assert result.is_ok() + assert result.unwrap() == sample_primitive + + def test_invalid_import_path_without_attribute_name(self) -> None: + result = resolve_primitive(PythonPath(NormalizedRawIdPath("primitive"))) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, machine_errors.PrimitiveInvalidImportPath) + assert error.import_path == "primitive" + + def test_module_not_importable(self) -> None: + result = resolve_primitive(PythonPath(NormalizedRawIdPath("donna.machine.tests.missing.sample_primitive"))) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, machine_errors.PrimitiveModuleNotImportable) + assert error.module_path == "donna.machine.tests.missing" + + def test_primitive_not_available(self) -> None: + result = resolve_primitive( + PythonPath(NormalizedRawIdPath("donna.machine.tests.test_primitives.missing_primitive")) + ) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, machine_errors.PrimitiveNotAvailable) + assert error.import_path == "donna.machine.tests.test_primitives.missing_primitive" + assert error.module_path == "donna.machine.tests.test_primitives" + + def test_object_is_not_primitive(self) -> None: + result = resolve_primitive( + PythonPath(NormalizedRawIdPath("donna.machine.tests.test_primitives.sample_non_primitive")) + ) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, machine_errors.PrimitiveNotPrimitive) + assert error.import_path == "donna.machine.tests.test_primitives.sample_non_primitive" diff --git a/donna/machine/tests/test_state.py b/donna/machine/tests/test_state.py new file mode 100644 index 00000000..b5911f0f --- /dev/null +++ b/donna/machine/tests/test_state.py @@ -0,0 +1,236 @@ +from donna.core.errors import ErrorsList +from donna.core.result import Ok, Result +from donna.domain.internal_ids import TaskId, WorkUnitId +from donna.machine import errors as machine_errors +from donna.machine.changes import Change, ChangeSetTaskContext +from donna.machine.context import reset_context, set_context +from donna.machine.operations import OperationKind +from donna.machine.state import MutableState +from donna.machine.tasks import Task, WorkUnit +from donna.machine.tests import make +from donna.machine.tests.helpers import FakeMachineContext + + +class _StateOperation(OperationKind): + def execute_section( + self, + task: Task, + unit: WorkUnit, + artifact: object, + section_id: object, + ) -> Result[list[Change], ErrorsList]: + return Ok([ChangeSetTaskContext(task_id=task.id, key="status", value="done")]) + + +class TestBaseState: + def test_has_work__depends_on_queued_work_units(self) -> None: + assert not make.mutable_state(work_units=[]).has_work() + assert make.mutable_state(work_units=[make.work_unit()]).has_work() + + def test_current_task__returns_last_task(self) -> None: + first = make.task(id=make.TASK_ID) + second = make.task(id=TaskId("T-2-c")) + state = make.mutable_state(tasks=[first, second]) + + assert state.current_task == second + + def test_current_task__returns_none_without_tasks(self) -> None: + assert make.mutable_state(tasks=[]).current_task is None + + def test_get_action_request__returns_matching_request(self) -> None: + request = make.action_request() + state = make.mutable_state(action_requests=[request]) + + result = state.get_action_request(make.ACTION_REQUEST_ID) + + assert result.is_ok() + assert result.unwrap() == request + + def test_get_action_request__reports_missing_request(self) -> None: + result = make.mutable_state(action_requests=[]).get_action_request(make.ACTION_REQUEST_ID) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, machine_errors.ActionRequestNotFound) + assert error.request_id == make.ACTION_REQUEST_ID + + def test_get_next_work_unit__returns_first_unit_for_current_task(self) -> None: + older_task = make.task(id=make.TASK_ID) + current_task = make.task(id=TaskId("T-2-c")) + older_unit = make.work_unit(task_id=older_task.id) + current_unit = make.work_unit(id=WorkUnitId("WU-3-d"), task_id=current_task.id) + state = make.mutable_state(tasks=[older_task, current_task], work_units=[older_unit, current_unit]) + + assert state.get_next_work_unit() == current_unit + + +class TestMutableState: + def test_build__creates_empty_not_started_state(self) -> None: + state = MutableState.build() + + assert state.tasks == [] + assert state.work_units == [] + assert state.action_requests == [] + assert not state.started + assert state.last_id == 0 + + def test_next_ids__increment_shared_counter(self) -> None: + state = MutableState.build() + + assert state.next_task_id() == "T-1-b" + assert state.next_work_unit_id() == "WU-2-c" + assert state.next_action_request_id() == "AR-3-d" + assert state.last_id == 3 + + def test_freeze_and_mutator__deep_copy_state(self) -> None: + state = make.mutable_state(tasks=[make.task()]) + + frozen = state.freeze() + state.tasks[0].context["changed"] = True + mutable = frozen.mutator() + mutable.tasks[0].context["mutable"] = True + + assert frozen.tasks[0].context == {} + assert mutable.tasks[0].context == {"mutable": True} + + def test_add_action_request__assigns_id_and_logs_request(self) -> None: + state = make.mutable_state(last_id=2) + request = make.action_request(id=None) + machine_context = FakeMachineContext() + token = set_context(machine_context) + + try: + state.add_action_request(request) + finally: + reset_context(token) + + assert state.action_requests == [request.replace(id=make.ACTION_REQUEST_ID)] + assert machine_context.journal.records == [ + {"message": "Request agent action `Action title`", "actor_id": "donna"} + ] + + def test_apply_changes__applies_changes_in_order(self) -> None: + state = make.mutable_state(tasks=[make.task()]) + + state.apply_changes([ChangeSetTaskContext(task_id=make.TASK_ID, key="first", value=1)]) + + assert state.tasks[0].context == {"first": 1} + + def test_complete_action_request__queues_next_work_unit_and_removes_request(self) -> None: + task = make.task() + request = make.action_request() + state = make.mutable_state(tasks=[task], action_requests=[request], last_id=2) + machine_context = FakeMachineContext() + token = set_context(machine_context) + + try: + result = state.complete_action_request(make.ACTION_REQUEST_ID, make.SECONDARY_OPERATION_ID) + finally: + reset_context(token) + + assert result.is_ok() + assert state.action_requests == [] + assert len(state.work_units) == 1 + assert state.work_units[0].id == "WU-3-d" + assert state.work_units[0].task_id == task.id + assert state.work_units[0].operation_id == make.SECONDARY_OPERATION_ID + assert machine_context.journal.records == [ + {"message": "Complete agent action `Action title`", "actor_id": None} + ] + + def test_start_workflow__creates_task_and_initial_work_unit(self) -> None: + state = MutableState.build() + artifact = make.artifact() + machine_context = FakeMachineContext(artifact=artifact) + token = set_context(machine_context) + + try: + result = state.start_workflow(make.PRIMARY_OPERATION_ID) + finally: + reset_context(token) + + assert result.is_ok() + assert state.started + assert len(state.tasks) == 1 + assert state.tasks[0].id == "T-1-b" + assert state.tasks[0].workflow_id == make.PRIMARY_OPERATION_ID + assert len(state.work_units) == 1 + assert state.work_units[0].id == "WU-2-c" + assert state.work_units[0].task_id == state.tasks[0].id + assert machine_context.artifacts.viewed == [make.ARTIFACT_ID] + assert machine_context.journal.records == [{"message": "Start workflow `Workflow`", "actor_id": None}] + + def test_finish_workflow__removes_task_and_logs_workflow_title(self) -> None: + task = make.task() + state = make.mutable_state(tasks=[task]) + machine_context = FakeMachineContext(artifact=make.artifact()) + token = set_context(machine_context) + + try: + state.finish_workflow(task.id) + finally: + reset_context(token) + + assert state.tasks == [] + assert machine_context.journal.records == [{"message": "Finish workflow `Workflow`", "actor_id": None}] + + def test_execute_next_work_unit__applies_operation_changes_and_removes_unit(self) -> None: + task = make.task() + unit = make.work_unit() + state = make.mutable_state(tasks=[task], work_units=[unit]) + machine_context = FakeMachineContext(artifact=make.artifact(), primitive=_StateOperation()) + token = set_context(machine_context) + + try: + result = state.execute_next_work_unit() + finally: + reset_context(token) + + assert result.is_ok() + assert state.work_units == [] + assert state.tasks[0].context == {"status": "done"} + assert machine_context.current_work_unit_id.get() is None + + +class TestStateNode: + def test_status__reports_new_session(self) -> None: + cell = MutableState.build().node().status() + + assert cell.kind == "session_state_status" + assert cell.content is not None + assert "new session" in cell.content + assert cell.meta == {"tasks": 0, "queued_work_units": 0, "pending_action_requests": 0} + + def test_status__reports_idle_session(self) -> None: + cell = make.mutable_state(tasks=[], work_units=[], action_requests=[], started=True).node().status() + + assert cell.content is not None + assert "IDLE" in cell.content + assert cell.meta["tasks"] == 0 + + def test_status__reports_pending_work_units(self) -> None: + cell = make.mutable_state(tasks=[make.task()], work_units=[make.work_unit()]).node().status() + + assert cell.content is not None + assert "PENDING WORK UNITS" in cell.content + assert cell.meta["queued_work_units"] == 1 + + def test_status__reports_pending_action_requests(self) -> None: + cell = make.mutable_state(tasks=[make.task()], action_requests=[make.action_request()]).node().status() + + assert cell.content is not None + assert "AWAITING YOUR ACTION" in cell.content + assert cell.meta["pending_action_requests"] == 1 + + def test_status__reports_unfinished_tasks(self) -> None: + cell = make.mutable_state(tasks=[make.task()]).node().status() + + assert cell.content is not None + assert "unfinished TASKS" in cell.content + assert cell.meta["tasks"] == 1 + + def test_references__returns_action_request_nodes(self) -> None: + references = make.mutable_state(action_requests=[make.action_request()]).node().references() + + assert len(references) == 1 + assert references[0].status().kind == "action_request" diff --git a/donna/machine/tests/test_tasks.py b/donna/machine/tests/test_tasks.py new file mode 100644 index 00000000..6cb09480 --- /dev/null +++ b/donna/machine/tests/test_tasks.py @@ -0,0 +1,89 @@ +from donna.core.errors import ErrorsList +from donna.core.result import Ok, Result +from donna.domain.internal_ids import TaskId, WorkUnitId +from donna.machine.changes import Change, ChangeSetTaskContext +from donna.machine.context import reset_context, set_context +from donna.machine.operations import OperationKind +from donna.machine.tasks import Task, WorkUnit +from donna.machine.tests import make +from donna.machine.tests.helpers import FakeMachineContext + + +class _ContextSettingOperation(OperationKind): + def execute_section( + self, + task: Task, + unit: WorkUnit, + artifact: object, + section_id: object, + ) -> Result[list[Change], ErrorsList]: + return Ok( + [ + ChangeSetTaskContext( + task_id=task.id, + key="executed", + value={ + "unit_id": str(unit.id), + "section_id": str(section_id), + }, + ) + ] + ) + + +class TestTask: + def test_build__creates_empty_context(self) -> None: + task = Task.build(id=TaskId("T-1-b"), workflow_id=make.PRIMARY_OPERATION_ID) + + assert task.context == {} + + +class TestWorkUnit: + def test_build__uses_empty_context_by_default(self) -> None: + unit = WorkUnit.build( + id=WorkUnitId("WU-1-b"), + task_id=TaskId("T-1-b"), + operation_id=make.PRIMARY_OPERATION_ID, + ) + + assert unit.context == {} + + def test_build__deep_copies_context(self) -> None: + source_context = {"items": [1]} + + unit = WorkUnit.build( + id=WorkUnitId("WU-1-b"), + task_id=TaskId("T-1-b"), + operation_id=make.PRIMARY_OPERATION_ID, + context=source_context, + ) + source_context["items"].append(2) + + assert unit.context == {"items": [1]} + + def test_run__executes_operation_and_restores_scope(self) -> None: + task = make.task() + unit = make.work_unit() + artifact = make.artifact() + primitive = _ContextSettingOperation() + machine_context = FakeMachineContext(artifact=artifact, primitive=primitive) + token = set_context(machine_context) + + try: + result = unit.run(task) + finally: + reset_context(token) + + assert result.is_ok() + changes = result.unwrap() + assert changes == [ + ChangeSetTaskContext( + task_id=task.id, + key="executed", + value={"unit_id": str(unit.id), "section_id": str(make.PRIMARY_SECTION_ID)}, + ) + ] + assert machine_context.artifacts.executed == [(make.ARTIFACT_ID, task, unit)] + assert machine_context.primitives.resolved == [make.PRIMITIVE_PATH] + assert machine_context.journal.records == [{"message": "Workflow", "actor_id": "donna"}] + assert machine_context.current_operation_id.get() is None diff --git a/donna/machine/tests/test_templates.py b/donna/machine/tests/test_templates.py new file mode 100644 index 00000000..b7d8a866 --- /dev/null +++ b/donna/machine/tests/test_templates.py @@ -0,0 +1,73 @@ +import pytest + +from donna.core.errors import ErrorsList +from donna.core.result import Err, Ok, Result +from donna.machine import errors as machine_errors +from donna.machine.templates import Directive, DirectiveUnsupportedRenderMode, RenderMode +from donna.machine.templates_context import DirectiveContext + + +class _Directive(Directive): + analyze_id: str = "sample" + + def render_view(self, context: DirectiveContext, *argv: object) -> Result[object, ErrorsList]: + return Ok({"mode": context["render_mode"], "argv": argv}) + + +class _PreparingDirective(_Directive): + def _prepare_arguments( + self, context: DirectiveContext, *argv: object, **kwargs: object + ) -> Result[tuple[object, ...], ErrorsList]: + return Ok(("prepared", *argv, kwargs["extra"])) + + +class _FailingDirective(_Directive): + def _prepare_arguments( + self, context: DirectiveContext, *argv: object, **kwargs: object + ) -> Result[tuple[object, ...], ErrorsList]: + return Err([machine_errors.PrimitiveInvalidImportPath(import_path="bad")]) + + +class TestDirective: + def test_apply_directive__renders_view_mode(self) -> None: + result = _Directive().apply_directive({"render_mode": RenderMode.view}, "value") + + assert result.is_ok() + assert result.unwrap() == {"mode": RenderMode.view, "argv": ("value",)} + + def test_apply_directive__renders_execute_mode_with_view_default(self) -> None: + result = _Directive().apply_directive({"render_mode": RenderMode.execute}, "value") + + assert result.is_ok() + assert result.unwrap() == {"mode": RenderMode.execute, "argv": ("value",)} + + def test_apply_directive__renders_analysis_mode(self) -> None: + result = _Directive().apply_directive({"render_mode": RenderMode.analysis}, "a", 2) + + assert result.is_ok() + assert result.unwrap() == "$$donna sample a 2 donna$$" + + def test_apply_directive__uses_prepared_arguments(self) -> None: + result = _PreparingDirective().apply_directive({"render_mode": RenderMode.view}, "value", extra="tail") + + assert result.is_ok() + assert result.unwrap() == {"mode": RenderMode.view, "argv": ("prepared", "value", "tail")} + + def test_apply_directive__returns_argument_preparation_error(self) -> None: + result = _FailingDirective().apply_directive({"render_mode": RenderMode.view}) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], machine_errors.PrimitiveInvalidImportPath) + + def test_apply_directive__rejects_unsupported_render_mode(self) -> None: + with pytest.raises(DirectiveUnsupportedRenderMode) as exception_info: + _Directive().apply_directive({"render_mode": "unsupported"}) + + error = exception_info.value + assert error.arguments == {"render_mode": "unsupported", "directive_name": "_Directive"} + + def test_render_analyze__omits_empty_argument_gap(self) -> None: + result = _Directive().render_analyze({"render_mode": RenderMode.analysis}) + + assert result.is_ok() + assert result.unwrap() == "$$donna sample donna$$" diff --git a/donna/primitives/__init__.py b/donna/primitives/__init__.py index 543680c2..b24c5298 100644 --- a/donna/primitives/__init__.py +++ b/donna/primitives/__init__.py @@ -1 +1,7 @@ """Concrete implementations for Donna primitives.""" + +from donna.primitives import artifacts as artifacts +from donna.primitives import directives as directives +from donna.primitives import sections as sections + +__all__ = ("artifacts", "directives", "sections") diff --git a/donna/primitives/artifacts/__init__.py b/donna/primitives/artifacts/__init__.py index e69de29b..94c67867 100644 --- a/donna/primitives/artifacts/__init__.py +++ b/donna/primitives/artifacts/__init__.py @@ -0,0 +1,3 @@ +from donna.primitives.artifacts.workflow import Workflow + +__all__ = ("Workflow",) diff --git a/donna/primitives/artifacts/tests/__init__.py b/donna/primitives/artifacts/tests/__init__.py new file mode 100644 index 00000000..ddbfacf2 --- /dev/null +++ b/donna/primitives/artifacts/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for donna.primitives.artifacts.""" diff --git a/donna/primitives/artifacts/tests/test_workflow.py b/donna/primitives/artifacts/tests/test_workflow.py new file mode 100644 index 00000000..05988a4a --- /dev/null +++ b/donna/primitives/artifacts/tests/test_workflow.py @@ -0,0 +1,210 @@ +from donna.domain.ids import SectionId +from donna.machine.artifacts import ArtifactSection +from donna.machine.changes import ChangeAddWorkUnit +from donna.machine.errors import ArtifactSectionNotFound +from donna.machine.operations import FsmMode, OperationMeta +from donna.machine.tests import make as machine_make +from donna.primitives.artifacts.workflow import ( + FinalOperationHasTransitions, + NoOutgoingTransitions, + SectionIsNotAnOperation, + StartOperationMissing, + Workflow, + WorkflowMeta, + WorkflowSectionNotWorkflow, + WrongStartOperation, + find_workflow_sections, +) +from donna.primitives.tests import make + + +def workflow_section(meta: WorkflowMeta | None = None) -> ArtifactSection: + return machine_make.artifact_section( + id=make.section_id("workflow"), + kind=make.primitive_kind("donna.primitives.artifacts.workflow.Workflow"), + title="Workflow", + primary=True, + meta=meta or WorkflowMeta(start_operation_id=make.section_id("start")), + ) + + +def operation_section( + *, + id: SectionId, + transitions: set[SectionId] | None = None, + fsm_mode: FsmMode = FsmMode.normal, +) -> ArtifactSection: + return machine_make.artifact_section( + id=id, + kind=make.primitive_kind("donna.primitives.sections.text.Text"), + meta=OperationMeta( + fsm_mode=fsm_mode, + allowed_transitions=transitions or set(), + ), + ) + + +class TestFindWorkflowSections: + def test_follows_operation_transitions_once(self) -> None: + artifact = machine_make.artifact( + [ + operation_section(id=make.section_id("start"), transitions={make.section_id("next")}), + operation_section(id=make.section_id("next"), transitions={make.section_id("start")}), + ] + ) + + sections = find_workflow_sections(make.section_id("start"), artifact) + + assert sections == {make.section_id("start"), make.section_id("next")} + + def test_stops_at_missing_or_non_operation_sections(self) -> None: + artifact = machine_make.artifact( + [ + operation_section( + id=make.section_id("start"), + transitions={make.section_id("next"), make.section_id("other")}, + ), + machine_make.artifact_section( + id=make.section_id("next"), + kind=make.primitive_kind("donna.primitives.sections.text.Text"), + ), + ] + ) + + sections = find_workflow_sections(make.section_id("start"), artifact) + + assert sections == {make.section_id("start"), make.section_id("next"), make.section_id("other")} + + +class TestWorkflowMeta: + def test_cells_meta__serializes_explicit_start_operation(self) -> None: + meta = WorkflowMeta(start_operation_id=make.section_id("start")) + + assert meta.cells_meta() == {"start_operation_id": "start"} + + def test_cells_meta__omits_missing_start_operation(self) -> None: + meta = WorkflowMeta() + + assert meta.cells_meta() == {} + + +class TestWorkflow: + def test_execute_section__adds_work_unit_for_resolved_start_operation(self) -> None: + artifact = machine_make.artifact( + [ + workflow_section(WorkflowMeta(start_operation_id=make.section_id("start"))), + operation_section(id=make.section_id("start"), fsm_mode=FsmMode.final), + ] + ) + + result = Workflow().execute_section( + machine_make.task(), + machine_make.work_unit(), + artifact, + make.section_id("workflow"), + ) + + assert result.is_ok() + change = result.unwrap()[0] + assert isinstance(change, ChangeAddWorkUnit) + assert change.task_id == machine_make.TASK_ID + assert change.operation_id == make.operation_id("start") + + def test_validate_section__accepts_connected_workflow_ending_in_final_operation(self) -> None: + artifact = machine_make.artifact( + [ + workflow_section(WorkflowMeta(start_operation_id=make.section_id("start"))), + operation_section(id=make.section_id("start"), transitions={make.section_id("next")}), + operation_section(id=make.section_id("next"), fsm_mode=FsmMode.final), + ] + ) + + result = Workflow().validate_section(artifact, make.section_id("workflow")) + + assert result.is_ok() + + def test_validate_section__uses_first_tail_section_as_default_start_operation(self) -> None: + artifact = machine_make.artifact( + [ + workflow_section(WorkflowMeta()), + operation_section(id=make.section_id("start"), fsm_mode=FsmMode.final), + ] + ) + + result = Workflow().validate_section(artifact, make.section_id("workflow")) + + assert result.is_ok() + + def test_validate_section__rejects_non_workflow_section_meta(self) -> None: + artifact = machine_make.artifact( + [ + machine_make.artifact_section( + id=make.section_id("workflow"), + kind=make.primitive_kind("donna.primitives.sections.text.Text"), + primary=True, + ) + ] + ) + + result = Workflow().validate_section(artifact, make.section_id("workflow")) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], WorkflowSectionNotWorkflow) + + def test_validate_section__requires_start_operation_when_workflow_has_no_tail_sections(self) -> None: + artifact = machine_make.artifact([workflow_section(WorkflowMeta())]) + + result = Workflow().validate_section(artifact, make.section_id("workflow")) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], StartOperationMissing) + + def test_validate_section__reports_wrong_explicit_start_operation(self) -> None: + artifact = machine_make.artifact([workflow_section(WorkflowMeta(start_operation_id=make.section_id("start")))]) + + result = Workflow().validate_section(artifact, make.section_id("workflow")) + + assert result.is_err() + errors = result.unwrap_err() + assert isinstance(errors[0], ArtifactSectionNotFound) + assert isinstance(errors[1], WrongStartOperation) + + def test_validate_section__reports_non_operation_workflow_section(self) -> None: + artifact = machine_make.artifact( + [ + workflow_section(WorkflowMeta(start_operation_id=make.section_id("start"))), + operation_section(id=make.section_id("start"), transitions={make.section_id("next")}), + machine_make.artifact_section( + id=make.section_id("next"), + kind=make.primitive_kind("donna.primitives.sections.text.Text"), + ), + ] + ) + + result = Workflow().validate_section(artifact, make.section_id("workflow")) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], SectionIsNotAnOperation) + + def test_validate_section__reports_invalid_transition_rules(self) -> None: + artifact = machine_make.artifact( + [ + workflow_section(WorkflowMeta(start_operation_id=make.section_id("start"))), + operation_section( + id=make.section_id("start"), + transitions={make.section_id("next"), make.section_id("done")}, + ), + operation_section( + id=make.section_id("next"), + transitions={make.section_id("done")}, + fsm_mode=FsmMode.final, + ), + operation_section(id=make.section_id("done")), + ] + ) + + result = Workflow().validate_section(artifact, make.section_id("workflow")) + + assert result.is_err() + errors = result.unwrap_err() + assert {type(error) for error in errors} == {FinalOperationHasTransitions, NoOutgoingTransitions} diff --git a/donna/primitives/artifacts/workflow.py b/donna/primitives/artifacts/workflow.py index c061527a..14d4944c 100644 --- a/donna/primitives/artifacts/workflow.py +++ b/donna/primitives/artifacts/workflow.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import TYPE_CHECKING, ClassVar, cast from donna.core import errors as core_errors @@ -9,6 +10,7 @@ from donna.machine.errors import ArtifactValidationError from donna.machine.operations import FsmMode, OperationMeta from donna.machine.primitives import Primitive +from donna.protocol.cells import MetaValue from donna.workspaces import markdown from donna.workspaces.markdown_parser import MarkdownSectionMixin @@ -95,7 +97,7 @@ def find_workflow_sections(start_operation_id: SectionId, artifact: Artifact) -> if not isinstance(section.meta, OperationMeta): continue - to_visit.extend(section.meta.allowed_transtions) + to_visit.extend(section.meta.allowed_transitions) return workflow_sections @@ -107,7 +109,7 @@ class WorkflowConfig(ArtifactSectionConfig): class WorkflowMeta(ArtifactSectionMeta): start_operation_id: SectionId | None = None - def cells_meta(self) -> dict[str, object]: + def cells_meta(self) -> Mapping[str, MetaValue]: if self.start_operation_id is None: return {} return {"start_operation_id": str(self.start_operation_id)} @@ -196,7 +198,7 @@ def validate_section( # noqa: CCR001, CFQ001 ) continue - if workflow_section.meta.fsm_mode == FsmMode.final and workflow_section.meta.allowed_transtions: + if workflow_section.meta.fsm_mode == FsmMode.final and workflow_section.meta.allowed_transitions: errors.append( FinalOperationHasTransitions( artifact_id=artifact.id, section_id=section_id, workflow_section_id=workflow_section.id @@ -207,7 +209,7 @@ def validate_section( # noqa: CCR001, CFQ001 if workflow_section.meta.fsm_mode == FsmMode.final: continue - if not workflow_section.meta.allowed_transtions: + if not workflow_section.meta.allowed_transitions: errors.append( NoOutgoingTransitions( artifact_id=artifact.id, section_id=section_id, workflow_section_id=workflow_section.id diff --git a/donna/primitives/directives/__init__.py b/donna/primitives/directives/__init__.py index e69de29b..3dc24786 100644 --- a/donna/primitives/directives/__init__.py +++ b/donna/primitives/directives/__init__.py @@ -0,0 +1,4 @@ +from donna.primitives.directives.goto import GoTo +from donna.primitives.directives.task_variable import TaskVariable + +__all__ = ("GoTo", "TaskVariable") diff --git a/donna/primitives/directives/goto.py b/donna/primitives/directives/goto.py index 132d1bbc..1ebd3104 100644 --- a/donna/primitives/directives/goto.py +++ b/donna/primitives/directives/goto.py @@ -1,12 +1,11 @@ -from typing import Any, cast - -from jinja2.runtime import Context +from typing import cast from donna.core import errors as core_errors from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result from donna.domain.artifact_ids import ArtifactId, ArtifactSectionId, artifact_section_id, split_artifact_section_id from donna.machine.templates import Directive, PreparedDirectiveResult +from donna.machine.templates_context import DirectiveContext from donna.workspaces import config as workspace_config @@ -24,27 +23,30 @@ class GoToInvalidArguments(EnvironmentError): class GoTo(Directive): def _prepare_arguments( self, - context: Context, - *argv: Any, + context: DirectiveContext, + *argv: object, + **kwargs: object, ) -> PreparedDirectiveResult: if argv is None or len(argv) != 1: return Err([GoToInvalidArguments(provided_count=0 if argv is None else len(argv))]) artifact_id = cast(ArtifactId, context["artifact_id"]) - next_operation_id = artifact_section_id(artifact_id, argv[0]) + next_operation_id = artifact_section_id(artifact_id, str(argv[0])) return Ok((next_operation_id,)) - def render_view(self, context: Context, next_operation_id: ArtifactSectionId) -> Result[Any, ErrorsList]: + def render_view(self, context: DirectiveContext, *argv: object) -> Result[object, ErrorsList]: + next_operation_id = cast(ArtifactSectionId, argv[0]) protocol = workspace_config.protocol().value - root_dir = workspace_config.project_dir() + config_path = workspace_config.config_path() return Ok( - f"donna -p {protocol} -r '{root_dir}' " + f"donna -p {protocol} --config '{config_path}' " f"complete-action-request '{next_operation_id}'" ) - def render_analyze(self, context: Context, next_operation_id: ArtifactSectionId) -> Result[Any, ErrorsList]: + def render_analyze(self, context: DirectiveContext, *argv: object) -> Result[object, ErrorsList]: + next_operation_id = cast(ArtifactSectionId, argv[0]) parts = split_artifact_section_id(next_operation_id) assert parts is not None return Ok(f"$$donna {self.analyze_id} {parts.section_id} donna$$") diff --git a/donna/primitives/directives/task_variable.py b/donna/primitives/directives/task_variable.py index 8c0d86b4..0be0877c 100644 --- a/donna/primitives/directives/task_variable.py +++ b/donna/primitives/directives/task_variable.py @@ -1,11 +1,10 @@ -from typing import Any, cast - -from jinja2.runtime import Context +from typing import cast from donna.core import errors as core_errors from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result from donna.machine.templates import Directive, PreparedDirectiveResult +from donna.machine.templates_context import DirectiveContext class EnvironmentError(core_errors.EnvironmentError): @@ -28,8 +27,9 @@ class TaskVariableTaskContextMissing(EnvironmentError): class TaskVariable(Directive): def _prepare_arguments( self, - context: Context, - *argv: Any, + context: DirectiveContext, + *argv: object, + **kwargs: object, ) -> PreparedDirectiveResult: if argv is None or len(argv) != 1: return Err([TaskVariableInvalidArguments(provided_count=0 if argv is None else len(argv))]) @@ -38,13 +38,15 @@ def _prepare_arguments( return Ok((variable_name,)) - def render_view(self, context: Context, variable_name: str) -> Result[Any, ErrorsList]: + def render_view(self, context: DirectiveContext, *argv: object) -> Result[object, ErrorsList]: + variable_name = str(argv[0]) return Ok( - "$$donna at the time of execution of this section here will placed a value " + "$$donna at the time of execution of this section will place a value " f"of the task variable '{variable_name}' donna$$" ) - def render_execute(self, context: Context, variable_name: str) -> Result[Any, ErrorsList]: + def render_execute(self, context: DirectiveContext, *argv: object) -> Result[object, ErrorsList]: + variable_name = str(argv[0]) task_context = self._resolve_task_context(context) if task_context is None: return Err([TaskVariableTaskContextMissing()]) @@ -61,13 +63,13 @@ def render_execute(self, context: Context, variable_name: str) -> Result[Any, Er return Ok(task_context[variable_name]) - def _resolve_task_context(self, context: Context) -> dict[str, Any] | None: + def _resolve_task_context(self, context: DirectiveContext) -> dict[str, object] | None: task = context.get("current_task") if task is not None and hasattr(task, "context"): - return cast(dict[str, Any], task.context) + return cast(dict[str, object], task.context) task_context = context.get("task_context") if isinstance(task_context, dict): - return cast(dict[str, Any], task_context) + return cast(dict[str, object], task_context) return None diff --git a/donna/primitives/directives/tests/__init__.py b/donna/primitives/directives/tests/__init__.py new file mode 100644 index 00000000..956f7279 --- /dev/null +++ b/donna/primitives/directives/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for donna.primitives.directives.""" diff --git a/donna/primitives/directives/tests/test_goto.py b/donna/primitives/directives/tests/test_goto.py new file mode 100644 index 00000000..0294b694 --- /dev/null +++ b/donna/primitives/directives/tests/test_goto.py @@ -0,0 +1,54 @@ +from pathlib import Path + +from pytest_mock import MockerFixture + +from donna.domain.artifact_ids import ArtifactSectionId +from donna.machine.tests import make as machine_make +from donna.primitives.directives import goto +from donna.primitives.directives.goto import GoTo, GoToInvalidArguments +from donna.primitives.tests import make +from donna.protocol.modes import Mode + + +class TestGoTo: + def test_prepare_arguments__requires_one_argument(self) -> None: + result = GoTo(analyze_id="goto")._prepare_arguments( + make.template_context(artifact_id=machine_make.ARTIFACT_ID) + ) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, GoToInvalidArguments) + assert error.provided_count == 0 + + def test_prepare_arguments__builds_artifact_section_id_in_current_artifact(self) -> None: + result = GoTo(analyze_id="goto")._prepare_arguments( + make.template_context(artifact_id=machine_make.ARTIFACT_ID), "next" + ) + + assert result.is_ok() + assert result.unwrap() == (ArtifactSectionId("@/workflows/test.donna.md:next"),) + + def test_render_view__renders_complete_action_request_command(self, mocker: MockerFixture) -> None: + mocker.patch.object(goto.workspace_config, "protocol", return_value=Mode.llm) + mocker.patch.object(goto.workspace_config, "config_path", return_value=Path("/project/donna.toml")) + + result = GoTo(analyze_id="goto").render_view( + make.template_context(), + ArtifactSectionId("@/workflows/test.donna.md:next"), + ) + + assert result.is_ok() + assert ( + result.unwrap() == "donna -p llm --config '/project/donna.toml' " + "complete-action-request '@/workflows/test.donna.md:next'" + ) + + def test_render_analyze__renders_section_local_marker(self) -> None: + result = GoTo(analyze_id="goto").render_analyze( + make.template_context(), + ArtifactSectionId("@/workflows/test.donna.md:next"), + ) + + assert result.is_ok() + assert result.unwrap() == "$$donna goto next donna$$" diff --git a/donna/primitives/directives/tests/test_task_variable.py b/donna/primitives/directives/tests/test_task_variable.py new file mode 100644 index 00000000..b2cb4d7a --- /dev/null +++ b/donna/primitives/directives/tests/test_task_variable.py @@ -0,0 +1,68 @@ +from donna.primitives.directives.task_variable import ( + TaskVariable, + TaskVariableInvalidArguments, + TaskVariableTaskContextMissing, +) +from donna.primitives.tests import make + + +class TestTaskVariable: + def test_prepare_arguments__requires_one_argument(self) -> None: + result = TaskVariable(analyze_id="task_variable")._prepare_arguments(make.template_context()) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, TaskVariableInvalidArguments) + assert error.provided_count == 0 + + def test_prepare_arguments__coerces_variable_name_to_string(self) -> None: + result = TaskVariable(analyze_id="task_variable")._prepare_arguments(make.template_context(), 42) + + assert result.is_ok() + assert result.unwrap() == ("42",) + + def test_render_view__describes_deferred_variable_substitution(self) -> None: + result = TaskVariable(analyze_id="task_variable").render_view(make.template_context(), "answer") + + assert result.is_ok() + content = result.unwrap() + assert isinstance(content, str) + assert "will place a value" in content + assert "answer" in content + + def test_render_execute__returns_value_from_task_context_mapping(self) -> None: + result = TaskVariable(analyze_id="task_variable").render_execute( + make.template_context(task_context={"answer": 42}), + "answer", + ) + + assert result.is_ok() + assert result.unwrap() == 42 + + def test_render_execute__returns_value_from_current_task_context(self) -> None: + current_task = type("CurrentTask", (), {"context": {"answer": "yes"}})() + + result = TaskVariable(analyze_id="task_variable").render_execute( + make.template_context(current_task=current_task), + "answer", + ) + + assert result.is_ok() + assert result.unwrap() == "yes" + + def test_render_execute__reports_missing_task_context(self) -> None: + result = TaskVariable(analyze_id="task_variable").render_execute(make.template_context(), "answer") + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], TaskVariableTaskContextMissing) + + def test_render_execute__renders_warning_for_missing_variable(self) -> None: + result = TaskVariable(analyze_id="task_variable").render_execute( + make.template_context(task_context={}), + "answer", + ) + + assert result.is_ok() + content = result.unwrap() + assert isinstance(content, str) + assert "variable 'answer' does not found" in content diff --git a/donna/primitives/sections/__init__.py b/donna/primitives/sections/__init__.py index e69de29b..1586a514 100644 --- a/donna/primitives/sections/__init__.py +++ b/donna/primitives/sections/__init__.py @@ -0,0 +1,7 @@ +from donna.primitives.sections.finish_workflow import FinishWorkflow +from donna.primitives.sections.output import Output +from donna.primitives.sections.request_action import RequestAction +from donna.primitives.sections.run_script import RunScript +from donna.primitives.sections.text import Text + +__all__ = ("FinishWorkflow", "Output", "RequestAction", "RunScript", "Text") diff --git a/donna/primitives/sections/finish_workflow.py b/donna/primitives/sections/finish_workflow.py index 45c76d8b..38e24b32 100644 --- a/donna/primitives/sections/finish_workflow.py +++ b/donna/primitives/sections/finish_workflow.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, ClassVar, Literal, cast +from donna.context.context import context from donna.core.errors import ErrorsList from donna.core.result import Ok, Result, unwrap_to_error from donna.domain.artifact_ids import ArtifactId @@ -7,7 +8,6 @@ from donna.machine.artifacts import Artifact, ArtifactSectionConfig, ArtifactSectionMeta from donna.machine.operations import FsmMode, OperationConfig, OperationKind, OperationMeta from donna.protocol import cell_shortcuts -from donna.protocol.utils import instant_output_cell from donna.workspaces import markdown from donna.workspaces.markdown_parser import MarkdownSectionMixin @@ -29,7 +29,7 @@ def execute_section( operation = artifact.get_section(section_id).unwrap() info = cell_shortcuts.info(operation.description) - instant_output_cell(info) + context().output.emit_cell(info) return Ok([ChangeFinishTask(task_id=task.id)]) @@ -44,4 +44,4 @@ def markdown_construct_meta( primary: bool = False, ) -> Result[ArtifactSectionMeta, ErrorsList]: finish_config = cast(FinishWorkflowConfig, section_config) - return Ok(OperationMeta(fsm_mode=finish_config.fsm_mode, allowed_transtions=set())) + return Ok(OperationMeta(fsm_mode=finish_config.fsm_mode, allowed_transitions=set())) diff --git a/donna/primitives/sections/output.py b/donna/primitives/sections/output.py index b26c0312..debf3df9 100644 --- a/donna/primitives/sections/output.py +++ b/donna/primitives/sections/output.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, ClassVar, cast +from donna.context.context import context from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error from donna.domain.artifact_ids import ArtifactId, artifact_section_id, split_artifact_section_id @@ -8,7 +9,6 @@ from donna.machine.errors import ArtifactValidationError from donna.machine.operations import OperationConfig, OperationKind, OperationMeta from donna.protocol import cell_shortcuts -from donna.protocol.utils import instant_output_cell from donna.workspaces import markdown from donna.workspaces.markdown_parser import MarkdownSectionMixin @@ -53,7 +53,7 @@ def markdown_construct_meta( return Ok( OutputMeta( fsm_mode=output_config.fsm_mode, - allowed_transtions=allowed_transitions, + allowed_transitions=allowed_transitions, next_operation_id=output_config.next_operation_id, ) ) @@ -68,7 +68,7 @@ def execute_section( meta = cast(OutputMeta, operation.meta) info = cell_shortcuts.info(operation.description) - instant_output_cell(info) + context().output.emit_cell(info) next_operation_id = meta.next_operation_id assert next_operation_id is not None diff --git a/donna/primitives/sections/request_action.py b/donna/primitives/sections/request_action.py index dbf1aa70..6b1d9f67 100644 --- a/donna/primitives/sections/request_action.py +++ b/donna/primitives/sections/request_action.py @@ -68,7 +68,7 @@ def markdown_construct_meta( return Ok( OperationMeta( fsm_mode=request_config.fsm_mode, - allowed_transtions=extract_transitions(analysis), + allowed_transitions=extract_transitions(analysis), ) ) diff --git a/donna/primitives/sections/run_script.py b/donna/primitives/sections/run_script.py index 7ca7955d..516e6e34 100644 --- a/donna/primitives/sections/run_script.py +++ b/donna/primitives/sections/run_script.py @@ -5,13 +5,13 @@ import pydantic +from donna.context.context import context from donna.core import errors as core_errors from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error from donna.domain.artifact_ids import ArtifactId, artifact_section_id, split_artifact_section_id from donna.domain.ids import SectionId from donna.domain.paths import ProjectRootPath -from donna.machine import journal as machine_journal from donna.machine.artifacts import Artifact, ArtifactSectionConfig, ArtifactSectionMeta from donna.machine.errors import ArtifactValidationError from donna.machine.operations import OperationConfig, OperationKind, OperationMeta @@ -129,7 +129,7 @@ def markdown_construct_meta( return Ok( RunScriptMeta( fsm_mode=run_config.fsm_mode, - allowed_transtions=allowed_transitions, + allowed_transitions=allowed_transitions, script=script, save_stdout_to=run_config.save_stdout_to, save_stderr_to=run_config.save_stderr_to, @@ -152,7 +152,7 @@ def execute_section( script = meta.script assert script is not None - machine_journal.add( + context().journal.add( actor_id="donna", message=f"Run script `{operation.title}`", ).unwrap() @@ -163,7 +163,7 @@ def execute_section( project_dir=workspace_config.project_dir(), ) - machine_journal.add( + context().journal.add( actor_id="donna", message=( f"Script finished `{operation.title}`, exit code: {exit_code}, " diff --git a/donna/primitives/sections/tests/__init__.py b/donna/primitives/sections/tests/__init__.py new file mode 100644 index 00000000..e392d02f --- /dev/null +++ b/donna/primitives/sections/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for donna.primitives.sections.""" diff --git a/donna/primitives/sections/tests/test_finish_workflow.py b/donna/primitives/sections/tests/test_finish_workflow.py new file mode 100644 index 00000000..b00f1706 --- /dev/null +++ b/donna/primitives/sections/tests/test_finish_workflow.py @@ -0,0 +1,53 @@ +from pytest_mock import MockerFixture + +from donna.machine.changes import ChangeFinishTask +from donna.machine.operations import FsmMode, OperationMeta +from donna.machine.tests import make as machine_make +from donna.primitives.sections.finish_workflow import FinishWorkflow, FinishWorkflowConfig +from donna.primitives.tests import make +from donna.workspaces.tests import make as workspace_make + + +class TestFinishWorkflow: + def test_markdown_construct_meta__creates_final_operation_without_transitions(self) -> None: + result = FinishWorkflow().markdown_construct_meta( + artifact_id=machine_make.ARTIFACT_ID, + source=workspace_make.section_source(), + section_config=FinishWorkflowConfig( + id=make.section_id("done"), + kind=make.primitive_kind("donna.primitives.sections.finish_workflow.FinishWorkflow"), + ), + description="done", + ) + + assert result.is_ok() + meta = result.unwrap() + assert isinstance(meta, OperationMeta) + assert meta.fsm_mode == FsmMode.final + assert meta.allowed_transitions == set() + + def test_execute_section__emits_message_and_finishes_task(self, mocker: MockerFixture) -> None: + runtime_context = make.FakeRuntimeContext() + mocker.patch("donna.primitives.sections.finish_workflow.context", return_value=runtime_context) + artifact = machine_make.artifact( + [ + machine_make.artifact_section( + id=make.section_id("done"), + kind=make.primitive_kind("donna.primitives.sections.finish_workflow.FinishWorkflow"), + description="Finished", + meta=OperationMeta(fsm_mode=FsmMode.final, allowed_transitions=set()), + ) + ] + ) + + result = FinishWorkflow().execute_section( + machine_make.task(), machine_make.work_unit(), artifact, make.section_id("done") + ) + + assert result.is_ok() + cell = runtime_context.output.cells[0] + assert cell.kind == "info" + assert cell.content == "Finished" + change = result.unwrap()[0] + assert isinstance(change, ChangeFinishTask) + assert change.task_id == machine_make.TASK_ID diff --git a/donna/primitives/sections/tests/test_output.py b/donna/primitives/sections/tests/test_output.py new file mode 100644 index 00000000..fa460900 --- /dev/null +++ b/donna/primitives/sections/tests/test_output.py @@ -0,0 +1,95 @@ +from pytest_mock import MockerFixture + +from donna.machine.changes import ChangeAddWorkUnit +from donna.machine.operations import FsmMode +from donna.machine.tests import make as machine_make +from donna.primitives.sections.output import Output, OutputConfig, OutputMeta, OutputMissingNextOperation +from donna.primitives.tests import make +from donna.workspaces.tests import make as workspace_make + + +class TestOutput: + def test_markdown_construct_meta__records_next_operation_transition(self) -> None: + result = Output().markdown_construct_meta( + artifact_id=machine_make.ARTIFACT_ID, + source=workspace_make.section_source(), + section_config=OutputConfig( + id=make.section_id("start"), + kind=make.primitive_kind("donna.primitives.sections.output.Output"), + fsm_mode=FsmMode.start, + next_operation_id=make.section_id("next"), + ), + description="message", + ) + + assert result.is_ok() + meta = result.unwrap() + assert isinstance(meta, OutputMeta) + assert meta.fsm_mode == FsmMode.start + assert meta.next_operation_id == make.section_id("next") + assert meta.allowed_transitions == {make.section_id("next")} + + def test_markdown_construct_meta__allows_missing_next_operation_for_validation(self) -> None: + result = Output().markdown_construct_meta( + artifact_id=machine_make.ARTIFACT_ID, + source=workspace_make.section_source(), + section_config=OutputConfig( + id=make.section_id("start"), + kind=make.primitive_kind("donna.primitives.sections.output.Output"), + ), + description="message", + ) + + assert result.is_ok() + meta = result.unwrap() + assert isinstance(meta, OutputMeta) + assert meta.next_operation_id is None + assert meta.allowed_transitions == set() + + def test_validate_section__requires_next_operation(self) -> None: + artifact = machine_make.artifact( + [ + machine_make.artifact_section( + id=make.section_id("start"), + kind=make.primitive_kind("donna.primitives.sections.text.Text"), + meta=OutputMeta(allowed_transitions=set(), next_operation_id=None), + ) + ] + ) + + result = Output().validate_section(artifact, make.section_id("start")) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], OutputMissingNextOperation) + + def test_execute_section__emits_message_and_adds_next_work_unit(self, mocker: MockerFixture) -> None: + runtime_context = make.FakeRuntimeContext() + mocker.patch("donna.primitives.sections.output.context", return_value=runtime_context) + artifact = machine_make.artifact( + [ + machine_make.artifact_section( + id=make.section_id("start"), + kind=make.primitive_kind("donna.primitives.sections.text.Text"), + description="Agent message", + meta=OutputMeta( + allowed_transitions={make.section_id("next")}, + next_operation_id=make.section_id("next"), + ), + ) + ] + ) + + result = Output().execute_section( + machine_make.task(), + machine_make.work_unit(operation_id=make.operation_id("start")), + artifact, + make.section_id("start"), + ) + + assert result.is_ok() + cell = runtime_context.output.cells[0] + assert cell.kind == "info" + assert cell.content == "Agent message" + change = result.unwrap()[0] + assert isinstance(change, ChangeAddWorkUnit) + assert change.operation_id == machine_make.ARTIFACT_ID + ":next" diff --git a/donna/primitives/sections/tests/test_request_action.py b/donna/primitives/sections/tests/test_request_action.py new file mode 100644 index 00000000..56ddd0b8 --- /dev/null +++ b/donna/primitives/sections/tests/test_request_action.py @@ -0,0 +1,91 @@ +import pytest +from pydantic import ValidationError + +from donna.domain.errors import InvalidIdentifier +from donna.machine.changes import ChangeAddActionRequest +from donna.machine.operations import FsmMode, OperationMeta +from donna.machine.tests import make as machine_make +from donna.primitives.sections.request_action import RequestAction, RequestActionConfig, extract_transitions +from donna.primitives.tests import make +from donna.workspaces.tests import make as workspace_make + + +class TestExtractTransitions: + def test_extracts_unique_goto_directive_targets(self) -> None: + text = "\n".join( + [ + "$$donna goto next donna$$", + "regular text", + "$$donna goto done donna$$", + "$$donna goto next donna$$", + ] + ) + + transitions = extract_transitions(text) + + assert transitions == {make.section_id("next"), make.section_id("done")} + + def test_rejects_goto_target_that_is_not_a_section_id(self) -> None: + with pytest.raises(InvalidIdentifier): + extract_transitions("$$donna goto folder/next donna$$") + + +class TestRequestActionConfig: + def test_validate_fsm_mode__rejects_final_mode(self) -> None: + with pytest.raises(ValidationError): + RequestActionConfig( + id=make.section_id("start"), + kind=make.primitive_kind("donna.primitives.sections.request_action.RequestAction"), + fsm_mode=FsmMode.final, + ) + + +class TestRequestAction: + def test_markdown_construct_meta__uses_analysis_markdown_for_allowed_transitions(self) -> None: + source = workspace_make.section_source_from_markdown( + "# Workflow\n\n## Ask\n\nChoose one.\n\n$$donna goto next donna$$\n", + section_index=1, + ) + + result = RequestAction().markdown_construct_meta( + artifact_id=machine_make.ARTIFACT_ID, + source=source, + section_config=RequestActionConfig( + id=make.section_id("start"), + kind=make.primitive_kind("donna.primitives.sections.request_action.RequestAction"), + ), + description="Choose one.", + ) + + assert result.is_ok() + meta = result.unwrap() + assert isinstance(meta, OperationMeta) + assert meta.allowed_transitions == {make.section_id("next")} + + def test_execute_section__adds_action_request_for_current_operation(self) -> None: + artifact = machine_make.artifact( + [ + machine_make.artifact_section( + id=make.section_id("start"), + kind=make.primitive_kind("donna.primitives.sections.request_action.RequestAction"), + title="Ask agent", + description="Do the work", + meta=OperationMeta(fsm_mode=FsmMode.normal, allowed_transitions={make.section_id("next")}), + ) + ] + ) + + result = RequestAction().execute_section( + machine_make.task(), + machine_make.work_unit(operation_id=make.operation_id("start")), + artifact, + make.section_id("start"), + ) + + assert result.is_ok() + change = result.unwrap()[0] + assert isinstance(change, ChangeAddActionRequest) + assert change.action_request.id is None + assert change.action_request.title == "Ask agent" + assert change.action_request.request == "Do the work" + assert change.action_request.operation_id == make.operation_id("start") diff --git a/donna/primitives/sections/tests/test_run_script.py b/donna/primitives/sections/tests/test_run_script.py new file mode 100644 index 00000000..d5a76794 --- /dev/null +++ b/donna/primitives/sections/tests/test_run_script.py @@ -0,0 +1,189 @@ +from pathlib import Path + +from pytest_mock import MockerFixture + +from donna.machine.changes import ChangeAddWorkUnit, ChangeSetTaskContext +from donna.machine.tests import make as machine_make +from donna.primitives.sections import run_script +from donna.primitives.sections.run_script import ( + RunScript, + RunScriptConfig, + RunScriptGotoOnCodeIncludesZero, + RunScriptInvalidExitCode, + RunScriptMeta, + RunScriptMissingGotoOnFailure, + RunScriptMissingGotoOnSuccess, + RunScriptMissingScriptBlock, + _coerce_output, +) +from donna.primitives.tests import make +from donna.workspaces.tests import make as workspace_make + + +class TestRunScriptMeta: + def test_select_next_operation__uses_success_transition_for_zero_exit_code(self) -> None: + meta = RunScriptMeta( + allowed_transitions={make.section_id("next"), make.section_id("done")}, + goto_on_success=make.section_id("next"), + goto_on_failure=make.section_id("done"), + ) + + assert meta.select_next_operation(0) == make.section_id("next") + + def test_select_next_operation__uses_exit_code_specific_transition(self) -> None: + meta = RunScriptMeta( + allowed_transitions={make.section_id("next"), make.section_id("done")}, + goto_on_success=make.section_id("next"), + goto_on_failure=make.section_id("done"), + goto_on_code={"2": make.section_id("other")}, + ) + + assert meta.select_next_operation(2) == make.section_id("other") + + def test_select_next_operation__falls_back_to_failure_transition(self) -> None: + meta = RunScriptMeta( + allowed_transitions={make.section_id("next"), make.section_id("done")}, + goto_on_success=make.section_id("next"), + goto_on_failure=make.section_id("done"), + ) + + assert meta.select_next_operation(1) == make.section_id("done") + + +class TestRunScript: + def test_markdown_construct_meta__builds_script_meta_and_allowed_transitions(self) -> None: + source = workspace_make.section_source( + configs=[ + workspace_make.code_source( + "bash", + "echo ok", + donna=True, + script=True, + ) + ] + ) + + result = RunScript().markdown_construct_meta( + artifact_id=machine_make.ARTIFACT_ID, + source=source, + section_config=RunScriptConfig( + id=make.section_id("start"), + kind=make.primitive_kind("donna.primitives.sections.run_script.RunScript"), + goto_on_success=make.section_id("next"), + goto_on_failure=make.section_id("done"), + goto_on_code={"2": make.section_id("other")}, + save_stdout_to="stdout", + save_stderr_to="stderr", + timeout=5, + ), + description="run", + ) + + assert result.is_ok() + meta = result.unwrap() + assert isinstance(meta, RunScriptMeta) + assert meta.script == "echo ok" + assert meta.save_stdout_to == "stdout" + assert meta.save_stderr_to == "stderr" + assert meta.timeout == 5 + assert meta.allowed_transitions == {make.section_id("next"), make.section_id("done"), make.section_id("other")} + + def test_markdown_construct_meta__requires_script_block(self) -> None: + result = RunScript().markdown_construct_meta( + artifact_id=machine_make.ARTIFACT_ID, + source=workspace_make.section_source(), + section_config=RunScriptConfig( + id=make.section_id("start"), + kind=make.primitive_kind("donna.primitives.sections.run_script.RunScript"), + ), + description="run", + ) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], RunScriptMissingScriptBlock) + + def test_validate_section__collects_transition_config_errors(self) -> None: + artifact = machine_make.artifact( + [ + machine_make.artifact_section( + id=make.section_id("start"), + kind=make.primitive_kind("donna.primitives.sections.run_script.RunScript"), + meta=RunScriptMeta( + allowed_transitions={make.section_id("next")}, + goto_on_code={"not-an-int": make.section_id("next"), "0": make.section_id("done")}, + ), + ) + ] + ) + + result = RunScript().validate_section(artifact, make.section_id("start")) + + assert result.is_err() + assert {type(error) for error in result.unwrap_err()} == { + RunScriptMissingGotoOnSuccess, + RunScriptMissingGotoOnFailure, + RunScriptInvalidExitCode, + RunScriptGotoOnCodeIncludesZero, + } + + def test_execute_section__stores_outputs_and_adds_selected_next_work_unit(self, mocker: MockerFixture) -> None: + runtime_context = make.FakeRuntimeContext() + mocker.patch("donna.primitives.sections.run_script.context", return_value=runtime_context) + mocker.patch.object(run_script.workspace_config, "project_dir", return_value=Path("/project")) + run = mocker.patch.object(run_script, "_run_script", return_value=("stdout", "stderr", 2)) + artifact = machine_make.artifact( + [ + machine_make.artifact_section( + id=make.section_id("start"), + kind=make.primitive_kind("donna.primitives.sections.run_script.RunScript"), + title="Run checks", + meta=RunScriptMeta( + allowed_transitions={ + make.section_id("next"), + make.section_id("done"), + make.section_id("other"), + }, + script="echo ok", + save_stdout_to="stdout_key", + save_stderr_to="stderr_key", + goto_on_success=make.section_id("next"), + goto_on_failure=make.section_id("done"), + goto_on_code={"2": make.section_id("other")}, + ), + ) + ] + ) + + result = RunScript().execute_section( + machine_make.task(), + machine_make.work_unit(operation_id=make.operation_id("start")), + artifact, + make.section_id("start"), + ) + + assert result.is_ok() + run.assert_called_once_with(script="echo ok", timeout=60, project_dir=Path("/project")) + assert [message for _, message in runtime_context.journal.messages] == [ + "Run script `Run checks`", + "Script finished `Run checks`, exit code: 2, has stdout: True, has stderr: True`", + ] + changes = result.unwrap() + assert isinstance(changes[0], ChangeSetTaskContext) + assert changes[0].key == "stdout_key" + assert changes[0].value == "stdout" + assert isinstance(changes[1], ChangeSetTaskContext) + assert changes[1].key == "stderr_key" + assert changes[1].value == "stderr" + assert isinstance(changes[2], ChangeAddWorkUnit) + assert changes[2].operation_id == machine_make.ARTIFACT_ID + ":other" + + +class TestCoerceOutput: + def test_returns_empty_string_for_missing_output(self) -> None: + assert _coerce_output(None) == "" + + def test_decodes_bytes_as_utf8_with_replacement(self) -> None: + assert _coerce_output(b"ok \xff") == "ok \ufffd" + + def test_returns_string_output_unchanged(self) -> None: + assert _coerce_output("ok") == "ok" diff --git a/donna/primitives/tests/__init__.py b/donna/primitives/tests/__init__.py new file mode 100644 index 00000000..e7d7faba --- /dev/null +++ b/donna/primitives/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for donna.primitives.""" diff --git a/donna/primitives/tests/make.py b/donna/primitives/tests/make.py new file mode 100644 index 00000000..5b136ef4 --- /dev/null +++ b/donna/primitives/tests/make.py @@ -0,0 +1,30 @@ +from typing import cast + +from donna.context.tests.helpers import FakeJournal, FakeOutputEmitter +from donna.domain.artifact_ids import ArtifactSectionId +from donna.domain.id_paths import NormalizedRawIdPath +from donna.domain.ids import SectionId +from donna.domain.python_path import PythonPath +from donna.machine.templates_context import DirectiveContext + + +def primitive_kind(path: str) -> PythonPath: + return PythonPath(NormalizedRawIdPath(path)) + + +def section_id(value: str) -> SectionId: + return SectionId(value) + + +def operation_id(section: str) -> ArtifactSectionId: + return ArtifactSectionId(f"@/workflows/test.donna.md:{section}") + + +def template_context(**values: object) -> DirectiveContext: + return cast(DirectiveContext, values) + + +class FakeRuntimeContext: + def __init__(self) -> None: + self.output = FakeOutputEmitter() + self.journal = FakeJournal() diff --git a/donna/protocol/__init__.py b/donna/protocol/__init__.py index e69de29b..2a1e3543 100644 --- a/donna/protocol/__init__.py +++ b/donna/protocol/__init__.py @@ -0,0 +1,9 @@ +from donna.protocol import cell_shortcuts as cell_shortcuts +from donna.protocol import cells as cells +from donna.protocol import errors as errors +from donna.protocol import formatters as formatters +from donna.protocol import journal as journal +from donna.protocol import modes as modes +from donna.protocol import nodes as nodes + +__all__ = ("cell_shortcuts", "cells", "errors", "formatters", "journal", "modes", "nodes") diff --git a/donna/protocol/cells.py b/donna/protocol/cells.py index 29bc1321..91c9599e 100644 --- a/donna/protocol/cells.py +++ b/donna/protocol/cells.py @@ -5,12 +5,14 @@ from donna.core.entities import BaseEntity -MetaValue = str | int | bool | None +MetaValue = str | int | bool | None | list[str] def to_meta_value(value: object) -> MetaValue: if isinstance(value, (str, int, bool)) or value is None: return value + if isinstance(value, list) and all(isinstance(item, str) for item in value): + return value return str(value) diff --git a/donna/protocol/errors.py b/donna/protocol/errors.py index b584629e..2cfd6a4f 100644 --- a/donna/protocol/errors.py +++ b/donna/protocol/errors.py @@ -1,4 +1,6 @@ from donna.core import errors as core_errors +from donna.protocol.cells import Cell, MetaValue, to_meta_value +from donna.protocol.nodes import Node class InternalError(core_errors.InternalError): @@ -15,3 +17,65 @@ class UnsupportedFormatterMode(InternalError): class ContentWithoutMediaType(InternalError): message: str = "Cannot set content when media_type is None." + + +class EnvironmentErrorNode(Node): + __slots__ = ("_error",) + + def __init__(self, environment_error: core_errors.EnvironmentError) -> None: + self._error = environment_error + + def meta(self) -> dict[str, MetaValue]: + meta: dict[str, MetaValue] = { + "error_code": self._error.code, + } + + for field_name, _field in type(self._error).model_fields.items(): + if field_name in ("code", "message", "cell_kind", "cell_media_type", "ways_to_fix"): + continue + + value = getattr(self._error, field_name) + + if value is None: + continue + + meta[field_name] = to_meta_value(value) + + return meta + + def content(self) -> str: + intro = self._error.content_intro() + + message = self._error.message.format(error=self._error).strip() + + ways_to_fix = [fix.format(error=self._error).strip() for fix in self._error.ways_to_fix] + + if "\n" in self._error.message: + content = f"{intro}:\n\n{message}" + else: + content = f"{intro}: {message}" + + if not ways_to_fix: + return content + + if len(ways_to_fix) == 1: + return f"{content}\nWay to fix: {ways_to_fix[0]}" + + fixes = "\n".join(f"- {fix}" for fix in ways_to_fix) + + return f"{content}\n\nWays to fix:\n\n{fixes}" + + def status(self) -> Cell: + return Cell.build( + kind=self._error.cell_kind, + media_type=self._error.cell_media_type, + content=self.content(), + **self.meta(), + ) + + def journal_message(self) -> str: + return self._error.message.format(error=self._error).replace("\n", " ").strip() + + +def environment_error_node(error: core_errors.EnvironmentError) -> EnvironmentErrorNode: + return EnvironmentErrorNode(error) diff --git a/donna/protocol/formatters/__init__.py b/donna/protocol/formatters/__init__.py index e69de29b..aa65452f 100644 --- a/donna/protocol/formatters/__init__.py +++ b/donna/protocol/formatters/__init__.py @@ -0,0 +1,3 @@ +from donna.protocol.formatters.base import Formatter + +__all__ = ("Formatter",) diff --git a/donna/protocol/formatters/automation.py b/donna/protocol/formatters/automation.py index 8f6bb6ff..a78c48eb 100644 --- a/donna/protocol/formatters/automation.py +++ b/donna/protocol/formatters/automation.py @@ -1,21 +1,26 @@ import json -from donna.machine.journal import JournalRecord, serialize_record -from donna.protocol.cells import Cell +from donna.protocol.cells import Cell, MetaValue from donna.protocol.formatters.base import Formatter as BaseFormatter +from donna.protocol.journal import JournalRecord, serialize_record class Formatter(BaseFormatter): + def _json_line(self, data: object) -> bytes: + return ( + json.dumps(data, ensure_ascii=False, indent=None, separators=(",", ":"), sort_keys=True).encode() + b"\n" + ) + def format_cell(self, cell: Cell) -> bytes: - data: dict[str, str | int | bool | None] = {"id": cell.short_id} + data: dict[str, MetaValue] = {"id": cell.short_id} for meta_key, meta_value in sorted(cell.meta.items()): data[meta_key] = meta_value data["content"] = cell.content.strip() if cell.content else None - return json.dumps(data, ensure_ascii=False, indent=None, separators=(",", ":"), sort_keys=True).encode() + return self._json_line(data) def format_journal(self, record: JournalRecord) -> bytes: - return serialize_record(record) + return serialize_record(record) + b"\n" diff --git a/donna/protocol/formatters/base.py b/donna/protocol/formatters/base.py index 0ffb1241..6a463b22 100644 --- a/donna/protocol/formatters/base.py +++ b/donna/protocol/formatters/base.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -from donna.machine.journal import JournalRecord from donna.protocol.cells import Cell +from donna.protocol.journal import JournalRecord class Formatter(ABC): diff --git a/donna/protocol/formatters/human.py b/donna/protocol/formatters/human.py index 3f6d230a..5b58ab8e 100644 --- a/donna/protocol/formatters/human.py +++ b/donna/protocol/formatters/human.py @@ -1,6 +1,6 @@ -from donna.machine.journal import JournalRecord from donna.protocol.cells import Cell from donna.protocol.formatters.base import Formatter as BaseFormatter +from donna.protocol.journal import JournalRecord class Formatter(BaseFormatter): diff --git a/donna/protocol/formatters/llm.py b/donna/protocol/formatters/llm.py index 8a56a653..cd2e02d2 100644 --- a/donna/protocol/formatters/llm.py +++ b/donna/protocol/formatters/llm.py @@ -1,6 +1,6 @@ -from donna.machine.journal import JournalRecord from donna.protocol.cells import Cell from donna.protocol.formatters.base import Formatter as BaseFormatter +from donna.protocol.journal import JournalRecord class Formatter(BaseFormatter): diff --git a/donna/protocol/formatters/tests/__init__.py b/donna/protocol/formatters/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/donna/protocol/formatters/tests/test_automation.py b/donna/protocol/formatters/tests/test_automation.py new file mode 100644 index 00000000..728acf2d --- /dev/null +++ b/donna/protocol/formatters/tests/test_automation.py @@ -0,0 +1,36 @@ +import json + +from donna.protocol.formatters.automation import Formatter +from donna.protocol.tests.make import cell, journal_record + + +class TestFormatter: + def test_format_cell__serializes_cell_as_sorted_json_line(self) -> None: + formatted = Formatter().format_cell(cell()) + + assert json.loads(formatted) == { + "alpha": "first", + "content": "Sample content.", + "enabled": True, + "id": "EjRWeBI0VniSNFZ4EjRWeA", + "missing": None, + "zeta": 2, + } + assert formatted == ( + b'{"alpha":"first","content":"Sample content.","enabled":true,' + b'"id":"EjRWeBI0VniSNFZ4EjRWeA","missing":null,"zeta":2}\n' + ) + + def test_format_cell__serializes_missing_content_as_null(self) -> None: + formatted = Formatter().format_cell(cell(content=None)) + + assert json.loads(formatted)["content"] is None + + def test_format_journal__serializes_journal_record_as_json_line(self) -> None: + formatted = Formatter().format_journal(journal_record()) + + assert formatted == ( + b'{"actor_id":"agent","current_operation_id":"@/workflow.donna.md:operation",' + b'"current_task_id":"task-42-Q","current_work_unit_id":"work-unit-7-h",' + b'"message":"Completed step","timestamp":"2026-05-18T10:30:45Z"}\n' + ) diff --git a/donna/protocol/formatters/tests/test_human.py b/donna/protocol/formatters/tests/test_human.py new file mode 100644 index 00000000..f0b98d31 --- /dev/null +++ b/donna/protocol/formatters/tests/test_human.py @@ -0,0 +1,35 @@ +from donna.protocol.formatters.human import Formatter +from donna.protocol.tests.make import cell, journal_record + + +class TestFormatter: + def test_format_cell__renders_human_cell_with_sorted_metadata(self) -> None: + formatted = Formatter().format_cell(cell()).decode() + + assert formatted == ( + "----- DONNA CELL EjRWeBI0VniSNFZ4EjRWeA -----\n" + "kind = sample_status\n" + "media_type = text/markdown\n" + "alpha = first\n" + "enabled = True\n" + "missing = None\n" + "zeta = 2\n" + "\n" + "Sample content.\n" + "\n" + ) + + def test_format_cell__omits_media_type_and_content_when_absent(self) -> None: + formatted = Formatter().format_cell(cell(media_type=None, content=None, meta={})).decode() + + assert formatted == "----- DONNA CELL EjRWeBI0VniSNFZ4EjRWeA -----\nkind = sample_status\n\n" + + def test_format_journal__renders_time_short_task_actor_and_message(self) -> None: + formatted = Formatter().format_journal(journal_record()).decode() + + assert formatted == "10:30:45 [42] Completed step" + + def test_format_journal__uses_placeholders_for_missing_optional_fields(self) -> None: + formatted = Formatter().format_journal(journal_record(actor_id=None, current_task_id=None)).decode() + + assert formatted == "10:30:45 [-] <-> Completed step" diff --git a/donna/protocol/formatters/tests/test_llm.py b/donna/protocol/formatters/tests/test_llm.py new file mode 100644 index 00000000..31979814 --- /dev/null +++ b/donna/protocol/formatters/tests/test_llm.py @@ -0,0 +1,53 @@ +from donna.protocol.formatters.llm import Formatter +from donna.protocol.tests.make import cell, journal_record + + +class TestFormatter: + def test_format_cell__renders_llm_cell_with_sorted_metadata(self) -> None: + formatted = Formatter().format_cell(cell()).decode() + + assert formatted == ( + "--DONNA-CELL EjRWeBI0VniSNFZ4EjRWeA BEGIN--\n" + "kind=sample_status\n" + "media_type=text/markdown\n" + "alpha=first\n" + "enabled=True\n" + "missing=None\n" + "zeta=2\n" + "\n" + "Sample content.\n" + "--DONNA-CELL EjRWeBI0VniSNFZ4EjRWeA END--\n" + ) + + def test_format_cell__omits_media_type_and_content_when_absent(self) -> None: + formatted = Formatter().format_cell(cell(media_type=None, content=None, meta={})).decode() + + assert formatted == ( + "--DONNA-CELL EjRWeBI0VniSNFZ4EjRWeA BEGIN--\n" + "kind=sample_status\n" + "--DONNA-CELL EjRWeBI0VniSNFZ4EjRWeA END--\n" + ) + + def test_format_journal__renders_full_journal_context(self) -> None: + formatted = Formatter().format_journal(journal_record()).decode() + + assert formatted == ( + "2026-05-18T10:30:45+00:00 [task-42-Q] " + "[work-unit-7-h] [@/workflow.donna.md:operation] Completed step" + ) + + def test_format_journal__uses_placeholders_for_missing_optional_fields(self) -> None: + formatted = ( + Formatter() + .format_journal( + journal_record( + actor_id=None, + current_task_id=None, + current_work_unit_id=None, + current_operation_id=None, + ) + ) + .decode() + ) + + assert formatted == "2026-05-18T10:30:45+00:00 [-] <-> [-] [-] Completed step" diff --git a/donna/protocol/journal.py b/donna/protocol/journal.py new file mode 100644 index 00000000..6f5b3c20 --- /dev/null +++ b/donna/protocol/journal.py @@ -0,0 +1,38 @@ +import datetime +import json + +import pydantic + +from donna.core.entities import BaseEntity +from donna.domain.artifact_ids import ArtifactSectionId +from donna.domain.internal_ids import TaskId, WorkUnitId + + +def message_has_newlines(message: str) -> bool: + return "\n" in message or "\r" in message + + +class JournalRecord(BaseEntity): + timestamp: datetime.datetime + actor_id: str | None + message: str + current_task_id: TaskId | None + current_work_unit_id: WorkUnitId | None + current_operation_id: ArtifactSectionId | None + + @pydantic.field_validator("message", mode="after") + @classmethod + def validate_message_no_newlines(cls, value: str) -> str: + if message_has_newlines(value): + raise ValueError("Journal message must not contain newline characters.") + + return value + + +def serialize_record(record: JournalRecord) -> bytes: + return json.dumps( + record.model_dump(mode="json"), + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") diff --git a/donna/protocol/modes.py b/donna/protocol/modes.py index 4ac97b0f..47a32478 100644 --- a/donna/protocol/modes.py +++ b/donna/protocol/modes.py @@ -5,7 +5,6 @@ from donna.protocol.formatters.base import Formatter from donna.protocol.formatters.human import Formatter as HumanFormatter from donna.protocol.formatters.llm import Formatter as LLMFormatter -from donna.workspaces.config import protocol as protocol_mode class Mode(enum.StrEnum): @@ -14,8 +13,8 @@ class Mode(enum.StrEnum): automation = "automation" -def get_cell_formatter() -> Formatter: - match protocol_mode(): +def get_cell_formatter(mode: Mode) -> Formatter: + match mode: case Mode.human: return HumanFormatter() case Mode.llm: @@ -23,4 +22,4 @@ def get_cell_formatter() -> Formatter: case Mode.automation: return AutomationFormatter() case _: - raise UnsupportedFormatterMode(mode=protocol_mode()) + raise UnsupportedFormatterMode(mode=mode) diff --git a/donna/protocol/tests/__init__.py b/donna/protocol/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/donna/protocol/tests/make.py b/donna/protocol/tests/make.py new file mode 100644 index 00000000..583fcd0d --- /dev/null +++ b/donna/protocol/tests/make.py @@ -0,0 +1,33 @@ +import datetime +import uuid + +from donna.domain.artifact_ids import ArtifactId, artifact_section_id +from donna.domain.ids import SectionId +from donna.domain.internal_ids import TaskId, WorkUnitId +from donna.protocol.cells import Cell +from donna.protocol.journal import JournalRecord + + +def cell(**kwargs: object) -> Cell: + values = { + "id": uuid.UUID("12345678-1234-5678-9234-567812345678"), + "kind": "sample_status", + "media_type": "text/markdown", + "content": " Sample content. ", + "meta": {"zeta": 2, "alpha": "first", "enabled": True, "missing": None}, + } + values.update(kwargs) + return Cell.model_validate(values) + + +def journal_record(**kwargs: object) -> JournalRecord: + values = { + "timestamp": datetime.datetime(2026, 5, 18, 10, 30, 45, tzinfo=datetime.UTC), + "actor_id": "agent", + "message": "Completed step", + "current_task_id": TaskId.build("task", 42), + "current_work_unit_id": WorkUnitId.build("work-unit", 7), + "current_operation_id": artifact_section_id(ArtifactId("@/workflow.donna.md"), SectionId("operation")), + } + values.update(kwargs) + return JournalRecord.model_validate(values) diff --git a/donna/protocol/tests/test_cell_shortcuts.py b/donna/protocol/tests/test_cell_shortcuts.py new file mode 100644 index 00000000..b31db99c --- /dev/null +++ b/donna/protocol/tests/test_cell_shortcuts.py @@ -0,0 +1,31 @@ +from donna.protocol import cell_shortcuts + + +class TestOperationSucceeded: + def test_creates_markdown_operation_succeeded_cell(self) -> None: + cell = cell_shortcuts.operation_succeeded("Done.", operation="setup") + + assert cell.kind == "operation_succeeded" + assert cell.media_type == "text/markdown" + assert cell.content == "Done." + assert cell.meta == {"operation": "setup"} + + +class TestOperationFailed: + def test_creates_markdown_operation_failed_cell(self) -> None: + cell = cell_shortcuts.operation_failed("Failed.", operation="setup") + + assert cell.kind == "operation_failed" + assert cell.media_type == "text/markdown" + assert cell.content == "Failed." + assert cell.meta == {"operation": "setup"} + + +class TestInfo: + def test_creates_markdown_info_cell(self) -> None: + cell = cell_shortcuts.info("Ready.", scope="workspace") + + assert cell.kind == "info" + assert cell.media_type == "text/markdown" + assert cell.content == "Ready." + assert cell.meta == {"scope": "workspace"} diff --git a/donna/protocol/tests/test_cells.py b/donna/protocol/tests/test_cells.py new file mode 100644 index 00000000..0645fd89 --- /dev/null +++ b/donna/protocol/tests/test_cells.py @@ -0,0 +1,56 @@ +import uuid + +import pytest + +from donna.protocol.cells import Cell, to_meta_value +from donna.protocol.errors import ContentWithoutMediaType + + +class TestToMetaValue: + @pytest.mark.parametrize( + ("value", "expected"), + ( + ("value", "value"), + (1, 1), + (True, True), + (None, None), + (uuid.UUID("12345678-1234-5678-9234-567812345678"), "12345678-1234-5678-9234-567812345678"), + ), + ) + def test_converts_supported_metadata_values(self, value: object, expected: str | int | bool | None) -> None: + assert to_meta_value(value) == expected + + +class TestCell: + def test_build__requires_media_type_when_content_is_present(self) -> None: + with pytest.raises(ContentWithoutMediaType): + Cell.build(kind="status", media_type=None, content="content") + + def test_build__stores_metadata_as_cell_metadata(self) -> None: + cell = Cell.build(kind="status", media_type="text/plain", content="content", count=2, label="ready") + + assert cell.kind == "status" + assert cell.media_type == "text/plain" + assert cell.content == "content" + assert cell.meta == {"count": 2, "label": "ready"} + + def test_build_meta__creates_contentless_cell(self) -> None: + cell = Cell.build_meta(kind="status", count=0) + + assert cell.kind == "status" + assert cell.media_type is None + assert cell.content is None + assert cell.meta == {"count": 0} + + def test_build_markdown__uses_markdown_media_type(self) -> None: + cell = Cell.build_markdown(kind="status", content="# Done", count=1) + + assert cell.kind == "status" + assert cell.media_type == "text/markdown" + assert cell.content == "# Done" + assert cell.meta == {"count": 1} + + def test_short_id__returns_unpadded_urlsafe_base64_id(self) -> None: + cell = Cell.build_meta(kind="status").replace(id=uuid.UUID("12345678-1234-5678-9234-567812345678")) + + assert cell.short_id == "EjRWeBI0VniSNFZ4EjRWeA" diff --git a/donna/protocol/tests/test_errors.py b/donna/protocol/tests/test_errors.py new file mode 100644 index 00000000..7ce8746d --- /dev/null +++ b/donna/protocol/tests/test_errors.py @@ -0,0 +1,98 @@ +from decimal import Decimal + +from donna.core import errors as core_errors +from donna.protocol.errors import EnvironmentErrorNode, environment_error_node + + +class _SingleFixError(core_errors.EnvironmentError): + cell_kind: str = "sample_error" + code: str = "sample.single" + message: str = "Problem with {error.item}." + ways_to_fix: list[str] = ["Fix {error.item}."] + item: str + count: int + active: bool + optional: str | None = None + decimal_value: Decimal + + def content_intro(self) -> str: + return "Sample" + + +class _MultipleFixesError(core_errors.EnvironmentError): + cell_kind: str = "sample_error" + code: str = "sample.multiple" + message: str = "Problem with {error.item}." + ways_to_fix: list[str] = ["Fix {error.item}.", "Retry."] + item: str + + +class _MultilineError(core_errors.EnvironmentError): + cell_kind: str = "sample_error" + code: str = "sample.multiline" + message: str = "First line.\nSecond line." + + +class TestEnvironmentErrorNode: + def test_meta__includes_code_and_scalar_context_fields(self) -> None: + node = EnvironmentErrorNode( + _SingleFixError(item="artifact", count=3, active=True, decimal_value=Decimal("1.5")) + ) + + assert node.meta() == { + "error_code": "sample.single", + "item": "artifact", + "count": 3, + "active": True, + "decimal_value": "1.5", + } + + def test_content__renders_single_fix(self) -> None: + node = EnvironmentErrorNode( + _SingleFixError(item="artifact", count=3, active=True, decimal_value=Decimal("1.5")) + ) + + assert node.content() == "Sample: Problem with artifact.\nWay to fix: Fix artifact." + + def test_content__renders_multiple_fixes_as_list(self) -> None: + node = EnvironmentErrorNode(_MultipleFixesError(item="artifact")) + + assert node.content() == "Error: Problem with artifact.\n\nWays to fix:\n\n- Fix artifact.\n- Retry." + + def test_content__renders_multiline_message_as_block(self) -> None: + node = EnvironmentErrorNode(_MultilineError()) + + assert node.content() == "Error:\n\nFirst line.\nSecond line." + + def test_status__builds_error_cell(self) -> None: + node = EnvironmentErrorNode( + _SingleFixError(item="artifact", count=3, active=True, decimal_value=Decimal("1.5")) + ) + + cell = node.status() + + assert cell.kind == "sample_error" + assert cell.media_type == "text/markdown" + assert cell.content == "Sample: Problem with artifact.\nWay to fix: Fix artifact." + assert cell.meta == { + "error_code": "sample.single", + "item": "artifact", + "count": 3, + "active": True, + "decimal_value": "1.5", + } + + def test_journal_message__renders_single_line_message(self) -> None: + node = EnvironmentErrorNode(_MultilineError()) + + assert node.journal_message() == "First line. Second line." + + +class TestEnvironmentErrorNodeShortcut: + def test_returns_environment_error_node(self) -> None: + error = _MultipleFixesError(item="artifact") + + node = environment_error_node(error) + + assert isinstance(node, EnvironmentErrorNode) + assert node.status().meta["error_code"] == "sample.multiple" diff --git a/donna/protocol/tests/test_journal.py b/donna/protocol/tests/test_journal.py new file mode 100644 index 00000000..331626f5 --- /dev/null +++ b/donna/protocol/tests/test_journal.py @@ -0,0 +1,49 @@ +import json + +import pydantic +import pytest + +from donna.protocol.journal import JournalRecord, message_has_newlines, serialize_record +from donna.protocol.tests.make import journal_record + + +class TestMessageHasNewlines: + @pytest.mark.parametrize("message", ("line\nnext", "line\rnext")) + def test_detects_newline_characters(self, message: str) -> None: + assert message_has_newlines(message) + + def test_returns_false_for_single_line_message(self) -> None: + assert not message_has_newlines("single line") + + +class TestJournalRecord: + @pytest.mark.parametrize("message", ("line\nnext", "line\rnext")) + def test_validate_message_no_newlines__rejects_multiline_message(self, message: str) -> None: + with pytest.raises(pydantic.ValidationError): + JournalRecord.model_validate( + { + **journal_record().model_dump(), + "message": message, + } + ) + + +class TestSerializeRecord: + def test_serializes_record_as_sorted_compact_json(self) -> None: + record = journal_record() + + serialized = serialize_record(record) + + assert json.loads(serialized) == { + "actor_id": "agent", + "current_operation_id": "@/workflow.donna.md:operation", + "current_task_id": "task-42-Q", + "current_work_unit_id": "work-unit-7-h", + "message": "Completed step", + "timestamp": "2026-05-18T10:30:45Z", + } + assert serialized == ( + b'{"actor_id":"agent","current_operation_id":"@/workflow.donna.md:operation",' + b'"current_task_id":"task-42-Q","current_work_unit_id":"work-unit-7-h",' + b'"message":"Completed step","timestamp":"2026-05-18T10:30:45Z"}' + ) diff --git a/donna/protocol/tests/test_modes.py b/donna/protocol/tests/test_modes.py new file mode 100644 index 00000000..0fca6303 --- /dev/null +++ b/donna/protocol/tests/test_modes.py @@ -0,0 +1,26 @@ +import pytest + +from donna.protocol.errors import UnsupportedFormatterMode +from donna.protocol.formatters.automation import Formatter as AutomationFormatter +from donna.protocol.formatters.human import Formatter as HumanFormatter +from donna.protocol.formatters.llm import Formatter as LLMFormatter +from donna.protocol.modes import Mode, get_cell_formatter + + +class TestGetCellFormatter: + @pytest.mark.parametrize( + ("mode", "formatter_class"), + ( + (Mode.human, HumanFormatter), + (Mode.llm, LLMFormatter), + (Mode.automation, AutomationFormatter), + ), + ) + def test_returns_formatter_for_supported_mode(self, mode: Mode, formatter_class: type[object]) -> None: + assert isinstance(get_cell_formatter(mode), formatter_class) + + def test_unsupported_mode_raises_internal_error(self) -> None: + with pytest.raises(UnsupportedFormatterMode) as error_info: + get_cell_formatter("missing") # type: ignore[arg-type] + + assert error_info.value.arguments == {"mode": "missing"} diff --git a/donna/protocol/tests/test_nodes.py b/donna/protocol/tests/test_nodes.py new file mode 100644 index 00000000..c79ec101 --- /dev/null +++ b/donna/protocol/tests/test_nodes.py @@ -0,0 +1,57 @@ +from donna.protocol.cells import Cell +from donna.protocol.nodes import Node + + +class _SampleNode(Node): + __slots__ = ("_name", "_references") + + def __init__(self, name: str, references: list[Node] | None = None) -> None: + self._name = name + self._references = references or [] + + def status(self) -> Cell: + return Cell.build_markdown(kind="node_status", content=f"Status {self._name}") + + def info(self) -> Cell: + return Cell.build_markdown(kind="node_info", content=f"Info {self._name}") + + def references(self) -> list[Node]: + return self._references + + +class _StatusOnlyNode(Node): + __slots__ = ("_name",) + + def __init__(self, name: str) -> None: + self._name = name + + def status(self) -> Cell: + return Cell.build_markdown(kind="node_status", content=f"Status {self._name}") + + +class TestNode: + def test_info__defaults_to_status(self) -> None: + node = _StatusOnlyNode("root") + + info = node.info() + + assert info.kind == "node_status" + assert info.content == "Status root" + + def test_details__includes_info_for_node_and_references(self) -> None: + reference = _SampleNode("reference") + node = _SampleNode("root", references=[reference]) + + assert [cell.content for cell in node.details()] == ["Info root", "Info reference"] + + def test_index__includes_status_for_node_and_references(self) -> None: + reference = _SampleNode("reference") + node = _SampleNode("root", references=[reference]) + + assert [cell.content for cell in node.index()] == ["Status root", "Status reference"] + + def test_references__defaults_to_empty_list(self) -> None: + assert _StatusOnlyNode("root").references() == [] + + def test_components__defaults_to_empty_list(self) -> None: + assert _StatusOnlyNode("root").components() == [] diff --git a/donna/protocol/utils.py b/donna/protocol/utils.py deleted file mode 100644 index 648f3da0..00000000 --- a/donna/protocol/utils.py +++ /dev/null @@ -1,22 +0,0 @@ -import sys - -from donna.machine.journal import JournalRecord -from donna.protocol.cells import Cell -from donna.protocol.modes import get_cell_formatter - - -def instant_output(text: bytes) -> None: - sys.stdout.buffer.write(text + b"\n") - sys.stdout.buffer.flush() - - -def instant_output_journal(record: JournalRecord) -> None: - formatter = get_cell_formatter() - formatted_output = formatter.format_journal(record) - instant_output(formatted_output) - - -def instant_output_cell(cell: Cell) -> None: - formatter = get_cell_formatter() - formatted_output = formatter.format_cell(cell) - instant_output(formatted_output) diff --git a/donna/runtime/__init__.py b/donna/runtime/__init__.py new file mode 100644 index 00000000..fc151474 --- /dev/null +++ b/donna/runtime/__init__.py @@ -0,0 +1,5 @@ +"""Runtime orchestration for Donna workflow execution.""" + +from donna.runtime import sessions as sessions + +__all__ = ("sessions",) diff --git a/donna/machine/sessions.py b/donna/runtime/sessions.py similarity index 88% rename from donna/machine/sessions.py rename to donna/runtime/sessions.py index 41c08b0c..090d0bc0 100644 --- a/donna/machine/sessions.py +++ b/donna/runtime/sessions.py @@ -7,7 +7,6 @@ from donna.domain.artifact_ids import ArtifactId, ArtifactSectionId, artifact_section_id, split_artifact_section_id from donna.domain.internal_ids import ActionRequestId from donna.machine import errors as machine_errors -from donna.machine import journal as machine_journal from donna.machine.operations import OperationMeta from donna.machine.state import ConsistentState, MutableState from donna.protocol.cell_shortcuts import operation_succeeded @@ -18,7 +17,18 @@ @unwrap_to_error def load_state() -> Result[ConsistentState, ErrorsList]: - return Ok(context().state.load().unwrap()) + loaded = context().state.load() + + if loaded.is_ok(): + return Ok(loaded.unwrap()) + + errors = loaded.unwrap_err() + if not any(isinstance(error, machine_errors.SessionStateNotInitialized) for error in errors): + return Err(errors) + + state = MutableState.build().freeze() + context().state.save(state).unwrap() + return Ok(state) @unwrap_to_error @@ -60,20 +70,12 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> CellsResult: @unwrap_to_error -def start() -> Result[list[Cell], ErrorsList]: - workspace_sessions.reset_dir() - +def new_session() -> Result[list[Cell], ErrorsList]: _save_state(MutableState.build().freeze()).unwrap() - machine_journal.add(message="Started new session.").unwrap() - - return Ok([operation_succeeded("Started new session.")]) - + context().journal.add(message="Created new session state.").unwrap() -@unwrap_to_error -def reset() -> Result[list[Cell], ErrorsList]: - _save_state(MutableState.build().freeze()).unwrap() - return Ok([operation_succeeded("Session state reset.")]) + return Ok([operation_succeeded("Created new session state.")]) @unwrap_to_error @@ -130,7 +132,7 @@ def _validate_operation_transition( assert isinstance(operation.meta, OperationMeta) - if next_operation_parts.section_id not in operation.meta.allowed_transtions: + if next_operation_parts.section_id not in operation.meta.allowed_transitions: return Err( [machine_errors.InvalidOperationTransition(operation_id=operation_id, next_operation_id=next_operation_id)] ) diff --git a/donna/runtime/tests/__init__.py b/donna/runtime/tests/__init__.py new file mode 100644 index 00000000..7cdf4447 --- /dev/null +++ b/donna/runtime/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for donna.runtime.""" diff --git a/donna/runtime/tests/make.py b/donna/runtime/tests/make.py new file mode 100644 index 00000000..91f7600e --- /dev/null +++ b/donna/runtime/tests/make.py @@ -0,0 +1,111 @@ +import contextvars +from typing import cast + +from donna.context.context import Context +from donna.context.context import reset_context as reset_runtime_context +from donna.context.context import set_context as set_runtime_context +from donna.context.tests.helpers import FakeJournal, FakeOutputEmitter +from donna.core.errors import ErrorsList +from donna.core.result import Err, Ok, Result +from donna.domain.artifact_ids import ArtifactId, ArtifactSectionId +from donna.domain.internal_ids import WorkUnitId +from donna.domain.python_path import PythonPath +from donna.machine.artifacts import Artifact +from donna.machine.context import MachineContext, ValueScope +from donna.machine.context import reset_context as reset_machine_context +from donna.machine.context import set_context as set_machine_context +from donna.machine.primitives import Primitive +from donna.machine.state import ConsistentState +from donna.machine.tasks import Task, WorkUnit +from donna.workspaces.artifacts import ArtifactRenderContext + + +class FakeStateStore: + def __init__(self, state: ConsistentState | None = None, errors: ErrorsList | None = None) -> None: + self.state = state + self.errors = errors + self.loaded_count = 0 + self.saved: list[ConsistentState] = [] + + def load(self) -> Result[ConsistentState, ErrorsList]: + self.loaded_count += 1 + + if self.errors is not None: + return Err(self.errors) + + assert self.state is not None + return Ok(self.state) + + def save(self, state: ConsistentState) -> Result[None, ErrorsList]: + self.saved.append(state) + self.state = state + self.errors = None + return Ok(None) + + +class FakeArtifacts: + def __init__(self, artifact: Artifact) -> None: + self.artifact = artifact + self.loaded: list[tuple[ArtifactId, ArtifactRenderContext]] = [] + self.viewed: list[ArtifactId] = [] + self.executed: list[tuple[ArtifactId, Task, WorkUnit]] = [] + + def load(self, artifact_id: ArtifactId, render_context: ArtifactRenderContext) -> Result[Artifact, ErrorsList]: + self.loaded.append((artifact_id, render_context)) + return Ok(self.artifact) + + def load_for_view(self, artifact_id: ArtifactId) -> Result[Artifact, ErrorsList]: + self.viewed.append(artifact_id) + return Ok(self.artifact) + + def load_for_execution( + self, + artifact_id: ArtifactId, + task: Task, + work_unit: WorkUnit, + ) -> Result[Artifact, ErrorsList]: + self.executed.append((artifact_id, task, work_unit)) + return Ok(self.artifact) + + +class FakePrimitives: + def __init__(self, primitive: Primitive) -> None: + self.primitive = primitive + self.resolved: list[PythonPath] = [] + + def resolve(self, primitive_id: PythonPath) -> Result[Primitive, ErrorsList]: + self.resolved.append(primitive_id) + return Ok(self.primitive) + + +class FakeRuntimeContext: + def __init__(self, *, state: ConsistentState | None, artifact: Artifact, primitive: Primitive) -> None: + self.state = FakeStateStore(state=state) + self.artifacts = FakeArtifacts(artifact) + self.primitives = FakePrimitives(primitive) + self.journal = FakeJournal() + self.output = FakeOutputEmitter() + self.current_work_unit_id: ValueScope[WorkUnitId] = ValueScope() + self.current_operation_id: ValueScope[ArtifactSectionId] = ValueScope() + + +class InstalledContext: + def __init__(self, context: FakeRuntimeContext) -> None: + self.context = context + self._runtime_token: contextvars.Token[Context | None] | None = None + self._machine_token: contextvars.Token[MachineContext | None] | None = None + + def __enter__(self) -> FakeRuntimeContext: + self._runtime_token = set_runtime_context(cast(Context, self.context)) + self._machine_token = set_machine_context(self.context) + return self.context + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + assert self._machine_token is not None + assert self._runtime_token is not None + reset_machine_context(self._machine_token) + reset_runtime_context(self._runtime_token) + + +def installed_context(context: FakeRuntimeContext) -> InstalledContext: + return InstalledContext(context) diff --git a/donna/runtime/tests/test_sessions.py b/donna/runtime/tests/test_sessions.py new file mode 100644 index 00000000..0c94645d --- /dev/null +++ b/donna/runtime/tests/test_sessions.py @@ -0,0 +1,326 @@ +from pytest_mock import MockerFixture + +from donna.core.errors import ErrorsList +from donna.core.result import Ok, Result +from donna.domain.artifact_ids import ArtifactId, ArtifactSectionId +from donna.domain.id_paths import NormalizedRawIdPath +from donna.domain.ids import SectionId +from donna.domain.internal_ids import ActionRequestId, TaskId, WorkUnitId +from donna.domain.python_path import PythonPath +from donna.machine import errors as machine_errors +from donna.machine.action_requests import ActionRequest +from donna.machine.artifacts import Artifact, ArtifactSection +from donna.machine.changes import Change, ChangeAddActionRequest +from donna.machine.operations import OperationKind, OperationMeta +from donna.machine.state import ConsistentState, MutableState +from donna.machine.tasks import Task, WorkUnit +from donna.runtime import sessions +from donna.runtime.tests import make + +ARTIFACT_ID = ArtifactId("@/workflows/runtime.donna.md") +START_SECTION_ID = SectionId("start") +NEXT_SECTION_ID = SectionId("next") +OTHER_SECTION_ID = SectionId("other") +START_OPERATION_ID = ArtifactSectionId("@/workflows/runtime.donna.md:start") +NEXT_OPERATION_ID = ArtifactSectionId("@/workflows/runtime.donna.md:next") +OTHER_OPERATION_ID = ArtifactSectionId("@/workflows/runtime.donna.md:other") +OPERATION_KIND = PythonPath(NormalizedRawIdPath("donna.runtime.tests.test_sessions.operation")) +TASK_ID = TaskId("T-1-b") +WORK_UNIT_ID = WorkUnitId("WU-2-c") +ACTION_REQUEST_ID = ActionRequestId("AR-3-d") + + +class _NoopOperation(OperationKind): + def execute_section( + self, + task: Task, + unit: WorkUnit, + artifact: Artifact, + section_id: SectionId, + ) -> Result[list[Change], ErrorsList]: + return Ok([]) + + +class _RequestActionOperation(OperationKind): + def execute_section( + self, + task: Task, + unit: WorkUnit, + artifact: Artifact, + section_id: SectionId, + ) -> Result[list[Change], ErrorsList]: + request = ActionRequest.build( + title="Choose next", + request="Pick the next operation", + operation_id=unit.operation_id, + ) + return Ok([ChangeAddActionRequest(action_request=request)]) + + +def _operation_section( + *, + id: SectionId, + primary: bool = False, + allowed_transitions: set[SectionId] | None = None, +) -> ArtifactSection: + return ArtifactSection( + id=id, + artifact_id=ARTIFACT_ID, + kind=OPERATION_KIND, + title=f"Operation {id}", + description=f"Description for {id}", + primary=primary, + meta=OperationMeta(allowed_transitions=allowed_transitions or set()), + ) + + +def _artifact() -> Artifact: + return Artifact( + id=ARTIFACT_ID, + sections=[ + _operation_section(id=START_SECTION_ID, primary=True, allowed_transitions={NEXT_SECTION_ID}), + _operation_section(id=NEXT_SECTION_ID), + _operation_section(id=OTHER_SECTION_ID), + ], + ) + + +def _context( + *, + state: ConsistentState | None = None, + primitive: OperationKind | None = None, +) -> make.FakeRuntimeContext: + return make.FakeRuntimeContext( + state=state or MutableState.build().freeze(), + artifact=_artifact(), + primitive=primitive or _NoopOperation(), + ) + + +def _task() -> Task: + return Task.build(id=TASK_ID, workflow_id=START_OPERATION_ID) + + +def _work_unit() -> WorkUnit: + return WorkUnit.build(id=WORK_UNIT_ID, task_id=TASK_ID, operation_id=START_OPERATION_ID) + + +def _action_request(operation_id: ArtifactSectionId = START_OPERATION_ID) -> ActionRequest: + return ActionRequest( + id=ACTION_REQUEST_ID, + title="Choose next", + request="Pick next", + operation_id=operation_id, + ) + + +class TestLoadState: + def test_returns_loaded_state(self) -> None: + state = MutableState.build().freeze() + runtime_context = _context(state=state) + + with make.installed_context(runtime_context): + result = sessions.load_state() + + assert result.is_ok() + assert result.unwrap() == state + assert runtime_context.state.saved == [] + + def test_creates_empty_state_when_session_state_is_missing(self) -> None: + runtime_context = _context() + runtime_context.state.state = None + runtime_context.state.errors = [machine_errors.SessionStateNotInitialized()] + + with make.installed_context(runtime_context): + result = sessions.load_state() + + assert result.is_ok() + loaded_state = result.unwrap() + assert loaded_state == MutableState.build().freeze() + assert runtime_context.state.saved == [loaded_state] + + def test_returns_non_initialization_errors_without_saving(self) -> None: + error = machine_errors.SessionStateChangedExternally() + runtime_context = _context() + runtime_context.state.errors = [error] + + with make.installed_context(runtime_context): + result = sessions.load_state() + + assert result.is_err() + assert result.unwrap_err() == [error] + assert runtime_context.state.saved == [] + + +class TestNewSession: + def test_creates_fresh_state_and_reports_success(self) -> None: + existing_state = MutableState.build() + existing_state.mark_started() + runtime_context = _context(state=existing_state.freeze()) + + with make.installed_context(runtime_context): + result = sessions.new_session() + + assert result.is_ok() + assert runtime_context.state.saved == [MutableState.build().freeze()] + assert runtime_context.journal.records == [{"message": "Created new session state.", "actor_id": None}] + assert result.unwrap()[0].kind == "operation_succeeded" + + +class TestClear: + def test_resets_workspace_session_dir_and_reports_success(self, mocker: MockerFixture) -> None: + reset_dir = mocker.patch("donna.runtime.sessions.workspace_sessions.reset_dir") + + result = sessions.clear() + + assert result.is_ok() + reset_dir.assert_called_once_with() + assert result.unwrap()[0].kind == "operation_succeeded" + + +class TestContinue: + def test_runs_queued_work_until_action_request_and_returns_details(self) -> None: + state = MutableState( + tasks=[_task()], + work_units=[_work_unit()], + action_requests=[], + started=True, + last_id=2, + ) + runtime_context = _context(state=state.freeze(), primitive=_RequestActionOperation()) + + with make.installed_context(runtime_context): + result = sessions.continue_() + + assert result.is_ok() + final_state = runtime_context.state.state + assert final_state is not None + assert final_state.work_units == [] + assert len(final_state.action_requests) == 1 + assert runtime_context.state.saved[-1] == final_state + assert [cell.kind for cell in result.unwrap()] == ["session_state_status", "action_request"] + + +class TestStatus: + def test_returns_current_state_info_cell(self) -> None: + runtime_context = _context() + + with make.installed_context(runtime_context): + result = sessions.status() + + assert result.is_ok() + assert [cell.kind for cell in result.unwrap()] == ["session_state_status"] + + +class TestDetails: + def test_returns_current_state_detail_cells(self) -> None: + state = MutableState( + tasks=[_task()], + work_units=[], + action_requests=[_action_request()], + started=True, + last_id=3, + ) + runtime_context = _context(state=state.freeze()) + + with make.installed_context(runtime_context): + result = sessions.details() + + assert result.is_ok() + assert [cell.kind for cell in result.unwrap()] == ["session_state_status", "action_request"] + + +class TestStartWorkflow: + def test_starts_primary_workflow_operation_runs_it_and_returns_details(self) -> None: + runtime_context = _context(primitive=_RequestActionOperation()) + + with make.installed_context(runtime_context): + result = sessions.start_workflow(ARTIFACT_ID) + + assert result.is_ok() + final_state = runtime_context.state.state + assert final_state is not None + assert final_state.started + assert len(final_state.tasks) == 1 + assert final_state.work_units == [] + assert len(final_state.action_requests) == 1 + assert runtime_context.artifacts.loaded[0][0] == ARTIFACT_ID + assert runtime_context.artifacts.viewed == [ARTIFACT_ID] + assert [cell.kind for cell in result.unwrap()] == ["session_state_status", "action_request"] + + +class TestValidateOperationTransition: + def test_accepts_allowed_transition(self) -> None: + state = MutableState( + tasks=[_task()], + work_units=[], + action_requests=[_action_request()], + started=True, + last_id=3, + ) + runtime_context = _context() + + with make.installed_context(runtime_context): + result = sessions._validate_operation_transition(state, ACTION_REQUEST_ID, NEXT_OPERATION_ID) + + assert result.is_ok() + + def test_rejects_disallowed_transition(self) -> None: + state = MutableState( + tasks=[_task()], + work_units=[], + action_requests=[_action_request()], + started=True, + last_id=3, + ) + runtime_context = _context() + + with make.installed_context(runtime_context): + result = sessions._validate_operation_transition(state, ACTION_REQUEST_ID, OTHER_OPERATION_ID) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, machine_errors.InvalidOperationTransition) + assert error.operation_id == START_OPERATION_ID + assert error.next_operation_id == OTHER_OPERATION_ID + + +class TestCompleteActionRequest: + def test_completes_request_runs_next_operation_and_returns_details(self) -> None: + state = MutableState( + tasks=[_task()], + work_units=[], + action_requests=[_action_request()], + started=True, + last_id=3, + ) + runtime_context = _context(state=state.freeze()) + + with make.installed_context(runtime_context): + result = sessions.complete_action_request(ACTION_REQUEST_ID, NEXT_OPERATION_ID) + + assert result.is_ok() + final_state = runtime_context.state.state + assert final_state is not None + assert final_state.action_requests == [] + assert final_state.work_units == [] + assert len(runtime_context.state.saved) == 2 + assert runtime_context.artifacts.executed[0][0] == ARTIFACT_ID + assert [cell.kind for cell in result.unwrap()] == ["session_state_status"] + + def test_returns_error_for_disallowed_transition_without_saving(self) -> None: + state = MutableState( + tasks=[_task()], + work_units=[], + action_requests=[_action_request()], + started=True, + last_id=3, + ) + runtime_context = _context(state=state.freeze()) + + with make.installed_context(runtime_context): + result = sessions.complete_action_request(ACTION_REQUEST_ID, OTHER_OPERATION_ID) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], machine_errors.InvalidOperationTransition) + assert runtime_context.state.saved == [] diff --git a/donna/skills/__init__.py b/donna/skills/__init__.py index 79fdb740..f3fdd4cb 100644 --- a/donna/skills/__init__.py +++ b/donna/skills/__init__.py @@ -1,4 +1,6 @@ +from donna.skills import entities as entities +from donna.skills import fixtures as fixtures from donna.skills.entities import SkillDocument from donna.skills.fixtures import load_skill_text -__all__ = ["SkillDocument", "load_skill_text"] +__all__ = ["SkillDocument", "entities", "fixtures", "load_skill_text"] diff --git a/donna/skills/entities.py b/donna/skills/entities.py index beec4356..f8abf8de 100644 --- a/donna/skills/entities.py +++ b/donna/skills/entities.py @@ -5,4 +5,4 @@ class SkillDocument(enum.StrEnum): usage = "usage" configuration = "configuration" initialization = "initialization" - artifacts = "artifacts" + workflows = "workflows" diff --git a/donna/skills/fixtures.py b/donna/skills/fixtures.py index ae89ecc7..c8b89d6a 100644 --- a/donna/skills/fixtures.py +++ b/donna/skills/fixtures.py @@ -6,7 +6,7 @@ SkillDocument.usage: "usage.md", SkillDocument.configuration: "configuration.md", SkillDocument.initialization: "initialization.md", - SkillDocument.artifacts: "artifacts.md", + SkillDocument.workflows: "workflows.md", } diff --git a/donna/skills/fixtures/artifacts.md b/donna/skills/fixtures/artifacts.md deleted file mode 100644 index 00d48531..00000000 --- a/donna/skills/fixtures/artifacts.md +++ /dev/null @@ -1,142 +0,0 @@ -# `donna` Artifacts - -Donna artifacts are project files that Donna can discover, render, validate, and execute as workflow input. They are usually Markdown files with the `.donna.md` extension. - -Donna reads artifacts from the project filesystem. It does not mutate project artifacts through artifact commands. Developers and agents edit files directly, then ask Donna to list workflows or validate artifacts. - -## Artifact Locations - -The common artifact areas are: - -- `/workflows`: project-owned workflows. -- `/.agents/donna`: project-local Donna documentation, when present. -- `/.session/donna`: session artifacts and active workflow state. - -Example: - -```text -workflows/polish.donna.md -workflows/rfc/request.donna.md -.session/donna/current_task.donna.md -``` - -Donna sees only `.donna.md` files under directories listed in `donna.toml:workflow_dirs`. - -## List Workflows - -List workflow artifacts: - -```bash -donna -p llm list -``` - -Read artifact source files directly when you need details beyond the workflow introduction. - -## Validate Artifacts - -Validate one artifact: - -```bash -donna -p llm validate '@/workflows/polish.donna.md' -``` - -Validate all visible artifacts: - -```bash -donna -p llm validate --all -``` - -Run validation after creating or editing Donna artifacts. - -## Artifact IDs - -Use `@/` for project-root artifact ids: - -```bash -donna -p llm validate '@/workflows/polish.donna.md' -``` - -Validate multiple specific artifacts by passing multiple ids: - -```bash -donna -p llm validate '@/workflows/polish.donna.md' './workflows/rfc/request.donna.md' -``` - -Artifact path arguments accept root-anchored paths such as `@/workflows/polish.donna.md`, relative paths such as `./workflows/polish.donna.md`, and absolute paths inside the project root. - -## Creating Artifacts - -Create normal documentation as plain Markdown files. A minimal documentation file: - -````markdown -# Example Specification - -This document describes one stable project rule. -```` - -Documentation files are not Donna artifacts and do not need artifact validation. - -## Creating Workflows - -A workflow artifact defines a finite-state machine. The H1 section is a workflow by default. Each H2 section declares one operation. When the H1 config omits `start_operation_id`, Donna starts from the first H2 section. - -Minimal workflow: - -````markdown -# Example Workflow - -This workflow asks the agent to do one thing and finish. - -## Do The Work - -```toml donna -id = "ask_agent" -kind = "donna.lib.request_action" -``` - -Perform the requested change. - -When done, continue with `{{ donna.lib.goto("finish") }}`. - -## Finish - -```toml donna -id = "finish" -kind = "donna.lib.finish" -``` - -The workflow is complete. -```` - -Validate the workflow before running it: - -```bash -donna -p llm validate '@/workflows/example.donna.md' -``` - -Run it: - -```bash -donna -p llm run '@/workflows/example.donna.md' -``` - -## Managing Artifacts - -Use direct file edits to create, update, move, or delete artifact files. Then use Donna to inspect the result. - -Recommended loop: - -1. Edit the artifact source file. -2. Validate the artifact: - -```bash -donna -p llm validate '@/specs/example.donna.md' -``` - -3. If it is a workflow, list it: - -```bash -donna -p llm list -``` - -Keep artifact files concise. Put project-wide explanations in specifications and operational step-by-step instructions in workflows. diff --git a/donna/skills/fixtures/configuration.md b/donna/skills/fixtures/configuration.md index 9a2b47f4..1b78e210 100644 --- a/donna/skills/fixtures/configuration.md +++ b/donna/skills/fixtures/configuration.md @@ -1,96 +1,132 @@ # `donna` Configuration -Donna project configuration lives at: +`donna.toml` tells `donna` where a project's workflow virtual machine stores state, where it discovers workflow artifacts, which fallback Markdown section config values to use, and how to forward workflow journal records. -```text -/donna.toml -``` +The configuration file has three main parts: -The file is created by `donna -p llm init`. Edit it when the project needs workflow source directories, default section settings, or journal forwarding. +- Top-level workspace settings configure schema version, session storage, and artifact discovery. +- `defaults` configures fallback config for Markdown artifact sections. Most projects can omit this section. +- `journal` configures optional forwarding of Donna journal records to an external command. -## Minimal Configuration +The configuration file is TOML with schema version `1`. The presence of `donna.toml` marks a Donna project root. -A default project can use the generated configuration without manual edits. The effective defaults are: +Minimal file: ```toml -session_dir = ".session/donna" -default_section_kind = "donna.lib.text" -default_primary_section_kind = "donna.lib.workflow" -default_primary_section_id = "primary" -workflow_dirs = ["./workflows", "./.session/donna"] - -[journal] +version = 1 ``` -## Session Directory - -`session_dir` points to Donna's temporary session directory. -Donna stores runtime state, action requests, and session-created artifacts there. +If `version` is omitted, Donna treats the configuration as schema version `1`. -Default: +Minimal file (will be generated by `donna init`): ```toml +version = 1 + session_dir = ".session/donna" + +workflow_dirs = [ + "./workflows", + "./.session/donna", +] ``` -Relative paths are resolved from the project root; absolute paths are used as configured. Use a directory ignored by version control unless a project intentionally tracks session artifacts. +Top-level fields: -## Default Sections +- `version`: optional integer, currently `1`. +- `session_dir`: optional relative path to Donna's session directory. +- `workflow_dirs`: optional list of relative directories scanned for `.donna.md` workflow artifacts. +- `defaults`: optional fallback config for Markdown artifact sections. +- `journal`: optional journal forwarding config. -Donna loads artifacts only from `*.donna.md` Markdown files. +Unknown top-level fields are invalid. -When a non-primary section omits `kind`, Donna uses `default_section_kind`. When the primary section omits `kind`, Donna uses `default_primary_section_kind`. When the primary section omits `id`, Donna uses `default_primary_section_id`. +## Session Directory + +`session_dir` points to Donna's temporary session directory. Donna stores runtime state there. ```toml -default_section_kind = "donna.lib.text" -default_primary_section_kind = "donna.lib.workflow" -default_primary_section_id = "primary" +session_dir = ".session/donna" ``` Fields: -- `default_section_kind`: full Python path to the primitive used for sections without an explicit `kind`. -- `default_primary_section_kind`: full Python path to the primitive used for the primary H1 section without an explicit `kind`. -- `default_primary_section_id`: section id assigned to the primary H1 section when it omits `id`. +- `session_dir`: optional relative project path, default `.session/donna`. + +The path is resolved from the Donna project root. + +Use a directory ignored by version control unless the project intentionally tracks session artifacts. ## Workflow Directories `workflow_dirs` controls where Donna searches for workflow artifacts. Donna recursively scans each configured directory and recognizes only files ending with `.donna.md`. -Default: - ```toml -workflow_dirs = ["./workflows", "./.session/donna"] +workflow_dirs = [ + "./workflows", + "./.session/donna", +] ``` -Paths are relative to the Donna project root. Missing directories are ignored, so a project can keep the default list before all of those directories exist. +Fields: + +- `workflow_dirs`: optional list of relative project paths, default `["./workflows", "./.session/donna"]`. + +Each path is resolved from the Donna project root. + +Missing directories are ignored. + +Use narrower directories when you want Donna to ignore unrelated `.donna.md` files elsewhere in the project. + +## Markdown Section Defaults -Example: +Donna artifacts are Markdown files ending with `.donna.md`. Each artifact is split into a primary H1 section and zero or more tail H2 sections. A fenced `toml donna` block configures the section that contains it. + +`defaults` supplies missing config values during Markdown artifact construction: ```toml -workflow_dirs = [ - "./workflows", - "./.session/donna", - "./team-workflows", -] +[defaults] +tail_section_kind = "donna.lib.text" +primary_section_kind = "donna.lib.workflow" +primary_section_id = "primary" ``` -Use narrower directories when you want Donna to ignore unrelated `.donna.md` files elsewhere in the project. +Fields: + +- `tail_section_kind`: default primitive path used for H2 sections without explicit `kind`. +- `primary_section_kind`: default primitive path used for the H1 section without explicit `kind`. +- `primary_section_id`: default section id used for the H1 section without explicit `id`. -## Journal Forwarding +Primitive is a Python object that is used to interpret the content of an artifact section. -`journal.cmd` forwards Donna journal records to an external command. Omit it or set it to `null` to disable forwarding. +Read `donna -p llm skill workflows` for a deeper explanation. -Example: +Explicit section config always wins over these defaults. + +Most projects should not change `defaults`. Change these fields only when a project intentionally uses custom Donna primitives or a different artifact convention. + +## Journal + +`journal.cmd` forwards Donna journal records to an external command. Omit `cmd` to disable forwarding. ```toml [journal] -cmd = ["./bin/taskwarior.sh", "log", "+journal", "+donna", "{message}"] +cmd = [ + "./bin/journal-tool.sh", + "record", + "{timestamp}", + "{actor_id}", + "{current_task_id}", + "{current_operation_id}", + "{message}", +] ``` -The command is configured as a list of arguments. Donna does not run a shell for this command. +Fields: + +- `cmd`: optional non-empty list of command arguments. -Supported placeholders: +Supported whole-argument placeholders: - `{timestamp}`: ISO-8601 record timestamp. - `{actor_id}`: actor that created the record. @@ -101,22 +137,23 @@ Supported placeholders: Invalid placeholder names make configuration loading fail. -Example with explicit fields: +Placeholders are recognized only when the whole argument starts with `{` and ends with `}`. For example, `{message}` is a placeholder, but `message:{message}` is a literal argument. -```toml -[journal] -cmd = [ - "./bin/taskwarior.sh", - "log", - "+journal", - "+donna", - "actor:{actor_id}", - "operation:{current_operation_id}", - "{message}", -] -``` +Donna still prints newly created journal records through the selected output protocol even when `journal.cmd` is omitted. + +## Recommendations + +Keep project-owned workflows in a dedicated workflow directory such as `./workflows`. + +Keep temporary or generated workflows under the configured `session_dir` and include that directory in `workflow_dirs` when those workflows should be executable. + +Do not broaden `workflow_dirs` to the whole repository unless you plan to do some rocket science. In most cases a single specialized directory will enough. + +Keep default section settings unchanged unless the project has custom primitives and a clear convention for using them. + +Configure `journal.cmd` only when the project has a stable journal tool. A failing journal command can make Donna report environment errors during workflow execution. -## Validation Workflow +## Validation After editing `donna.toml`, run: diff --git a/donna/skills/fixtures/initialization.md b/donna/skills/fixtures/initialization.md index 32981dd9..35190ae5 100644 --- a/donna/skills/fixtures/initialization.md +++ b/donna/skills/fixtures/initialization.md @@ -1,61 +1,108 @@ # `donna` Initialization -Initialization creates the Donna project config. Runtime commands create the configured session directory lazily. +`donna init` creates a starter `donna.toml` for a project that does not have one yet. -Use this document when a project has no `donna.toml`. +The generated file is intentionally small. It gives the project a valid schema version, a session directory, workflow discovery directories, and commented examples for optional defaults and journal forwarding. -## What Initialization Creates +Use this document when a configuration file is missing, when adding Donna to a new project, or when checking what initialization should create. -`donna -p llm init` creates: +For configuration syntax details, use: -```text -/donna.toml +```bash +donna -p llm skill configuration ``` -`donna.toml` stores configuration. The configured session directory stores Donna runtime state and session artifacts after runtime commands create it. +For command usage after initialization, use: + +```bash +donna -p llm skill usage +``` + +## Project Root + +Run initialization from the directory that should contain `donna.toml`. + +Without `--config`, `donna init` creates `donna.toml` in the current working directory. With `--config PATH`, it creates the configuration file at that path and treats the containing directory as the project root for later commands. -## Initialize The Current Directory +The project root matters because Donna discovers it by locating `donna.toml`. `session_dir` and `workflow_dirs` are relative to that directory, and Donna reports workflow artifacts as `@/` anchored at that directory. Relative artifact paths passed to commands are resolved from the command's current working directory and accepted only when they point inside the project root. -Run from the directory that should become the project root: +## Create The Starter File + +Create `donna.toml` in the current directory: ```bash donna -p llm init ``` -This command fails if `donna.toml` already exists. +Create it at an explicit config path: + +```bash +donna -p llm --config /path/to/project/donna.toml init +``` + +`init` does not overwrite an existing file. + +Right after creating the starter file, check whether the default workflow discovery directories match the project layout. Ask the developer before changing project structure or introducing new workflow directories. + +## Filling The Configuration -## Initialize Another Directory +Start from the smallest configuration that matches the project. -Pass an explicit root directory: +General workflow: + +1. Check whether the project already has workflow files. +2. Keep `session_dir` unchanged unless the project has an established runtime-state or temporary files location. +3. Keep `workflow_dirs` unchanged when project workflows live in `./workflows` or temporary workflows should live in `./.session/donna`. +4. Add or narrow `workflow_dirs` only when the real workflow layout requires it. +5. Configure `journal.cmd` only when project instructions provide a reliable journal command. + +Do not create workflow files or new project directories during initialization unless the developer explicitly asks for that. `donna init` only creates `donna.toml`; runtime commands create the session directory lazily. + +## First Workflow Files + +Donna discovers Markdown workflow files ending with `.donna.md` under configured workflow directories. + +After initialization, offer the developer to create the first project workflows automatically. Base the proposal on the project configuration, existing scripts, package manifests, CI definitions, test commands, formatter and linter commands, changelog or release files, and specifications or documentation templates. + +Good starter workflows: + +1. Polish code by running autoformatters and linters in order, capturing command output, asking the agent to fix issues, and looping until the code is clean. +2. Run tests, ask the agent to fix failures, and loop until all configured test suites pass. +3. Analyze branch changes and update the changelog or release notes. +4. Prepare a project-specific documentation or specification artifact from an existing template, then review it against its specification until it is complete. +5. Reproduce the project's CI pipeline locally by running the same checks in a deterministic order and routing failures to focused agent repair steps. +6. Verify changed deliverables against a specification, design document, or acceptance criteria, then route missing or incorrect deliverables back to fix steps. +7. Prepare implementation plans from existing RFC, design, issue, or specification documents and save the resulting workflow as a session artifact. +8. Run dependency, generated-file, schema, migration, or documentation-build checks when the project already has stable commands for them. + +Use direct file edits to create or change workflow files. Donna commands inspect, validate, render, and run workflow artifacts; they do not edit project-owned workflow files for you. + +Before adding or changing workflows, use: ```bash -donna -p llm --root /path/to/project init +donna -p llm skill workflows ``` -The target directory must already exist. Donna creates `donna.toml` inside it. - -## First Checks After Initialization +## Validation Loop -Verify the project config can load: +After creating or editing `donna.toml`, verify that Donna can load the project config and initialize session state: ```bash donna -p llm status ``` -List available workflows: +`status` is the right first check because a newly initialized project may have no workflow files yet. + +If the project already has workflows, or if you created workflows during initialization, list visible workflows: ```bash donna -p llm list ``` -Validate artifacts: +Then validate the discovered workflow artifacts: ```bash donna -p llm validate --all ``` -## Agent Guidance - -Initialize Donna only when the developer asks for it or when the task explicitly requires Donna and no `donna.toml` exists. - -Edit project-owned artifacts directly when the developer asks for project-specific behavior changes. +If `list` returns no workflows, that can be valid for a newly initialized project. Check `workflow_dirs`, directory existence, and `.donna.md` suffixes only when workflows were expected. When configuration loading fails, fix `donna.toml` before continuing with workflow work. When validation fails, fix the workflow source files before running them. diff --git a/donna/skills/fixtures/usage.md b/donna/skills/fixtures/usage.md index 1146b976..a40764f1 100644 --- a/donna/skills/fixtures/usage.md +++ b/donna/skills/fixtures/usage.md @@ -1,169 +1,322 @@ # `donna` Usage -Donna is a CLI tool for orchestrating AI-agent work with project-local workflows, artifacts, and session state. +`donna` is a command line tool that helps agents run predefined workflows in a deterministic way. -Use this document as the first reference for command usage. For narrower topics, use: +Treat Donna as a small workflow virtual machine for agents. A workflow is a state machine stored in project-local Donna artifacts. Donna maintains the active session state, runs workflow operations, asks the agent to apply judgment or perform work when fully deterministic execution is not enough, and waits for the agent to report which operation to run next. -- `donna skill configuration` for `donna.toml`. -- `donna skill initialization` for creating or checking Donna project files. -- `donna skill artifacts` for artifact layout, discovery, and authoring rules. -- `donna skill usage` for this command overview. +Donna does not replace the agent. The agent is still responsible for reading project instructions, doing the requested work, running tools, making code changes, and reporting results. Donna serves the control-flow role: it keeps the workflow path explicit, validated, resumable, and less dependent on the agent remembering every process step. -## Project Root - -Donna works inside a project root. If `--root/-r` is omitted, commands that load a project discover the project root by searching upward from the current directory for `donna.toml`. +## This Documentation -Use `--root PATH` when running Donna from outside the project tree or when targeting a specific project: +This output is built-in skill-style documentation printed by: ```bash -donna -p llm --root /path/to/project status +donna -p llm skill usage ``` -`donna skill ...` does not load a project config and can run from any directory. +Use it as the first reference for agent-side command usage in a session. Use the other built-in skill documents for narrower tasks: + +- `donna -p llm skill configuration` explains `donna.toml`. +- `donna -p llm skill initialization` explains project initialization. +- `donna -p llm skill workflows` explains Donna workflow format, layout, execution and creation best practices. +- `donna -p llm skill usage` prints this document. ## Output Protocols Donna supports three protocol modes: -- `llm`: structured cells optimized for agents. -- `human`: compact terminal output for people. -- `automation`: output intended for programs. +- `llm`: structured cell output intended for agents. +- `human`: compact terminal output intended for people. +- `automation`: JSON Lines output intended for programs. + +Use `llm` when invoking `donna` as a coding agent. It is the normal choice for this documentation's examples. + +Use `human` for compact terminal inspection by a person. + +Use `automation` when an agent or another program needs automatic processing of Donna output. Automation output is JSON Lines: each stdout line is one JSON object representing one Donna output cell or journal record. + +Example automation command: + +```bash +donna -p automation list +``` + +Example automation output: + +```jsonl +{"artifact_id":"@/workflows/polish.donna.md","artifact_kind":"donna.lib.workflow","artifact_title":"Polishing Workflow","content":"Initiate operations to polish and refine the codebase.","id":"WxVlGyfwTs-vnaTh6c46ww"} +{"artifact_id":"@/workflows/rfc/request.donna.md","artifact_kind":"donna.lib.workflow","artifact_title":"Create a Request for Change","content":"This workflow creates a Request for Change document.","id":"gPY_FHlISKu7HOphsyj-kQ"} +``` + +Global options go before the subcommand: -Agents should use `-p llm` for normal Donna workflow commands: +```bash +donna -p llm --config /path/to/project/donna.toml status +``` + +## Project Root + +Most commands need a Donna project. If `--config` is omitted, Donna discovers the project root by searching upward from the current working directory for `donna.toml`. + +Use `--config PATH` when running from outside the intended project tree or when there is any ambiguity. The path points to the active `donna.toml`; the project root is the directory that contains it: + +```bash +donna -p llm --config /path/to/project/donna.toml list +``` + +Donna uses project-root anchored ids for workflow artifacts. The `@/` prefix means "from the Donna project root": + +```bash +donna -p llm run @/workflows/some-workflow.donna.md +``` + +Relative artifact paths passed to CLI commands are resolved from the process current working directory, then normalized to project-root anchored ids. Absolute artifact paths are accepted only when they point inside the Donna project root. Prefer `@/` paths in agent notes and workflow instructions because they are independent of the current working directory. + +Artifact section ids append `:section_id` to an artifact id: + +```bash +donna -p llm complete-action-request AR-12-x @/.session/donna/workflow.donna.md:next_step +``` + +`donna skill ...` and `donna init` do not require an existing Donna project config. + +## Agent Safety Rules + +1. Read the project's own agent instructions before using Donna. +2. Use `-p llm` unless a human explicitly asks for human output or a program needs automation output. +3. Run `status` before deciding what to do with a session. +4. If Donna says the session is awaiting your action, address the pending action requests before unrelated work. +5. If the developer asks for new work while Donna has pending work units or action requests, ask whether to continue the current session or start a new one. +6. Run workflows only when explicitly instructed by the developer, project instructions, or Donna. +7. Use the action request id and next operation id exactly as Donna provides them. Do not invent operation ids. +8. Do not run `new-session` unless you understand the consequence and the developer or project instructions allow it. + +## Usage Patterns + +### Workflow Execution + +Workflow execution means Donna drives operations until the workflow finishes, reports an error, or asks the agent to perform work. The command that starts workflow execution is: + +```bash +donna -p llm run @/path/to/workflows/.donna.md +``` + +`run` starts the workflow in the current session. Donna then executes workflow operations until it needs agent work, finishes, or reports an error. Some operations may take time because they run scripts, validators, formatters, tests, or other tools. Wait for the command to finish and read all emitted cells before deciding what to do next. + +When Donna emits an action request, perform the requested work exactly. Action requests include an id and allowed next operation choices. After finishing the requested work, report the action request completion with the id and next operation that matches the result: + +```bash +donna -p llm complete-action-request AR-12-x @/.session/donna/workflow.donna.md:next_step +``` + +`complete-action-request` removes the action request, queues the selected next operation, and continues workflow execution immediately. It may finish the workflow, emit another action request, or run more deterministic operations before returning. + +The workflow is finished when Donna emits the workflow's finish message, when `status` says the session is idle with no active tasks, or when Donna explicitly tells you to report back: ```bash donna -p llm status ``` -The root option goes before the command: +When the workflow is finished, report the result to the developer before starting unrelated work. + +### Nested Workflows + +Donna can have multiple active workflows in one session. Treat them as a call stack: starting a workflow while another workflow is active makes the new workflow current; finishing it returns control to the parent workflow. + +A parent action request may instruct you to run a child workflow. In that case: + +1. Start the child workflow with `donna -p llm run ...`. +2. Complete the child workflow before completing the parent action request. +3. Do not report the parent action request as complete just because the child workflow started. +4. After the child finishes, return to the parent action request and choose one of its declared transitions. +5. Keep child workflow outputs explicit: write files, update notes, or summarize results before completing the parent request. + +### Start Workflow + +A developer or project instruction explicitly asks you to run a Donna workflow, and Donna has no active work that must be continued first. + +Read project instructions and this usage document if you have not already: ```bash -donna -p llm --root /path/to/project list +donna -p llm skill usage ``` -## Skill Documents +Inspect the current session before starting anything: -The `skill` command prints built-in agent documentation as plain Markdown. It does not require an initialized Donna project. +```bash +donna -p llm status +``` + +If Donna reports pending work units or action requests, do not start unrelated work silently. Ask whether to continue the current session or start a new one. -Examples: +List available workflows if you have not been given a specific workflow to run: ```bash -donna skill usage -donna skill configuration -donna skill initialization -donna skill artifacts +donna -p llm list ``` -Use these documents when an agent needs stable instructions before `donna.toml` exists or when synced artifacts are not available. +Run the selected workflow, then follow the workflow execution rules above: -## Project Commands +```bash +donna -p llm run @/path/to/workflows/.donna.md +``` -Workspace commands create or check Donna project configuration. +### Continue Workflow -Initialize Donna in the current directory: +Donna has queued work units, pending action requests, a workflow command tells you to continue, or the developer asks you to continue the current Donna workflow. + +Inspect the current session: ```bash -donna -p llm init +donna -p llm status +``` + +Continue queued workflow execution: + +```bash +donna -p llm continue +``` + +Strictly follow every instruction Donna gives you after that. If Donna finishes the workflow or tells you to report back, do so before doing anything else. + +### Creating New Workflow + +A developer or an active Donna workflow asks you to create or modify a workflow. + +Read the workflow creation instructions if you have not already: + +```bash +donna -p llm skill workflows ``` -Initialize Donna in an explicit existing directory: +Create or edit the workflow source file directly. Choose the target location by this priority: + +1. Use the explicitly specified filepath when the developer, project instructions, or parent workflow provides one. +2. For a temporary workflow, use a directory configured for temporary files or session artifacts. +3. For a temporary workflow if no directory for temporary files or session artifacts is configured, use the donna session directory, you can find it in `donna.toml`. +4. For a permanent workflow, use one of the directories where project-owned workflows are stored. Check `donna.toml` when the project-owned workflow directories are not obvious. + +Validate the workflow: ```bash -donna -p llm --root /path/to/project init +donna -p llm validate @/workflows/example.donna.md +``` + +If the workflow should be discoverable, list workflows and confirm it appears with the expected summary: + +```bash +donna -p llm list ``` ## Session Commands -All workflow execution happens in the active session. Session state lives under the configured session directory, `.session/donna` by default. +All workflow execution happens in the active session. Session state and session-created artifacts live under the configured session directory. -Start a new session: +Donna automatically creates empty session state when a session-aware command needs it and no session state exists. + +Create a fresh session: ```bash -donna -p llm start +donna -p llm new-session ``` -Starting a session resets session state and removes session artifacts. Only start a new session when the developer asks for it or when no active session exists. +`new-session` creates or **resets** Donna session state. Use it only when starting fresh work is intended. -Show concise status: +Show concise session status, including whether Donna is idle, has queued work units, or is awaiting action: ```bash donna -p llm status ``` -Show detailed session state and action requests: +Show detailed session state and pending action requests: ```bash donna -p llm details ``` -Continue queued workflow execution: +Continue queued workflow execution and emit the next action request if a workflow reaches one: ```bash donna -p llm continue ``` -Run a workflow artifact: +Run a workflow artifact in the current session: ```bash donna -p llm run @/workflows/polish.donna.md ``` -Complete an action request by passing its id and the next operation id exactly as Donna instructed: +Complete an action request: ```bash donna -p llm complete-action-request AR-12-x @/.session/donna/workflow.donna.md:next_step ``` -## Artifact Commands +The first argument is the action request id. The second argument is an artifact section id in `artifact:section` form. Prefer copying the exact completion command or next operation id from Donna's action request output. -Artifacts are `*.donna.md` project files under Donna's configured `workflow_dirs`. Agents use artifacts to discover workflows, read documentation, and validate Donna-readable files. +## Workflow Commands -List workflows: +Donna workflows are `*.donna.md` files discovered under the configured `workflow_dirs`. Workflow ids are project-root anchored paths such as `@/workflows/polish.donna.md`. Section ids append `:section_id`, for example `@/workflows/polish.donna.md:finish`. + +List available workflows and their summaries: ```bash donna -p llm list ``` -Validate all visible artifacts: +Render a workflow to debug how Donna sees it: + +```bash +donna -p llm render @/workflows/polish.donna.md --mode view +``` + +Use `render` when a workflow does not validate, an operation displays unexpected instructions, or a directive does not behave as expected. + +Render modes: + +- `view`: Use to see how Donna will render workflow instructions to the agent. This is the safest mode for inspecting the workflow text and generated agent-facing instructions. +- `execute`: Use when debugging the exact operation text Donna would execute in the current session context. This mode may fail when the workflow references data that is expected to exist on the current session stack. +- `analysis`: Use when inspecting machine-readable directive behavior, workflow transitions, and validation-related rendering. + +Validate all discovered workflows: ```bash donna -p llm validate --all ``` -Validate specific artifacts by project-root or relative path: +Validate specific workflows: ```bash -donna -p llm validate '@/workflows/polish.donna.md' +donna -p llm validate @/workflows/polish.donna.md ``` -## Normal Agent Flow +Workflow arguments accept root-anchored ids, relative paths, and absolute paths inside the project root. Prefer root-anchored ids in notes and agent instructions because they remain stable when the current directory changes. -1. Read project instructions and `donna skill usage`. -2. Check session state: +## Project Commands + +Initialize Donna in the current directory: ```bash -donna -p llm status +donna -p llm init ``` -3. If there is no active work and a workflow is needed, list workflows: +Initialize Donna in an explicit directory: ```bash -donna -p llm list +donna -p llm --config /path/to/project/donna.toml init ``` -4. Start the selected workflow: +`init` creates or refreshes Donna project configuration. Read `donna -p llm skill initialization` and project instructions before using it in an existing repository. + +Print the installed Donna package version: ```bash -donna -p llm run @/workflows/polish.donna.md +donna version ``` -5. Execute Donna action requests exactly. -6. Report completion with `complete-action-request`. -7. Continue until Donna finishes the workflow. - ## Journal Forwarding -Donna creates internal journal records for workflow events. To forward them to another tool, configure `[journal].cmd` in `/donna.toml`. +Donna creates internal journal records for significant workflow events. Projects can forward those records to another tool by configuring `journal.cmd` in `donna.toml`. Example: @@ -173,3 +326,5 @@ cmd = ["./bin/taskwarior.sh", "log", "+journal", "+donna", "{message}"] ``` Supported placeholders are `timestamp`, `actor_id`, `message`, `current_task_id`, `current_work_unit_id`, and `current_operation_id`. + +When no journal command is configured, Donna still prints newly created internal journal records through the selected protocol formatter. diff --git a/donna/skills/fixtures/workflows.md b/donna/skills/fixtures/workflows.md new file mode 100644 index 00000000..6ff90aab --- /dev/null +++ b/donna/skills/fixtures/workflows.md @@ -0,0 +1,617 @@ +# `donna` Workflows + +Donna workflows are Markdown artifacts that describe finite-state machines for agent work. + +Use this document when you need to create, review, debug, or change a `*.donna.md` workflow file. + +A workflow does not do the agent's job. It defines control flow: + +- which operation starts the work; +- which deterministic steps Donna can run by itself; +- where Donna must stop and ask the agent to act; +- which next operations are valid after each stop; +- when the workflow is complete. + +The agent still reads project instructions, edits files, runs tools, uses judgment, and reports results. Donna keeps the process explicit, resumable, and validated. + +## Workflow Files + +Donna discovers workflow artifacts under the directories configured in `donna.toml:workflow_dirs`. For configuration details, defaults, and directory selection guidance, read `donna -p llm skill configuration`. + +Only files ending with `.donna.md` are workflow artifacts. + +Relative and absolute paths are accepted by Donna CLI commands when they resolve inside the Donna project root, but `@/` paths are stable from any current working directory. + +## File Shape + +A workflow file is one Markdown document with exactly one H1 section followed by zero or more H2 sections. + +The H1 section is the workflow section. Each H2 section is usually one workflow operation. + +## IDs + +Donna uses three related ids when working with workflows. + +A workflow artifact id identifies the workflow file. Use project-root anchored artifact ids in workflow instructions and notes: + +```text +@/workflows/do-work.donna.md +@/.session/donna/plans/implement-feature.donna.md +``` + +A section id identifies one section inside a workflow artifact. For workflow operations, it is the `id` field in the operation's config block: + +```toml +id = "finish" +``` + +A full artifact section id identifies one section in one artifact by appending `:` to the artifact id: + +```text +@/workflows/do-work.donna.md:finish +``` + +Use full artifact section ids when completing action requests. Use local section ids in workflow config fields and `goto` directives inside the same workflow. + +## Minimal Workflow + +````markdown +# Example Workflow + +This workflow checks the current time, asks the agent whether it is tea time, and branches on the answer. + +## Get Current Time + +```toml donna +id = "get_current_time" +kind = "donna.lib.run_script" +save_stdout_to = "current_time" +goto_on_success = "ask_about_tea" +goto_on_failure = "finish" +``` + +```bash donna script +#!/usr/bin/env bash +date +%H:%M +``` + +## Ask About Tea + +```toml donna +id = "ask_about_tea" +kind = "donna.lib.request_action" +``` + +The current time is: + +```text +{{ donna.lib.task_variable("current_time") }} +``` + +Is it time to drink tea? + +1. If yes, `{{ donna.lib.goto("turn_on_kettle") }}`. +2. If no, `{{ donna.lib.goto("finish") }}`. + +## Turn On Kettle + +```toml donna +id = "turn_on_kettle" +kind = "donna.lib.request_action" +``` + +Turn on the kettle, then `{{ donna.lib.goto("finish") }}`. + +## Finish + +```toml donna +id = "finish" +kind = "donna.lib.finish" +``` + +The workflow is complete. Report the result to the developer. +```` + +## Markdown Parsing Rules + +Donna's Markdown parser uses only H1 and H2 headings as section boundaries. + +- The first heading must be H1. +- There can be only one H1. +- H2 starts a tail section. +- H3 and deeper headings stay inside the current H1 or H2 section body. +- Text before the first H1 is invalid. + +Donna removes `donna` config and script code fences from the rendered section description. Ordinary code fences remain visible to the agent. + +Each section may have at most one config block. A config block is a fenced code block marked with `donna`; `toml donna` is treated as `toml donna config`. + +````markdown +```toml donna +id = "operation_id" +kind = "donna.lib.request_action" +``` +```` + +The parser supports TOML, JSON, YAML, and YML config formats, but write workflow config as TOML unless the project has a clear reason to do otherwise. + +Script blocks are fenced code blocks marked with both `donna` and `script`: + +````markdown +```bash donna script +#!/usr/bin/env bash +echo "hello" +``` +```` + +A `donna.lib.run_script` operation must have exactly one script block. + +## Section Config + +All section config is strict. Unknown fields are invalid. + +Common fields: + +- `id`: local section id. The H1 defaults to `primary`; H2 sections without `id` get generated unstable ids. Set explicit ids for all operation sections. +- `kind`: primitive path that tells Donna how to interpret the section. The H1 defaults to `donna.lib.workflow`; H2 sections default to `donna.lib.text`. +- `fsm_mode`: optional operation mode. Valid values are `start`, `normal`, and `final`. The default is `normal`. + +Section ids may contain ASCII letters, digits, underscores, hyphens, and dots. Use lowercase snake case for readability: + +```toml +id = "review_changes" +``` + +Do not rely on `fsm_mode = "start"` to choose the start operation. Donna starts from the H1 `start_operation_id` when it is set, otherwise from the first H2 section. `fsm_mode = "start"` is metadata. + +## Workflow Section + +The H1 section is normally a `donna.lib.workflow` section. It describes the whole workflow and optionally chooses the first operation. + +````markdown +```toml donna +id = "primary" +kind = "donna.lib.workflow" +start_operation_id = "start" +``` +```` + +Fields: + +- `id`: optional, defaults to `primary`. +- `kind`: optional, defaults to `donna.lib.workflow`. +- `start_operation_id`: optional local id of the operation to run first. + +If `start_operation_id` is omitted, Donna uses the first H2 section. If the workflow has no H2 section and no explicit start operation, validation fails. + +Write the H1 body as a concise summary. It is what `donna -p llm list` shows for the workflow. + +## Operation Sections + +Each operation is an H2 section with a config block. The H2 title becomes the operation title. The body becomes the operation instructions, output text, or script host depending on `kind`. + +Standard operation kinds are: + +- `donna.lib.request_action`: stop and ask the agent to do work. +- `donna.lib.run_script`: run a deterministic shell script from the project root. +- `donna.lib.output`: print information, then continue automatically. +- `donna.lib.finish`: print final information and finish the workflow task. + +`donna.lib.text` is not an operation. It can be used for unreachable notes in an artifact, but a reachable workflow section must be an operation or validation fails. + +### `request_action` + +Use `donna.lib.request_action` when the next step needs agent judgment, file edits, research, tool use, or communication with the developer. + +````markdown +```toml donna +id = "review_changes" +kind = "donna.lib.request_action" +``` +```` + +Donna emits the section body as an action request and waits. The agent must complete the request by choosing one of the transitions declared in the action request body. + +Declare transitions with `{{ donna.lib.goto("") }}`: + +```markdown +1. Review the changes. +2. If fixes are needed, `{{ donna.lib.goto("fix_changes") }}`. +3. If no fixes are needed, `{{ donna.lib.goto("finish") }}`. +``` + +Each `goto` declares an allowed transition from the current request action to the target operation. + +Rules: + +- A reachable `request_action` operation must contain at least one `goto`. +- `request_action` cannot use `fsm_mode = "final"`. +- The next operation passed to `complete-action-request` must be one of the operation's declared `goto` targets. +- Use explicit result-based choices in the text so the agent knows which transition to select. + +### `run_script` + +Use `donna.lib.run_script` for deterministic checks or commands that Donna can run without agent judgment. + +````markdown +## Run Tests + +```toml donna +id = "run_tests" +kind = "donna.lib.run_script" +save_stdout_to = "test_stdout" +save_stderr_to = "test_stderr" +goto_on_success = "finish" +goto_on_failure = "fix_tests" +timeout = 120 +``` + +```bash donna script +#!/usr/bin/env bash +./bin/test.sh +``` +```` + +Fields: + +- `goto_on_success`: required operation id for exit code `0`. +- `goto_on_failure`: required fallback operation id for non-zero exit codes. +- `goto_on_code`: optional TOML table mapping specific non-zero exit codes to operation ids. +- `save_stdout_to`: optional task-context key for stdout. +- `save_stderr_to`: optional task-context key for stderr. +- `timeout`: optional timeout in seconds, default `60`. +- `fsm_mode`: optional, default `normal`. + +Example with exit-code-specific routing: + +```toml +goto_on_success = "finish" +goto_on_failure = "fix_failure" + +[goto_on_code] +"124" = "fix_timeout" +"2" = "fix_usage" +``` + +Exit code `0` must not be placed in `goto_on_code`; use `goto_on_success`. + +Execution behavior: + +- Donna writes the script to a temporary executable file. +- The script runs from the Donna project root. +- The process inherits the environment. +- Stdin is closed. +- Stdout and stderr are captured. +- A timeout returns exit code `124`. +- Donna queues the selected next operation automatically. + +Script output is not automatically shown to the agent. Save it to task context and read it with `donna.lib.task_variable` in a later request action. + +### `output` + +Use `donna.lib.output` when Donna should print information and then continue automatically. + +````markdown +```toml donna +id = "show_context" +kind = "donna.lib.output" +next_operation_id = "next_step" +``` +```` + +Fields: + +- `next_operation_id`: required operation id to queue after printing the section body. +- `fsm_mode`: optional, default `normal`. + +A reachable `output` operation must have `next_operation_id`. + +### `finish` + +Use `donna.lib.finish` for the terminal operation. + +````markdown +```toml donna +id = "finish" +kind = "donna.lib.finish" +``` +```` + +`finish` is always final. It prints the section body and completes the active workflow task. It must not have outgoing transitions. + +There may be multiple `finish` operations. + +Use the finish body to tell the agent what to report: + +```markdown +Workflow completed. Report the files changed, verification performed, and remaining risks. +``` + +## Transitions And Validation + +Donna validates the reachable operation graph starting from the workflow start operation. + +An operation is reachable when it is the start operation or it can be reached through outgoing transitions from another reachable operation. + +Outgoing transitions come from operation metadata: + +- `request_action`: every `{{ donna.lib.goto("...") }}` in the analyzed request body. +- `run_script`: `goto_on_success`, `goto_on_failure`, and all `goto_on_code` values. +- `output`: `next_operation_id`. +- `finish`: no transitions. + +Validation rules for the reachable graph: + +- Every reachable section must exist. +- Every reachable non-workflow section must be an operation. +- Every reachable non-final operation must have at least one outgoing transition. +- Every reachable final operation must have no outgoing transitions. + +Primitive-specific validation also runs for all sections. + +Run validation after every workflow edit: + +```bash +donna -p llm validate @/workflows/example.donna.md +``` + +Render the workflow when transitions or directives look wrong: + +```bash +donna -p llm render @/workflows/example.donna.md --mode analysis +``` + +## Execution Notes + +`donna -p llm run @/path/to/workflow.donna.md` starts the workflow in the current session. + +Execution loop: + +1. Donna loads the artifact and starts at the primary workflow section. +2. The workflow section queues the start operation. +3. Donna executes queued work units for the current task. +4. Deterministic operations can queue the next operation immediately. +5. `request_action` creates an action request and pauses. +6. The agent performs the request and calls `complete-action-request` with the chosen next operation id. +7. Donna validates that the transition is allowed, queues the next operation, and continues. +8. `finish` completes the task. + +## Workflow Stack And Child Workflows + +Donna can have multiple active workflows in one session. Treat them as a call stack: + +- The latest started workflow is the current workflow. +- Starting a workflow while another workflow is active pushes a child workflow on top of the stack. +- Finishing the child workflow pops it from the stack. +- After the child workflow finishes, the parent workflow becomes current again. + +A child workflow may be started by the developer, by the agent while following a parent action request, or by future Donna operations. This is useful when a parent workflow delegates a substantial subtask to a specialized workflow. + +The primary way to start a child workflow from a parent workflow is to ask the agent in a `request_action` operation. There are two common ways to write that request: + +- Specify the workflow path explicitly when the parent workflow requires one exact child workflow. +- Specify the task to complete and ask the agent to choose the best workflow for it. + +Prefer task-based delegation when possible. It allows dynamic behavior: the parent workflow depends on the child workflow's role, not on one concrete workflow artifact. This works like a simple form of polymorphism. + +Example with an explicit child workflow: + +```markdown +1. Run the workflow `@/workflows/prepare-release-notes.donna.md`. +2. Complete that child workflow. +3. Return to this action request. +4. If release notes are ready, `{{ donna.lib.goto("verify_release") }}`. +5. If they are blocked, `{{ donna.lib.goto("handle_release_notes_blocker") }}`. +``` + +Example with task-based child workflow selection: + +```markdown +1. Choose the best workflow for preparing release notes. +2. Run that workflow and complete it. +3. Return to this action request. +4. If release notes are ready, `{{ donna.lib.goto("verify_release") }}`. +5. If they are blocked, `{{ donna.lib.goto("handle_release_notes_blocker") }}`. +``` + +## Directives And Rendering + +Donna renders workflow Markdown with Jinja directives. + +Directives have two author-facing uses: + +- Structural directives define workflow structure that is not convenient to express with static config alone. +- Informational directives insert meaningful runtime information into text shown to the agent. + +Donna normally renders text for the agent in `view` mode. Other render modes exist, but they are implementation details and should not affect how you write normal workflows. + +Workflow authors may use regular Jinja2 constructs such as variables, conditionals, loops, and macros to produce complex agent-facing text. + +Use view rendering when debugging what an agent will see: + +```bash +donna -p llm render @/workflows/example.donna.md --mode view +``` + +Do not use templates that add or remove headings, config blocks, or operation ids depending on render context. Workflow structure should stay stable after rendering. + +### `goto` + +`{{ donna.lib.goto("next_operation") }}` declares an allowed transition from a `request_action` operation to another operation in the same workflow. + +Use `goto` only in `request_action` instructions. The visible rendered text tells the agent how to complete the action request with that next operation. + +Example: + +```markdown +1. If the check passed, `{{ donna.lib.goto("finish") }}`. +2. If the check failed, `{{ donna.lib.goto("fix_issue") }}`. +``` + +The argument is a local section id, not a full artifact section id. + +### `task_variable` + +`{{ donna.lib.task_variable("name") }}` inserts a value saved in the current task context. + +Use it to show the agent output captured by earlier automated operations, especially `run_script` operations with `save_stdout_to` or `save_stderr_to`. + +Example: + +````markdown +```text +{{ donna.lib.task_variable("test_stdout") }} +``` +```` + +Use the same key that the earlier operation saved: + +```toml +save_stdout_to = "test_stdout" +``` + +## Creating Workflows + +Start with the workflow's control-flow shape, then fill operation instructions. + +Recommended process: + +1. Name the workflow by its outcome, not by a generic process label. +2. Write the H1 summary that `donna list` should show. +3. Add one operation per meaningful state or decision point. +4. Prefer `run_script` for deterministic checks and `request_action` for agent judgment. +5. Add a `finish` operation(s) with clear reporting instructions. +6. Validate the workflow. + +Good operation titles stand alone: + +- `Run Unit Tests` +- `Fix Formatter Output` +- `Review Deliverables` +- `Check Migration File` + +Avoid titles that only make sense by order: + +- `Step 1` +- `Part Two` +- `Continue` +- `Do It` + +Keep request actions specific. Each request should have enough context for the agent to act and enough transition choices to continue correctly. + +Split large request actions when they mix unrelated work. A good split is usually: + +- research or inspect; +- implement one bounded change; +- verify the changed behavior; +- decide where to go next. + +Remember, you can add references to project-specific specifications in the action request text — no need to repeat all documentation there. Keep action requests short and concise. + +## Workflow Design Patterns + +Linear workflow: + +```text +start -> do_work -> verify -> finish +``` + +Retry loop: + +```text +run_check -> fix_check -> run_check +run_check -> finish +``` + +Review gate: + +```text +implement -> review +review -> implement +review -> finish +``` + +Decision tree: + +```text +classify_request -> simple_path +classify_request -> complex_path +classify_request -> ask_for_clarification +simple_path -> finish +complex_path -> plan_work +ask_for_clarification -> classify_request +``` + +Classification tree: + +```text +check_outlook -> class_play_tennis +check_outlook -> check_humidity +check_outlook -> check_wind +check_humidity -> class_stay_home +check_humidity -> class_play_tennis +check_wind -> class_stay_home +check_wind -> class_play_tennis +class_play_tennis -> finish +class_stay_home -> finish +``` + +Script with manual repair: + +```text +run_script success -> next_step +run_script failure -> fix_failure +fix_failure -> run_script +``` + +Use loops deliberately. The operation that loops back should say what changed and why rerunning the earlier operation is necessary. + +## Debugging Workflows + +If a workflow is not listed: + +1. Check that the file ends with `.donna.md`. +2. Check that it is under one of `workflow_dirs`. +3. Check that the workflow directory exists. +4. Run `donna -p llm list` from the intended Donna project root or pass `--config /path/to/project/donna.toml`. + +If validation says a start operation is missing: + +1. Check `start_operation_id` in the H1 config. +2. Check the target H2 `id`. +3. If no explicit start is set, check that the workflow has at least one H2 section. + +If validation says an operation has no outgoing transitions: + +1. For `request_action`, add `{{ donna.lib.goto("next") }}` in the request body. +2. For `run_script`, set `goto_on_success` and `goto_on_failure`. +3. For `output`, set `next_operation_id`. +4. For terminal operations, use `kind = "donna.lib.finish"`. + +If an action request cannot transition to the chosen operation: + +1. Use one of the next operation ids shown in the action request. +2. Check that the `goto` target is local to the same workflow artifact. +3. Re-render in `analysis` mode to confirm Donna detected the transition. + +If a script failure output is missing from a later request action: + +1. Check `save_stdout_to` and `save_stderr_to` keys. +2. Check that `task_variable` uses the exact same key. +3. Check that the operation reading the variable runs after the script operation. + +## Workflow Checklist + +Before considering a workflow ready: + +1. The file is under a configured workflow directory and ends with `.donna.md`. +2. The H1 summary explains the workflow outcome. +3. Every operation H2 has a stable `id` and explicit `kind`. +4. The start operation is intentional. +5. Every non-final reachable operation has an outgoing transition. +6. Every final operation is `donna.lib.finish` or has `fsm_mode = "final"` with no transitions. +7. Every `request_action` gives the agent clear completion choices. +8. Every `run_script` has success and failure routing. +9. Script output needed by the agent is saved and later read from task context. +10. `donna -p llm validate ` succeeds. +11. `donna -p llm render --mode view` is readable by an agent. diff --git a/donna/skills/tests/__init__.py b/donna/skills/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/donna/skills/tests/test_entities.py b/donna/skills/tests/test_entities.py new file mode 100644 index 00000000..000d3369 --- /dev/null +++ b/donna/skills/tests/test_entities.py @@ -0,0 +1,11 @@ +from donna.skills import SkillDocument + + +class TestSkillDocument: + def test_values__match_public_document_names(self) -> None: + assert [document.value for document in SkillDocument] == [ + "usage", + "configuration", + "initialization", + "workflows", + ] diff --git a/donna/skills/tests/test_fixtures.py b/donna/skills/tests/test_fixtures.py new file mode 100644 index 00000000..cef40ae6 --- /dev/null +++ b/donna/skills/tests/test_fixtures.py @@ -0,0 +1,29 @@ +import importlib.resources + +import pytest + +from donna.skills import SkillDocument, load_skill_text + +EXPECTED_DOCUMENT_HEADINGS = { + SkillDocument.usage: "# `donna` Usage", + SkillDocument.configuration: "# `donna` Configuration", + SkillDocument.initialization: "# `donna` Initialization", + SkillDocument.workflows: "# `donna` Workflows", +} + + +class TestLoadSkillText: + def test_default_document_is_usage(self) -> None: + assert load_skill_text() == load_skill_text(SkillDocument.usage) + + @pytest.mark.parametrize(("document", "heading"), EXPECTED_DOCUMENT_HEADINGS.items()) + def test_loads_document_text(self, document: SkillDocument, heading: str) -> None: + assert load_skill_text(document).startswith(f"{heading}\n") + + +def test_fixture_resources__match_public_document_names() -> None: + fixture_dir = importlib.resources.files("donna.skills").joinpath("fixtures") + + fixture_names = sorted(resource.name for resource in fixture_dir.iterdir() if resource.name.endswith(".md")) + + assert fixture_names == sorted(f"{document.value}.md" for document in SkillDocument) diff --git a/donna/workspaces/__init__.py b/donna/workspaces/__init__.py index e69de29b..0028d528 100644 --- a/donna/workspaces/__init__.py +++ b/donna/workspaces/__init__.py @@ -0,0 +1,25 @@ +from donna.workspaces import artifacts as artifacts +from donna.workspaces import config as config +from donna.workspaces import errors as errors +from donna.workspaces import files as files +from donna.workspaces import initialization as initialization +from donna.workspaces import journal as journal +from donna.workspaces import markdown as markdown +from donna.workspaces import markdown_parser as markdown_parser +from donna.workspaces import paths as paths +from donna.workspaces import sessions as sessions +from donna.workspaces import templates as templates + +__all__ = ( + "artifacts", + "config", + "errors", + "files", + "initialization", + "journal", + "markdown", + "markdown_parser", + "paths", + "sessions", + "templates", +) diff --git a/donna/workspaces/artifacts.py b/donna/workspaces/artifacts.py index bd0b529f..21bd2156 100644 --- a/donna/workspaces/artifacts.py +++ b/donna/workspaces/artifacts.py @@ -8,10 +8,10 @@ from donna.domain.constants import DONNA_ARTIFACT_EXTENSION from donna.domain.paths import ProjectPathId, RelativeProjectPath, ResolvedProjectPath, UntrustedPath from donna.machine.tasks import Task, WorkUnit +from donna.machine.templates import RenderMode from donna.workspaces import errors as world_errors from donna.workspaces.files import FileFingerprint from donna.workspaces.paths import normalize_existing_path -from donna.workspaces.templates import RenderMode if TYPE_CHECKING: from donna.machine.artifacts import Artifact @@ -186,14 +186,15 @@ def render_markdown_artifact( from donna.workspaces.markdown_parser import construct_artifact_from_bytes workspace_config = config() + defaults = workspace_config.defaults return Ok( construct_artifact_from_bytes( artifact_id, content, render_context, - default_section_kind=workspace_config.default_section_kind, - default_primary_section_kind=workspace_config.default_primary_section_kind, - default_primary_section_id=workspace_config.default_primary_section_id, + default_section_kind=defaults.tail_section_kind, + default_primary_section_kind=defaults.primary_section_kind, + default_primary_section_id=defaults.primary_section_id, ).unwrap() ) diff --git a/donna/workspaces/config.py b/donna/workspaces/config.py index 1a7e32cf..8385b9e0 100644 --- a/donna/workspaces/config.py +++ b/donna/workspaces/config.py @@ -1,7 +1,7 @@ from __future__ import annotations import enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal import pydantic @@ -9,7 +9,7 @@ from donna.domain.constants import DONNA_DEFAULT_SESSION_DIR, DONNA_DEFAULT_WORKFLOW_DIR from donna.domain.id_paths import NormalizedRawIdPath from donna.domain.ids import SectionId -from donna.domain.paths import ProjectRootPath, RelativeProjectPath +from donna.domain.paths import ProjectConfigPath, ProjectRootPath, RelativeProjectPath from donna.domain.python_path import PythonPath from donna.workspaces import errors as world_errors @@ -60,6 +60,12 @@ def validate_cmd(cls, value: list[str] | None) -> list[str] | None: return value +class DefaultsConfig(BaseEntity): + tail_section_kind: PythonPath = PythonPath(NormalizedRawIdPath("donna.lib.text")) + primary_section_kind: PythonPath = PythonPath(NormalizedRawIdPath("donna.lib.workflow")) + primary_section_id: SectionId = SectionId("primary") + + def _default_workflow_dirs() -> list[RelativeProjectPath]: return [ RelativeProjectPath(DONNA_DEFAULT_WORKFLOW_DIR), @@ -82,10 +88,9 @@ def _validate_relative_project_path(path: RelativeProjectPath) -> RelativeProjec class Config(BaseEntity): + version: Literal[1] = 1 session_dir: RelativeProjectPath = RelativeProjectPath(DONNA_DEFAULT_SESSION_DIR) - default_section_kind: PythonPath = PythonPath(NormalizedRawIdPath("donna.lib.text")) - default_primary_section_kind: PythonPath = PythonPath(NormalizedRawIdPath("donna.lib.workflow")) - default_primary_section_id: SectionId = SectionId("primary") + defaults: DefaultsConfig = pydantic.Field(default_factory=DefaultsConfig) workflow_dirs: list[RelativeProjectPath] = pydantic.Field(default_factory=_default_workflow_dirs) journal: JournalConfig = pydantic.Field(default_factory=JournalConfig) @@ -117,6 +122,7 @@ class Workspace(BaseEntity): model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) root: ProjectRootPath + config_path: ProjectConfigPath config: Config @@ -146,6 +152,7 @@ def __call__(self) -> V: project_dir = GlobalConfig[ProjectRootPath]() +config_path = GlobalConfig[ProjectConfigPath]() config = GlobalConfig[Config]() protocol: GlobalConfig["Mode"] = GlobalConfig() @@ -154,5 +161,8 @@ def install_workspace(workspace: Workspace) -> None: if not project_dir.is_set(): project_dir.set(ProjectRootPath(workspace.root)) + if not config_path.is_set(): + config_path.set(ProjectConfigPath(workspace.config_path)) + if not config.is_set(): config.set(workspace.config) diff --git a/donna/workspaces/errors.py b/donna/workspaces/errors.py index 04c35289..f3180fcb 100644 --- a/donna/workspaces/errors.py +++ b/donna/workspaces/errors.py @@ -32,6 +32,17 @@ class ConfigValidationFailed(WorkspaceConfigError): details: str +class WorkspaceConfigNotDiscovered(WorkspaceError): + code: str = "donna.workspaces.config_not_discovered" + message: str = "Could not find a project directory containing `{error.config_name}`." + ways_to_fix: list[str] = [ + "Run Donna from within a project directory that contains the Donna config file.", + "Create the Donna project config via CLI command if it does not exist yet.", + "Pass `--config PATH` to use a specific Donna config file.", + ] + config_name: str + + class WorkspaceAlreadyInitialized(WorkspaceError): code: str = "donna.workspaces.workspace_already_initialized" message: str = "Donna project config already exists at `{error.config_path}`" @@ -43,6 +54,28 @@ class WorkspaceAlreadyInitialized(WorkspaceError): config_path: ProjectConfigPath +class WorkspaceConfigNotFound(WorkspaceError): + code: str = "donna.workspaces.config_not_found" + message: str = "Donna project config does not exist at `{error.config_path}`" + ways_to_fix: list[str] = [ + "Check the config path for typos.", + "Create the Donna project config via CLI command if it does not exist yet.", + "Omit `--config` to discover `donna.toml` from the current working directory.", + ] + config_path: ProjectConfigPath + + +class WorkspaceConfigDirNotFound(WorkspaceError): + code: str = "donna.workspaces.config_dir_not_found" + message: str = "Donna project config directory does not exist for `{error.config_path}`" + ways_to_fix: list[str] = [ + "Check the config path for typos.", + "Create the directory that should contain the Donna project config.", + "Choose an existing project directory for the Donna project config.", + ] + config_path: ProjectConfigPath + + class JournalCommandConfigInvalid(WorkspaceError): code: str = "donna.workspaces.journal_command_config_invalid" message: str = "Journal command config is invalid: {error.details}" diff --git a/donna/workspaces/fixtures/base_config.toml b/donna/workspaces/fixtures/base_config.toml index 2fec7bb6..fdeb4f15 100644 --- a/donna/workspaces/fixtures/base_config.toml +++ b/donna/workspaces/fixtures/base_config.toml @@ -1,3 +1,4 @@ +version = 1 # Directory where Donna will keep its state. session_dir = ".session/donna" @@ -5,27 +6,28 @@ session_dir = ".session/donna" # Defaults to simplify initialization of a workflow. # Most likely you don't need to change these unless you implement a custom operation or workflow. # -# default_section_kind = "donna.lib.text" -# default_primary_section_kind = "donna.lib.workflow" -# default_primary_section_id = "primary" +# [defaults] +# tail_section_kind = "donna.lib.text" +# primary_section_kind = "donna.lib.workflow" +# primary_section_id = "primary" # Directories where Donna will look for workflow files. workflow_dirs = [ "./workflows", "./.session/donna", ] -[journal] + # Command Donna will execute to log a workflow execution progress. # If the command is not specified, Donna will not log anything. # -# Example: append journal records to a project-local text file. -# Uses only /bin/sh and printf, which are available on standard Linux systems. +# Example: forward journal records to a project-specific journal tool. +# [journal] # cmd = [ -# "/bin/sh", -# "-c", -# "printf '%s [%s] %s\\n' \"$1\" \"$2\" \"$3\" >> donna.log", -# "journal", +# "./bin/journal-tool.sh", +# "record", # "{timestamp}", # "{actor_id}", +# "{current_task_id}", +# "{current_operation_id}", # "{message}", # ] diff --git a/donna/workspaces/initialization.py b/donna/workspaces/initialization.py index 5c4a4e97..a4114cc6 100644 --- a/donna/workspaces/initialization.py +++ b/donna/workspaces/initialization.py @@ -3,49 +3,48 @@ import tomllib from donna.core import errors as core_errors -from donna.core import utils from donna.core.result import Err, Ok, Result, unwrap_to_error from donna.domain.constants import DONNA_CONFIG_NAME from donna.domain.paths import PathInput, ProjectConfigPath, ProjectRootPath, UntrustedPath from donna.protocol.modes import Mode from donna.workspaces import config from donna.workspaces import errors as world_errors +from donna.workspaces import utils from donna.workspaces.paths import resolve_project_root BASE_CONFIG_FIXTURE = "base_config.toml" @unwrap_to_error -def load_workspace(root_dir: PathInput | None = None) -> Result[config.Workspace, core_errors.ErrorsList]: +def load_workspace(config_path: PathInput | None = None) -> Result[config.Workspace, core_errors.ErrorsList]: """Load workspace configuration without mutating process-global state.""" - if root_dir is None: + if config_path is None: project_dir = utils.discover_project_dir(DONNA_CONFIG_NAME).unwrap() + resolved_config_path = ProjectConfigPath(pathlib.Path(project_dir) / DONNA_CONFIG_NAME) else: - project_dir = resolve_project_root(UntrustedPath(root_dir)) - if not (pathlib.Path(project_dir) / DONNA_CONFIG_NAME).is_file(): - return Err([core_errors.ProjectDirNotFound(config_name=DONNA_CONFIG_NAME)]) + resolved_config_path = ProjectConfigPath(pathlib.Path(config_path).expanduser().resolve()) + project_dir = resolve_project_root(UntrustedPath(pathlib.Path(resolved_config_path).parent)) - config_path = ProjectConfigPath(pathlib.Path(project_dir) / DONNA_CONFIG_NAME) - - if not config_path.exists(): - return Ok(config.Workspace(root=project_dir, config=config.Config())) + if not pathlib.Path(resolved_config_path).is_file(): + return Err([world_errors.WorkspaceConfigNotFound(config_path=resolved_config_path)]) try: - data = tomllib.loads(config_path.read_text(encoding="utf-8")) + data = pathlib.Path(resolved_config_path).read_text(encoding="utf-8") + parsed = tomllib.loads(data) except tomllib.TOMLDecodeError as e: - return Err([world_errors.ConfigParseFailed(config_path=config_path, details=str(e))]) + return Err([world_errors.ConfigParseFailed(config_path=resolved_config_path, details=str(e))]) try: - loaded_config = config.Config.model_validate(data) + loaded_config = config.Config.model_validate(parsed) except Exception as e: - return Err([world_errors.ConfigValidationFailed(config_path=config_path, details=str(e))]) + return Err([world_errors.ConfigValidationFailed(config_path=resolved_config_path, details=str(e))]) - return Ok(config.Workspace(root=project_dir, config=loaded_config)) + return Ok(config.Workspace(root=project_dir, config_path=resolved_config_path, config=loaded_config)) @unwrap_to_error def initialize_runtime( - root_dir: PathInput | None = None, + config_path: PathInput | None = None, protocol: Mode | None = None, ) -> Result[config.Workspace, core_errors.ErrorsList]: """Initialize the runtime environment for the application. @@ -55,27 +54,30 @@ def initialize_runtime( if protocol is not None: config.protocol.set(protocol) - workspace = load_workspace(root_dir=root_dir).unwrap() + workspace = load_workspace(config_path=config_path).unwrap() config.install_workspace(workspace) return Ok(workspace) @unwrap_to_error -def initialize_workspace(project_dir: PathInput) -> Result[config.Workspace, core_errors.ErrorsList]: +def initialize_workspace(config_path: PathInput) -> Result[config.Workspace, core_errors.ErrorsList]: """Initialize Donna project configuration.""" - project_dir = ProjectRootPath(pathlib.Path(project_dir).resolve()) - config_path = ProjectConfigPath(pathlib.Path(project_dir) / DONNA_CONFIG_NAME) + config_path = ProjectConfigPath(pathlib.Path(config_path).expanduser().resolve()) + project_dir = ProjectRootPath(pathlib.Path(config_path).parent) + + if not pathlib.Path(project_dir).is_dir(): + return Err([world_errors.WorkspaceConfigDirNotFound(config_path=config_path)]) - if config_path.exists(): + if pathlib.Path(config_path).exists(): return Err([world_errors.WorkspaceAlreadyInitialized(config_path=config_path)]) config_text = ( importlib.resources.files(__package__).joinpath("fixtures", BASE_CONFIG_FIXTURE).read_text(encoding="utf-8") ) - config_path.write_text(config_text, encoding="utf-8") + pathlib.Path(config_path).write_text(config_text, encoding="utf-8") - workspace = load_workspace(root_dir=project_dir).unwrap() + workspace = load_workspace(config_path=config_path).unwrap() config.install_workspace(workspace) return Ok(workspace) diff --git a/donna/workspaces/journal.py b/donna/workspaces/journal.py index 9532988f..90f1a553 100644 --- a/donna/workspaces/journal.py +++ b/donna/workspaces/journal.py @@ -9,7 +9,7 @@ from donna.workspaces.config import JournalRecordAttribute if TYPE_CHECKING: - from donna.machine.journal import JournalRecord + from donna.protocol.journal import JournalRecord def _is_variable_argument(argument: str) -> bool: diff --git a/donna/workspaces/markdown.py b/donna/workspaces/markdown.py index 0688cb5d..f66b709b 100644 --- a/donna/workspaces/markdown.py +++ b/donna/workspaces/markdown.py @@ -1,5 +1,5 @@ import enum -from typing import Any +from typing import cast from markdown_it import MarkdownIt from markdown_it.token import Token @@ -23,7 +23,7 @@ class CodeSource(BaseEntity): properties: dict[str, str | bool] content: str - def structured_data(self) -> Result[Any, ErrorsList]: + def structured_data(self) -> Result[object, ErrorsList]: if "script" in self.properties: return Ok({}) @@ -75,7 +75,7 @@ def as_original_markdown(self, with_title: bool) -> str: def as_analysis_markdown(self, with_title: bool) -> str: return self._as_markdown(self.analysis_tokens, with_title) - def config(self) -> Result[dict[str, Any], ErrorsList]: + def config(self) -> Result[dict[str, object], ErrorsList]: config_blocks = [config for config in self.configs if "config" in config.properties] if len(config_blocks) > 1: return Err( @@ -89,7 +89,8 @@ def config(self) -> Result[dict[str, Any], ErrorsList]: if not config_blocks: return Ok({}) - return config_blocks[0].structured_data() + data = config_blocks[0].structured_data().unwrap() + return Ok(cast(dict[str, object], data)) def script(self) -> Result[str | None, ErrorsList]: script_blocks = [config.content for config in self.configs if "script" in config.properties] @@ -262,7 +263,8 @@ def parse( # noqa: CCR001, CFQ001 tokens = md.parse(text) # we do not need root node - node: SyntaxTreeNode | None = SyntaxTreeNode(tokens).children[0] + root = SyntaxTreeNode(tokens) + node: SyntaxTreeNode | None = root.children[0] if root.children else None sections: list[SectionSource] = [] diff --git a/donna/workspaces/markdown_parser.py b/donna/workspaces/markdown_parser.py index 39b1df6c..f64737e7 100644 --- a/donna/workspaces/markdown_parser.py +++ b/donna/workspaces/markdown_parser.py @@ -1,5 +1,5 @@ import uuid -from typing import Any, ClassVar, Protocol, cast +from typing import ClassVar, Protocol, cast from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error @@ -8,10 +8,11 @@ from donna.domain.python_path import PythonPath from donna.machine.artifacts import Artifact, ArtifactSection, ArtifactSectionConfig, ArtifactSectionMeta from donna.machine.primitives import Primitive, resolve_primitive +from donna.machine.templates import RenderMode from donna.workspaces import errors as world_errors from donna.workspaces import markdown from donna.workspaces.artifacts import ArtifactRenderContext -from donna.workspaces.templates import RenderMode, render +from donna.workspaces.templates import render class MarkdownSectionConstructor(Protocol): @@ -19,7 +20,7 @@ def markdown_construct_section( self, artifact_id: ArtifactId, source: markdown.SectionSource, - config: dict[str, Any], + config: dict[str, object], primary: bool = False, ) -> Result[ArtifactSection, ErrorsList]: pass @@ -61,10 +62,10 @@ def markdown_construct_section( # noqa: CCR001 self, artifact_id: ArtifactId, source: markdown.SectionSource, - config: dict[str, Any], + config: dict[str, object], primary: bool = False, ) -> Result[ArtifactSection, ErrorsList]: - section_config = self.config_class.parse_obj(config) + section_config = self.config_class.model_validate(config) title = self.markdown_build_title( artifact_id=artifact_id, @@ -164,11 +165,7 @@ def construct_artifact_from_markdown_source( # noqa: CCR001 if "kind" not in head_config or head_config["kind"] is None: head_config["kind"] = default_primary_section_kind - head_kind_value = head_config["kind"] - if isinstance(head_kind_value, PythonPath): - head_kind = head_kind_value - else: - head_kind = PythonPath.parse(head_kind_value).unwrap() + head_kind = _parse_primitive_id(head_config["kind"]).unwrap() if "id" not in head_config or head_config["id"] is None: head_config["id"] = default_primary_section_id @@ -215,11 +212,7 @@ def construct_sections_from_markdown( # noqa: CCR001 if "kind" not in data: data["kind"] = default_section_kind - kind_value = data["kind"] - if isinstance(kind_value, str): - primitive_id = PythonPath.parse(kind_value).unwrap() - else: - primitive_id = kind_value + primitive_id = _parse_primitive_id(data["kind"]).unwrap() primitive = _resolve_primitive(primitive_id, primitive_overrides).unwrap() _ensure_markdown_constructible(primitive, primitive_id).unwrap() @@ -247,6 +240,13 @@ def _resolve_primitive( return resolve_primitive(primitive_id) +def _parse_primitive_id(value: object) -> Result[PythonPath, ErrorsList]: + if isinstance(value, PythonPath): + return Ok(value) + + return PythonPath.parse(value) + + def _ensure_markdown_constructible( primitive: Primitive, primitive_id: PythonPath | str | None = None, diff --git a/donna/workspaces/templates.py b/donna/workspaces/templates.py index 53c9902c..79e57ff5 100644 --- a/donna/workspaces/templates.py +++ b/donna/workspaces/templates.py @@ -1,9 +1,8 @@ from __future__ import annotations -import enum import importlib import importlib.util -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import jinja2 @@ -12,33 +11,13 @@ from donna.core.result import Err, Ok, Result from donna.domain.artifact_ids import ArtifactId from donna.machine.templates import Directive +from donna.machine.templates_context import DirectiveContext from donna.workspaces import errors as world_errors if TYPE_CHECKING: from donna.workspaces.artifacts import ArtifactRenderContext -class RenderMode(enum.Enum): - """Modes for rendering artifacts. - - Donna could render artifacts for different purposes, for example: - - - to be displayed to the agent when Donna is used via CLI - - TODO: to be displayed to the agent when Donna is used as an agent tool - - TODO: to be displayed to the agent when Donna is used as an MCP server - - to be used for analysis by Donna itself - - In each mode Donna can produce different outputs. - - For example, it can output CLI commands in view/execute mode, tool specifications in tool mode, - special markup in analyze mode, etc. - """ - - view = "view" - execute = "execute" - analysis = "analysis" - - _ENVIRONMENT = None @@ -57,8 +36,9 @@ def __getitem__(self, name: str) -> "DirectivePathBuilder": return DirectivePathBuilder(self._parts + (name,)) @jinja2.pass_context - def __call__(self, context: jinja2.runtime.Context, *argv: object, **kwargs: object) -> object: # noqa: CCR001 - artifact_id = context.get("artifact_id") + def __call__(self, context: DirectiveContext, *argv: object, **kwargs: object) -> object: # noqa: CCR001 + raw_artifact_id = context.get("artifact_id") + artifact_id = cast(ArtifactId | None, raw_artifact_id if isinstance(raw_artifact_id, str) else None) directive_path = ".".join(self._parts) if len(self._parts) < 2: raise EnvironmentErrorsProxy( @@ -164,7 +144,7 @@ def env() -> jinja2.Environment: def render(artifact_id: ArtifactId, template: str, render_context: "ArtifactRenderContext") -> Result[str, ErrorsList]: - context = {"render_mode": render_context.primary_mode, "artifact_id": artifact_id} + context: dict[str, object] = {"render_mode": render_context.primary_mode, "artifact_id": artifact_id} if render_context.current_task is not None: context["current_task"] = render_context.current_task @@ -176,4 +156,4 @@ def render(artifact_id: ArtifactId, template: str, render_context: "ArtifactRend template_obj = env().from_string(template) return Ok(template_obj.render(**context)) except EnvironmentErrorsProxy as exc: - return Err(exc.arguments["errors"]) + return Err(cast(ErrorsList, exc.arguments["errors"])) diff --git a/donna/workspaces/tests/__init__.py b/donna/workspaces/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/donna/workspaces/tests/make.py b/donna/workspaces/tests/make.py new file mode 100644 index 00000000..f1f96b54 --- /dev/null +++ b/donna/workspaces/tests/make.py @@ -0,0 +1,58 @@ +import pathlib + +from donna.domain.artifact_ids import ArtifactId +from donna.domain.id_paths import NormalizedRawIdPath +from donna.domain.ids import SectionId +from donna.domain.paths import ProjectConfigPath, ProjectRootPath +from donna.domain.python_path import PythonPath +from donna.machine.artifacts import ArtifactSectionConfig +from donna.workspaces import markdown +from donna.workspaces.config import Config, Workspace +from donna.workspaces.markdown import CodeSource, SectionLevel, SectionSource + +ARTIFACT_ID = ArtifactId("@/workflows/test.donna.md") + + +def text_kind() -> PythonPath: + return PythonPath(NormalizedRawIdPath("donna.primitives.sections.text.Text")) + + +def workspace(root: pathlib.Path, config: Config | None = None) -> Workspace: + return Workspace( + root=ProjectRootPath(root), + config_path=ProjectConfigPath(root / "donna.toml"), + config=config or Config(), + ) + + +def section_source( + *, + level: SectionLevel = SectionLevel.h2, + title: str | None = "Section", + configs: list[CodeSource] | None = None, +) -> SectionSource: + return SectionSource( + level=level, + title=title, + configs=configs or [], + original_tokens=[], + analysis_tokens=[], + ) + + +def code_source(format: str = "toml", content: str = "", **properties: str | bool) -> CodeSource: + return CodeSource(format=format, content=content, properties=properties) + + +def section_source_from_markdown(text: str, section_index: int = 0) -> SectionSource: + source = markdown.parse(text, artifact_id=ARTIFACT_ID).unwrap()[section_index] + source.analysis_tokens.extend(source.original_tokens) + return source + + +def section_config( + *, + id: SectionId = SectionId("section"), + kind: PythonPath | None = None, +) -> ArtifactSectionConfig: + return ArtifactSectionConfig(id=id, kind=kind or text_kind()) diff --git a/donna/workspaces/tests/test_artifacts.py b/donna/workspaces/tests/test_artifacts.py new file mode 100644 index 00000000..82e8d7c8 --- /dev/null +++ b/donna/workspaces/tests/test_artifacts.py @@ -0,0 +1,347 @@ +import pathlib + +from pytest_mock import MockerFixture + +from donna.core.result import Err, Ok +from donna.domain.artifact_ids import ArtifactId +from donna.domain.paths import RelativeProjectPath, ResolvedProjectPath +from donna.machine.artifacts import Artifact +from donna.workspaces import artifacts +from donna.workspaces import errors as workspace_errors +from donna.workspaces.config import Config +from donna.workspaces.files import FileFingerprint +from donna.workspaces.tests import make + + +class TestHasDonnaArtifactExtension: + def test_matches_donna_markdown_suffix_case_insensitively(self) -> None: + assert artifacts.has_donna_artifact_extension("workflow.donna.md") + assert artifacts.has_donna_artifact_extension("workflow.DONNA.MD") + assert not artifacts.has_donna_artifact_extension("workflow.md") + + +class TestArtifactRenderContext: + def test_defaults__omit_current_work(self) -> None: + context = artifacts.ArtifactRenderContext(primary_mode=artifacts.RenderMode.view) + + assert context.primary_mode == artifacts.RenderMode.view + assert context.current_task is None + assert context.current_work_unit is None + + +class TestArtifactIdFromParts: + def test_returns_artifact_id_for_valid_parts(self) -> None: + assert artifacts._artifact_id_from_parts(["workflows", "test.donna.md"]) == make.ARTIFACT_ID + + def test_returns_none_for_invalid_parts(self) -> None: + assert artifacts._artifact_id_from_parts(["invalid name.donna.md"]) is None + + +class TestWorkflowDirParts: + def test_returns_posix_parts(self) -> None: + path = RelativeProjectPath(pathlib.Path("workflows") / "nested") + + assert artifacts._workflow_dir_parts(path) == ("workflows", "nested") + + +class TestArtifactIsInWorkflowDirs: + def test_accepts_artifacts_under_configured_workflow_dirs(self) -> None: + assert artifacts._artifact_is_in_workflow_dirs( + make.ARTIFACT_ID, + [RelativeProjectPath(pathlib.Path("workflows"))], + ) + + def test_rejects_workflow_dir_itself_and_other_dirs(self) -> None: + assert not artifacts._artifact_is_in_workflow_dirs( + ArtifactId("@/workflows.donna.md"), + [RelativeProjectPath(pathlib.Path("workflows"))], + ) + assert not artifacts._artifact_is_in_workflow_dirs( + make.ARTIFACT_ID, + [RelativeProjectPath(pathlib.Path("other"))], + ) + + +class TestArtifactIsVisibleInWorkspace: + def test_uses_configured_workflow_dirs(self, mocker: MockerFixture) -> None: + mocker.patch( + "donna.workspaces.config.config", + return_value=Config(workflow_dirs=[RelativeProjectPath(pathlib.Path("workflows"))]), + ) + + assert artifacts._artifact_is_visible_in_workspace(make.ARTIFACT_ID) + + +class TestArtifactIdFromFilesystemEntry: + def test_returns_artifact_id_for_regular_donna_file(self, tmp_path: pathlib.Path) -> None: + path = tmp_path / "test.donna.md" + path.write_text("", encoding="utf-8") + + assert ( + artifacts._artifact_id_from_filesystem_entry(ResolvedProjectPath(path), ["workflows"]) == make.ARTIFACT_ID + ) + + def test_returns_none_for_dirs_non_artifacts_and_invalid_names(self, tmp_path: pathlib.Path) -> None: + directory = tmp_path / "directory" + directory.mkdir() + ordinary_markdown = tmp_path / "ordinary.md" + ordinary_markdown.write_text("", encoding="utf-8") + invalid_name = tmp_path / "invalid name.donna.md" + invalid_name.write_text("", encoding="utf-8") + + assert artifacts._artifact_id_from_filesystem_entry(ResolvedProjectPath(directory), ["workflows"]) is None + assert ( + artifacts._artifact_id_from_filesystem_entry(ResolvedProjectPath(ordinary_markdown), ["workflows"]) is None + ) + assert artifacts._artifact_id_from_filesystem_entry(ResolvedProjectPath(invalid_name), ["workflows"]) is None + + +class TestWalkWorkflowDir: + def test_walks_directory_recursively_in_name_order(self, tmp_path: pathlib.Path) -> None: + nested = tmp_path / "nested" + nested.mkdir() + (tmp_path / "b.donna.md").write_text("", encoding="utf-8") + (nested / "a.donna.md").write_text("", encoding="utf-8") + + assert list(artifacts._walk_workflow_dir(ResolvedProjectPath(tmp_path), ["workflows"])) == [ + ArtifactId("@/workflows/b.donna.md"), + ArtifactId("@/workflows/nested/a.donna.md"), + ] + + +class TestWalkFilesystem: + def test_walk_filesystem__lists_artifacts_in_workflow_dirs( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + workflows = tmp_path / "workflows" + nested = workflows / "nested" + nested.mkdir(parents=True) + (workflows / "b.donna.md").write_text("", encoding="utf-8") + (workflows / "ignored.md").write_text("", encoding="utf-8") + (workflows / "invalid name.donna.md").write_text("", encoding="utf-8") + (nested / "a.donna.md").write_text("", encoding="utf-8") + mocker.patch("donna.workspaces.config.project_dir", return_value=tmp_path) + + assert list(artifacts.walk_filesystem([RelativeProjectPath(pathlib.Path("workflows"))])) == [ + ArtifactId("@/workflows/b.donna.md"), + ArtifactId("@/workflows/nested/a.donna.md"), + ] + + def test_walk_filesystem__preserves_workflow_dir_order( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + first = tmp_path / "first" + second = tmp_path / "second" + first.mkdir() + second.mkdir() + (first / "b.donna.md").write_text("", encoding="utf-8") + (second / "a.donna.md").write_text("", encoding="utf-8") + mocker.patch("donna.workspaces.config.project_dir", return_value=tmp_path) + + assert list( + artifacts.walk_filesystem( + [ + RelativeProjectPath(pathlib.Path("second")), + RelativeProjectPath(pathlib.Path("first")), + ] + ) + ) == [ + ArtifactId("@/second/a.donna.md"), + ArtifactId("@/first/b.donna.md"), + ] + + def test_walk_filesystem__ignores_missing_workflow_dirs( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + mocker.patch("donna.workspaces.config.project_dir", return_value=tmp_path) + + assert list(artifacts.walk_filesystem([RelativeProjectPath(pathlib.Path("missing"))])) == [] + + +class TestListArtifactIds: + def test_list_artifact_ids__deduplicates_discovered_artifacts(self, mocker: MockerFixture) -> None: + config = Config(workflow_dirs=[RelativeProjectPath(pathlib.Path("workflows"))]) + mocker.patch("donna.workspaces.config.config", return_value=config) + mocker.patch.object( + artifacts, + "walk_filesystem", + return_value=iter([make.ARTIFACT_ID, make.ARTIFACT_ID, ArtifactId("@/workflows/other.donna.md")]), + ) + + assert artifacts.list_artifact_ids() == [make.ARTIFACT_ID, ArtifactId("@/workflows/other.donna.md")] + + +class TestResolveArtifactPath: + def test_resolve_artifact_path__returns_existing_visible_file( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + path = tmp_path / "workflows" / "test.donna.md" + path.parent.mkdir() + path.write_text("", encoding="utf-8") + mocker.patch("donna.workspaces.config.project_dir", return_value=tmp_path) + + result = artifacts.resolve_artifact_path(make.ARTIFACT_ID) + + assert result.is_ok() + assert result.unwrap() == path + + def test_resolve_artifact_path__returns_none_for_missing_file( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + mocker.patch("donna.workspaces.config.project_dir", return_value=tmp_path) + + result = artifacts.resolve_artifact_path(make.ARTIFACT_ID) + + assert result.is_ok() + assert result.unwrap() is None + + +class TestFilesystemRawArtifact: + def test_get_bytes__returns_file_bytes(self, tmp_path: pathlib.Path) -> None: + path = tmp_path / "workflow.donna.md" + path.write_bytes(b"content") + raw_artifact = artifacts.FilesystemRawArtifact(path=ResolvedProjectPath(path)) + + assert raw_artifact.get_bytes() == b"content" + + def test_render__renders_markdown_from_file_bytes(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + path = tmp_path / "workflow.donna.md" + path.write_bytes(b"# Workflow") + expected_artifact = Artifact(id=make.ARTIFACT_ID, sections=[]) + render_markdown_artifact = mocker.patch.object( + artifacts, + "render_markdown_artifact", + return_value=Ok(expected_artifact), + ) + raw_artifact = artifacts.FilesystemRawArtifact(path=ResolvedProjectPath(path)) + + result = raw_artifact.render(make.ARTIFACT_ID, artifacts.RENDER_CONTEXT_VIEW) + + assert result.is_ok() + assert result.unwrap() == expected_artifact + render_markdown_artifact.assert_called_once_with( + make.ARTIFACT_ID, + b"# Workflow", + artifacts.RENDER_CONTEXT_VIEW, + ) + + def test_render__returns_markdown_render_errors(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + path = tmp_path / "workflow.donna.md" + path.write_bytes(b"# Workflow") + error = workspace_errors.MarkdownArtifactWithoutSections(artifact_id=make.ARTIFACT_ID) + mocker.patch.object(artifacts, "render_markdown_artifact", return_value=Err([error])) + raw_artifact = artifacts.FilesystemRawArtifact(path=ResolvedProjectPath(path)) + + result = raw_artifact.render(make.ARTIFACT_ID, artifacts.RENDER_CONTEXT_VIEW) + + assert result.is_err() + assert result.unwrap_err() == [error] + + +class TestFetchRawArtifact: + def test_fetch_raw_artifact__returns_filesystem_artifact( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + path = tmp_path / "workflows" / "test.donna.md" + path.parent.mkdir() + path.write_text("content", encoding="utf-8") + mocker.patch("donna.workspaces.config.project_dir", return_value=tmp_path) + mocker.patch( + "donna.workspaces.config.config", + return_value=Config(workflow_dirs=[RelativeProjectPath(pathlib.Path("workflows"))]), + ) + + result = artifacts.fetch_raw_artifact(make.ARTIFACT_ID) + + assert result.is_ok() + assert result.unwrap().get_bytes() == b"content" + + def test_fetch_raw_artifact__rejects_artifacts_outside_workflow_dirs( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + mocker.patch( + "donna.workspaces.config.config", + return_value=Config(workflow_dirs=[RelativeProjectPath(pathlib.Path("other"))]), + ) + + result = artifacts.fetch_raw_artifact(make.ARTIFACT_ID) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.ArtifactNotFound) + + def test_fetch_raw_artifact__rejects_unsupported_artifact_extension( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + artifact_id = ArtifactId("@/workflows/test.md") + path = tmp_path / "workflows" / "test.md" + path.parent.mkdir() + path.write_text("content", encoding="utf-8") + mocker.patch("donna.workspaces.config.project_dir", return_value=tmp_path) + mocker.patch( + "donna.workspaces.config.config", + return_value=Config(workflow_dirs=[RelativeProjectPath(pathlib.Path("workflows"))]), + ) + + result = artifacts.fetch_raw_artifact(artifact_id) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, workspace_errors.UnsupportedArtifactExtension) + assert error.extension == ".md" + + +class TestFetchArtifactBytes: + def test_fetch_artifact_bytes__returns_raw_bytes(self, mocker: MockerFixture) -> None: + raw_artifact = mocker.Mock() + raw_artifact.get_bytes.return_value = b"content" + mocker.patch.object(artifacts, "fetch_raw_artifact", return_value=Ok(raw_artifact)) + + result = artifacts.fetch_artifact_bytes(make.ARTIFACT_ID) + + assert result.is_ok() + assert result.unwrap() == b"content" + + +class TestRenderMarkdownArtifact: + def test_render_markdown_artifact__uses_workspace_defaults(self, mocker: MockerFixture) -> None: + expected_artifact = Artifact(id=make.ARTIFACT_ID, sections=[]) + mocker.patch("donna.workspaces.config.config", return_value=Config()) + construct = mocker.patch( + "donna.workspaces.markdown_parser.construct_artifact_from_bytes", + return_value=Ok(expected_artifact), + ) + + result = artifacts.render_markdown_artifact(make.ARTIFACT_ID, b"# Workflow", artifacts.RENDER_CONTEXT_VIEW) + + assert result.is_ok() + assert result.unwrap() == expected_artifact + construct.assert_called_once_with( + make.ARTIFACT_ID, + b"# Workflow", + artifacts.RENDER_CONTEXT_VIEW, + default_section_kind=Config().defaults.tail_section_kind, + default_primary_section_kind=Config().defaults.primary_section_kind, + default_primary_section_id=Config().defaults.primary_section_id, + ) + + +class TestArtifactFingerprint: + def test_artifact_fingerprint__returns_file_fingerprint( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + path = tmp_path / "workflow.donna.md" + path.write_text("data", encoding="utf-8") + mocker.patch.object(artifacts, "resolve_artifact_path", return_value=Ok(path)) + + result = artifacts.artifact_fingerprint(make.ARTIFACT_ID) + + assert result.is_ok() + assert result.unwrap() == FileFingerprint.from_path(path) + + def test_artifact_fingerprint__returns_none_for_missing_artifact(self, mocker: MockerFixture) -> None: + mocker.patch.object(artifacts, "resolve_artifact_path", return_value=Ok(None)) + + result = artifacts.artifact_fingerprint(make.ARTIFACT_ID) + + assert result.is_ok() + assert result.unwrap() is None diff --git a/donna/workspaces/tests/test_config.py b/donna/workspaces/tests/test_config.py new file mode 100644 index 00000000..1e17dc9f --- /dev/null +++ b/donna/workspaces/tests/test_config.py @@ -0,0 +1,207 @@ +import pathlib + +import pydantic +import pytest +from pytest_mock import MockerFixture + +from donna.domain.id_paths import NormalizedRawIdPath +from donna.domain.paths import ProjectConfigPath, ProjectRootPath, RelativeProjectPath +from donna.domain.python_path import PythonPath +from donna.workspaces import config as workspace_config +from donna.workspaces import errors as workspace_errors +from donna.workspaces.config import Config, DefaultsConfig, GlobalConfig, JournalConfig, JournalRecordAttribute +from donna.workspaces.tests import make + + +class TestJournalRecordAttribute: + def test_has_attribute__accepts_supported_attribute_names(self) -> None: + assert JournalRecordAttribute.has_attribute("message") + assert not JournalRecordAttribute.has_attribute("missing") + + +class TestIsJournalVariableArgument: + def test_detects_whole_argument_placeholders(self) -> None: + assert workspace_config._is_journal_variable_argument("{message}") + assert not workspace_config._is_journal_variable_argument("literal:{message}") + assert not workspace_config._is_journal_variable_argument("{message") + + +class TestJournalConfig: + def test_validate_cmd__accepts_none_and_supported_placeholders(self) -> None: + assert JournalConfig(cmd=None).cmd is None + assert JournalConfig(cmd=["tool", "{message}", "literal:{message}"]).cmd == [ + "tool", + "{message}", + "literal:{message}", + ] + + @pytest.mark.parametrize("cmd", [[], ["tool", "{missing}"], ["tool", 1]]) + def test_validate_cmd__rejects_invalid_command(self, cmd: list[object]) -> None: + with pytest.raises(pydantic.ValidationError): + JournalConfig.model_validate({"cmd": cmd}) + + +class TestDefaultsConfig: + def test_defaults__match_configuration_spec(self) -> None: + defaults = DefaultsConfig() + + assert defaults.tail_section_kind == PythonPath(NormalizedRawIdPath("donna.lib.text")) + assert defaults.primary_section_kind == PythonPath(NormalizedRawIdPath("donna.lib.workflow")) + assert defaults.primary_section_id == "primary" + + @pytest.mark.parametrize( + "data", + [ + {"tail_section_kind": "not a python path"}, + {"primary_section_kind": "not a python path"}, + {"primary_section_id": "---"}, + ], + ) + def test_validation__rejects_invalid_defaults(self, data: dict[str, str]) -> None: + with pytest.raises(pydantic.ValidationError): + DefaultsConfig.model_validate(data) + + +class TestDefaultWorkflowDirs: + def test_returns_spec_defaults(self) -> None: + assert workspace_config._default_workflow_dirs() == [ + pathlib.Path("workflows"), + pathlib.Path(".session/donna"), + ] + + +class TestSerializeWorkflowDir: + def test_serializes_as_project_relative_string(self) -> None: + assert ( + workspace_config._serialize_workflow_dir(RelativeProjectPath(pathlib.Path("workflows"))) == "./workflows" + ) + + +class TestValidateRelativeProjectPath: + def test_returns_relative_project_path(self) -> None: + path = RelativeProjectPath(pathlib.Path("workflows")) + + assert workspace_config._validate_relative_project_path(path) == path + + @pytest.mark.parametrize("path", [pathlib.Path("/outside"), pathlib.Path("../outside")]) + def test_rejects_invalid_project_paths(self, path: pathlib.Path) -> None: + with pytest.raises(ValueError): + workspace_config._validate_relative_project_path(RelativeProjectPath(path)) + + +class TestConfig: + def test_defaults__match_configuration_spec(self) -> None: + config = Config() + + assert config.version == 1 + assert config.session_dir == pathlib.Path(".session/donna") + assert config.workflow_dirs == [pathlib.Path("workflows"), pathlib.Path(".session/donna")] + assert config.journal == JournalConfig() + + def test_validate_workflow_dirs__deduplicates_preserving_order(self) -> None: + config = Config.model_validate({"workflow_dirs": ["workflows", "./workflows", ".session/donna"]}) + + assert config.workflow_dirs == [pathlib.Path("workflows"), pathlib.Path(".session/donna")] + + @pytest.mark.parametrize("field", ["session_dir", "workflow_dirs"]) + def test_validation__rejects_absolute_project_paths(self, field: str) -> None: + value = pathlib.Path("/outside") + data = {field: [value] if field == "workflow_dirs" else value} + + with pytest.raises(pydantic.ValidationError): + Config.model_validate(data) + + @pytest.mark.parametrize("field", ["session_dir", "workflow_dirs"]) + def test_validation__rejects_parent_directory_references(self, field: str) -> None: + value = pathlib.Path("../outside") + data = {field: [value] if field == "workflow_dirs" else value} + + with pytest.raises(pydantic.ValidationError): + Config.model_validate(data) + + @pytest.mark.parametrize( + "data", + [ + {"unknown": True}, + {"defaults": {"unknown": True}}, + {"journal": {"unknown": True}}, + ], + ) + def test_validation__rejects_unknown_fields(self, data: dict[str, object]) -> None: + with pytest.raises(pydantic.ValidationError): + Config.model_validate(data) + + def test_model_dump__serializes_workflow_dirs_as_project_relative_strings(self) -> None: + config = Config( + workflow_dirs=[ + RelativeProjectPath(pathlib.Path("workflows")), + RelativeProjectPath(pathlib.Path(".session/donna")), + ] + ) + + assert config.model_dump(mode="json")["workflow_dirs"] == ["./workflows", "./.session/donna"] + + +class TestGlobalConfig: + def test_get__raises_when_value_is_not_set(self) -> None: + global_config = GlobalConfig[str]() + + with pytest.raises(workspace_errors.GlobalConfigNotSet): + global_config.get() + + def test_set__stores_single_value(self) -> None: + global_config = GlobalConfig[str]() + + global_config.set("value") + + assert global_config.get() == "value" + assert global_config() + assert global_config.is_set() + + with pytest.raises(workspace_errors.GlobalConfigAlreadySet): + global_config.set("other") + + +class TestWorkspace: + def test_builds_validated_workspace_entity(self, tmp_path: pathlib.Path) -> None: + workspace = make.workspace(tmp_path) + + assert workspace.root == ProjectRootPath(tmp_path) + assert workspace.config_path == ProjectConfigPath(tmp_path / "donna.toml") + assert workspace.config == Config() + + +class TestInstallWorkspace: + def test_install_workspace__sets_unset_globals(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + project_dir = GlobalConfig[ProjectRootPath]() + config_path = GlobalConfig[ProjectConfigPath]() + config = GlobalConfig[Config]() + mocker.patch.object(workspace_config, "project_dir", project_dir) + mocker.patch.object(workspace_config, "config_path", config_path) + mocker.patch.object(workspace_config, "config", config) + workspace = make.workspace(tmp_path) + + workspace_config.install_workspace(workspace) + + assert project_dir.get() == ProjectRootPath(tmp_path) + assert config_path.get() == ProjectConfigPath(tmp_path / "donna.toml") + assert config.get() == workspace.config + + def test_install_workspace__does_not_replace_existing_globals( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + project_dir = GlobalConfig[ProjectRootPath]() + config_path = GlobalConfig[ProjectConfigPath]() + config = GlobalConfig[Config]() + project_dir.set(ProjectRootPath(tmp_path / "existing")) + config_path.set(ProjectConfigPath(tmp_path / "existing.toml")) + config.set(Config(session_dir=RelativeProjectPath(pathlib.Path("custom")))) + mocker.patch.object(workspace_config, "project_dir", project_dir) + mocker.patch.object(workspace_config, "config_path", config_path) + mocker.patch.object(workspace_config, "config", config) + + workspace_config.install_workspace(make.workspace(tmp_path)) + + assert project_dir.get() == ProjectRootPath(tmp_path / "existing") + assert config_path.get() == ProjectConfigPath(tmp_path / "existing.toml") + assert config.get().session_dir == pathlib.Path("custom") diff --git a/donna/workspaces/tests/test_errors.py b/donna/workspaces/tests/test_errors.py new file mode 100644 index 00000000..2f802745 --- /dev/null +++ b/donna/workspaces/tests/test_errors.py @@ -0,0 +1,211 @@ +import pathlib + +from donna.domain.paths import ProjectConfigPath +from donna.workspaces import errors as workspace_errors +from donna.workspaces.tests import make + + +class TestWorkspaceConfigError: + def test_content_intro__includes_config_path(self, tmp_path: pathlib.Path) -> None: + error = workspace_errors.ConfigParseFailed( + config_path=ProjectConfigPath(tmp_path / "donna.toml"), details="bad" + ) + + assert error.content_intro() == f"Error in Donna config file '{tmp_path / 'donna.toml'}'" + + +class TestInternalError: + def test_error_message__uses_workspace_internal_error_base(self) -> None: + assert workspace_errors.InternalError().error_message() == "An internal error occurred" + + +class TestWorkspaceError: + def test_cell_kind__uses_workspace_boundary(self) -> None: + assert workspace_errors.WorkspaceError.model_fields["cell_kind"].default == "workspace_error" + + +class _EnvironmentErrorCase: + def error(self) -> workspace_errors.WorkspaceError: + raise NotImplementedError + + def test_model_dump__includes_structured_context(self) -> None: + error = self.error() + + assert error.model_dump(mode="json") + + +class _InternalErrorCase: + def error(self) -> workspace_errors.InternalError: + raise NotImplementedError + + def test_error_message__formats_without_failure(self) -> None: + assert self.error().error_message() + + +class TestConfigParseFailed(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.ConfigParseFailed( + config_path=ProjectConfigPath(pathlib.Path("donna.toml")), details="bad" + ) + + +class TestConfigValidationFailed(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.ConfigValidationFailed( + config_path=ProjectConfigPath(pathlib.Path("donna.toml")), details="bad" + ) + + +class TestWorkspaceConfigNotDiscovered(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.WorkspaceConfigNotDiscovered(config_name="donna.toml") + + +class TestWorkspaceAlreadyInitialized(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.WorkspaceAlreadyInitialized(config_path=ProjectConfigPath(pathlib.Path("donna.toml"))) + + +class TestWorkspaceConfigNotFound(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.WorkspaceConfigNotFound(config_path=ProjectConfigPath(pathlib.Path("donna.toml"))) + + +class TestWorkspaceConfigDirNotFound(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.WorkspaceConfigDirNotFound(config_path=ProjectConfigPath(pathlib.Path("donna.toml"))) + + +class TestJournalCommandConfigInvalid(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.JournalCommandConfigInvalid(argument="{missing}", details="bad") + + +class TestJournalCommandFailed(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.JournalCommandFailed(command=["tool"], returncode=1, details="bad") + + +class TestArtifactError: + def test_content_intro__includes_artifact_id(self) -> None: + error = workspace_errors.ArtifactNotFound(artifact_id=make.ARTIFACT_ID) + + assert error.content_intro() == "Error for artifact '@/workflows/test.donna.md'" + + +class TestArtifactNotFound(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.ArtifactNotFound(artifact_id=make.ARTIFACT_ID) + + +class TestArtifactMultipleFiles(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.ArtifactMultipleFiles(artifact_id=make.ARTIFACT_ID) + + +class TestUnsupportedArtifactExtension(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.UnsupportedArtifactExtension(artifact_id=make.ARTIFACT_ID, extension=".md") + + +class TestMarkdownError: + def test_content_intro__describes_source_without_artifact_id(self) -> None: + error = workspace_errors.MarkdownArtifactWithoutSections() + + assert error.content_intro() == "Error in markdown source" + + def test_content_intro__describes_artifact_when_artifact_id_is_set(self) -> None: + error = workspace_errors.MarkdownArtifactWithoutSections(artifact_id=make.ARTIFACT_ID) + + assert error.content_intro() == "Error in markdown artifact '@/workflows/test.donna.md'" + + +class TestMarkdownUnsupportedCodeFormat(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.MarkdownUnsupportedCodeFormat(format="ini") + + +class TestMarkdownMultipleH1Sections(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.MarkdownMultipleH1Sections(artifact_id=make.ARTIFACT_ID) + + +class TestMarkdownH1SectionMustBeFirst(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.MarkdownH1SectionMustBeFirst(artifact_id=make.ARTIFACT_ID) + + +class TestMarkdownArtifactWithoutSections(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.MarkdownArtifactWithoutSections(artifact_id=make.ARTIFACT_ID) + + +class TestMarkdownMultipleConfigBlocksInSection(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.MarkdownMultipleConfigBlocksInSection(section_title="Section") + + +class TestMarkdownMultipleScriptBlocksInSection(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.MarkdownMultipleScriptBlocksInSection(section_title="Section") + + +class TestPrimitiveDoesNotSupportMarkdown(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.PrimitiveDoesNotSupportMarkdown(primitive_id="kind") + + +class TestTemplateDirectiveError: + def test_content_intro__describes_directive_without_artifact_id(self) -> None: + error = workspace_errors.DirectivePathIncomplete(path="directive") + + assert error.content_intro() == "Error in template directive" + + def test_content_intro__describes_directive_with_artifact_id(self) -> None: + error = workspace_errors.DirectivePathIncomplete(path="directive", artifact_id=make.ARTIFACT_ID) + + assert error.content_intro() == "Error in template directive for artifact '@/workflows/test.donna.md'" + + +class TestDirectivePathIncomplete(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.DirectivePathIncomplete(path="directive") + + +class TestDirectiveModuleNotImportable(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.DirectiveModuleNotImportable(module_path="missing") + + +class TestDirectiveNotAvailable(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.DirectiveNotAvailable(module_path="module", directive_name="missing") + + +class TestDirectiveNotDirective(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.DirectiveNotDirective(module_path="module", directive_name="name") + + +class TestDirectiveUnexpectedError(_EnvironmentErrorCase): + def error(self) -> workspace_errors.WorkspaceError: + return workspace_errors.DirectiveUnexpectedError(directive_path="module.name", details="bad") + + +class TestMarkdownSectionsCountMismatch(_InternalErrorCase): + def error(self) -> workspace_errors.InternalError: + return workspace_errors.MarkdownSectionsCountMismatch( + artifact_id=make.ARTIFACT_ID, + original_count=1, + analyzed_count=2, + ) + + +class TestGlobalConfigAlreadySet(_InternalErrorCase): + def error(self) -> workspace_errors.InternalError: + return workspace_errors.GlobalConfigAlreadySet() + + +class TestGlobalConfigNotSet(_InternalErrorCase): + def error(self) -> workspace_errors.InternalError: + return workspace_errors.GlobalConfigNotSet() diff --git a/donna/workspaces/tests/test_files.py b/donna/workspaces/tests/test_files.py new file mode 100644 index 00000000..e5f69f34 --- /dev/null +++ b/donna/workspaces/tests/test_files.py @@ -0,0 +1,24 @@ +import pathlib + +from donna.workspaces.files import FileFingerprint + + +class TestFileFingerprint: + def test_from_path__returns_fingerprint_for_regular_file(self, tmp_path: pathlib.Path) -> None: + path = tmp_path / "file.txt" + path.write_text("data", encoding="utf-8") + + fingerprint = FileFingerprint.from_path(path) + + assert fingerprint is not None + assert fingerprint.size == 4 + assert fingerprint.mtime_ns == path.stat().st_mtime_ns + + def test_from_path__returns_none_for_missing_path_or_directory(self, tmp_path: pathlib.Path) -> None: + assert FileFingerprint.from_path(tmp_path / "missing.txt") is None + assert FileFingerprint.from_path(tmp_path) is None + + def test_eq__compares_mtime_and_size(self) -> None: + assert FileFingerprint(mtime_ns=1, size=2) == FileFingerprint(mtime_ns=1, size=2) + assert FileFingerprint(mtime_ns=1, size=2) != FileFingerprint(mtime_ns=2, size=2) + assert FileFingerprint(mtime_ns=1, size=2) != object() diff --git a/donna/workspaces/tests/test_initialization.py b/donna/workspaces/tests/test_initialization.py new file mode 100644 index 00000000..7d22a168 --- /dev/null +++ b/donna/workspaces/tests/test_initialization.py @@ -0,0 +1,138 @@ +import pathlib + +from pytest_mock import MockerFixture + +from donna.core.result import Ok +from donna.domain.constants import DONNA_CONFIG_NAME +from donna.protocol.modes import Mode +from donna.workspaces import config as workspace_config +from donna.workspaces import errors as workspace_errors +from donna.workspaces.config import GlobalConfig +from donna.workspaces.initialization import initialize_runtime, initialize_workspace, load_workspace + + +class TestLoadWorkspace: + def test_explicit_config_path__loads_workspace(self, tmp_path: pathlib.Path) -> None: + config_path = tmp_path / DONNA_CONFIG_NAME + config_path.write_text( + 'version = 1\nsession_dir = ".session/custom"\nworkflow_dirs = ["workflows", "workflows"]\n', + encoding="utf-8", + ) + + result = load_workspace(config_path=config_path) + + assert result.is_ok() + workspace = result.unwrap() + assert workspace.root == tmp_path + assert workspace.config_path == config_path + assert workspace.config.session_dir == pathlib.Path(".session/custom") + assert workspace.config.workflow_dirs == [pathlib.Path("workflows")] + + def test_discovery__uses_nearest_project_config(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + config_path = tmp_path / DONNA_CONFIG_NAME + config_path.write_text("version = 1", encoding="utf-8") + mocker.patch("donna.workspaces.utils.discover_project_dir", return_value=Ok(tmp_path)) + + result = load_workspace() + + assert result.is_ok() + assert result.unwrap().root == tmp_path + + def test_missing_explicit_config__returns_not_found_error(self, tmp_path: pathlib.Path) -> None: + result = load_workspace(config_path=tmp_path / DONNA_CONFIG_NAME) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.WorkspaceConfigNotFound) + + def test_invalid_toml__returns_parse_error(self, tmp_path: pathlib.Path) -> None: + config_path = tmp_path / DONNA_CONFIG_NAME + config_path.write_text("version = ", encoding="utf-8") + + result = load_workspace(config_path=config_path) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.ConfigParseFailed) + + def test_invalid_config_schema__returns_validation_error(self, tmp_path: pathlib.Path) -> None: + config_path = tmp_path / DONNA_CONFIG_NAME + config_path.write_text("version = 2", encoding="utf-8") + + result = load_workspace(config_path=config_path) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.ConfigValidationFailed) + + def test_unknown_config_fields__return_validation_error(self, tmp_path: pathlib.Path) -> None: + config_path = tmp_path / DONNA_CONFIG_NAME + config_path.write_text("[defaults]\nunknown = true\n", encoding="utf-8") + + result = load_workspace(config_path=config_path) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.ConfigValidationFailed) + + +class TestInitializeRuntime: + def test_loads_workspace_installs_protocol_and_workspace( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + protocol = GlobalConfig[Mode]() + mocker.patch.object(workspace_config, "protocol", protocol) + install_workspace = mocker.patch("donna.workspaces.config.install_workspace") + config_path = tmp_path / DONNA_CONFIG_NAME + config_path.write_text("version = 1", encoding="utf-8") + + result = initialize_runtime(config_path=config_path, protocol=Mode.llm) + + assert result.is_ok() + workspace = result.unwrap() + assert protocol.get() == Mode.llm + install_workspace.assert_called_once_with(workspace) + + def test_loads_workspace_without_protocol_override(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + protocol = GlobalConfig[Mode]() + mocker.patch.object(workspace_config, "protocol", protocol) + config_path = tmp_path / DONNA_CONFIG_NAME + config_path.write_text("version = 1", encoding="utf-8") + + result = initialize_runtime(config_path=config_path) + + assert result.is_ok() + assert not protocol.is_set() + + +class TestInitializeWorkspace: + def test_creates_starter_config_and_loads_workspace(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + install_workspace = mocker.patch("donna.workspaces.config.install_workspace") + config_path = tmp_path / DONNA_CONFIG_NAME + + result = initialize_workspace(config_path) + + assert result.is_ok() + assert config_path.is_file() + config_text = config_path.read_text(encoding="utf-8") + assert "version = 1" in config_text + assert 'session_dir = ".session/donna"' in config_text + assert '"./workflows"' in config_text + assert '"./.session/donna"' in config_text + assert "# [defaults]" in config_text + assert "# [journal]" in config_text + assert "# cmd = [" in config_text + workspace = result.unwrap() + assert workspace.root == tmp_path + install_workspace.assert_called_once_with(workspace) + + def test_rejects_missing_config_directory(self, tmp_path: pathlib.Path) -> None: + result = initialize_workspace(tmp_path / "missing" / DONNA_CONFIG_NAME) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.WorkspaceConfigDirNotFound) + + def test_rejects_existing_config(self, tmp_path: pathlib.Path) -> None: + config_path = tmp_path / DONNA_CONFIG_NAME + config_path.write_text("version = 1", encoding="utf-8") + + result = initialize_workspace(config_path) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.WorkspaceAlreadyInitialized) diff --git a/donna/workspaces/tests/test_journal.py b/donna/workspaces/tests/test_journal.py new file mode 100644 index 00000000..95264e7f --- /dev/null +++ b/donna/workspaces/tests/test_journal.py @@ -0,0 +1,210 @@ +import datetime +import subprocess # noqa: S404 + +from pytest_mock import MockerFixture + +from donna.domain.artifact_ids import ArtifactSectionId +from donna.domain.internal_ids import TaskId, WorkUnitId +from donna.protocol.journal import JournalRecord +from donna.protocol.tests import make as protocol_make +from donna.workspaces import errors as workspace_errors +from donna.workspaces import journal +from donna.workspaces.config import Config, JournalConfig, JournalRecordAttribute + + +def _journal_record(**kwargs: object) -> JournalRecord: + values = { + "timestamp": datetime.datetime(2026, 5, 18, 10, 30, tzinfo=datetime.UTC), + "actor_id": "agent", + "message": "message", + "current_task_id": TaskId("T-1-b"), + "current_work_unit_id": WorkUnitId("WU-2-c"), + "current_operation_id": ArtifactSectionId("@/workflows/test.donna.md:primary"), + } + values.update(kwargs) + return protocol_make.journal_record(**values) + + +class TestIsVariableArgument: + def test_detects_whole_argument_placeholders(self) -> None: + assert journal._is_variable_argument("{message}") + assert not journal._is_variable_argument("literal:{message}") + assert not journal._is_variable_argument("{message") + + +class TestParseRecordAttribute: + def test_returns_supported_attribute(self) -> None: + result = journal._parse_record_attribute("message", "{message}") + + assert result.is_ok() + assert result.unwrap() == JournalRecordAttribute.message + + def test_reports_unsupported_attribute(self) -> None: + result = journal._parse_record_attribute("missing", "{missing}") + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, workspace_errors.JournalCommandConfigInvalid) + assert error.argument == "{missing}" + + +class TestFormatRecordAttribute: + def test_formats_record_attributes(self) -> None: + record = _journal_record() + + assert ( + journal._format_record_attribute(JournalRecordAttribute.timestamp, record) == "2026-05-18T10:30:00+00:00" + ) + assert journal._format_record_attribute(JournalRecordAttribute.actor_id, record) == "agent" + assert journal._format_record_attribute(JournalRecordAttribute.message, record) == "message" + assert journal._format_record_attribute(JournalRecordAttribute.current_task_id, record) == "T-1-b" + assert journal._format_record_attribute(JournalRecordAttribute.current_work_unit_id, record) == "WU-2-c" + assert ( + journal._format_record_attribute(JournalRecordAttribute.current_operation_id, record) + == "@/workflows/test.donna.md:primary" + ) + + def test_formats_missing_optional_attributes_as_empty_strings(self) -> None: + record = _journal_record( + actor_id=None, + current_task_id=None, + current_work_unit_id=None, + current_operation_id=None, + ) + + assert journal._format_record_attribute(JournalRecordAttribute.actor_id, record) == "" + assert journal._format_record_attribute(JournalRecordAttribute.current_task_id, record) == "" + assert journal._format_record_attribute(JournalRecordAttribute.current_work_unit_id, record) == "" + assert journal._format_record_attribute(JournalRecordAttribute.current_operation_id, record) == "" + + +class TestResolveCommandArgument: + def test_returns_literal_argument(self) -> None: + result = journal._resolve_command_argument("literal:{message}", _journal_record()) + + assert result.is_ok() + assert result.unwrap() == "literal:{message}" + + def test_resolves_placeholder_argument(self) -> None: + result = journal._resolve_command_argument("{message}", _journal_record()) + + assert result.is_ok() + assert result.unwrap() == "message" + + +class TestBuildCommandArgs: + def test_replaces_whole_argument_placeholders(self) -> None: + record = _journal_record() + + result = journal._build_command_args( + [ + "tool", + "{timestamp}", + "{actor_id}", + "{current_task_id}", + "{current_work_unit_id}", + "{current_operation_id}", + "{message}", + "literal:{message}", + ], + record, + ) + + assert result.is_ok() + assert result.unwrap() == [ + "tool", + "2026-05-18T10:30:00+00:00", + "agent", + "T-1-b", + "WU-2-c", + "@/workflows/test.donna.md:primary", + "message", + "literal:{message}", + ] + + def test_replaces_missing_optional_record_values_with_empty_strings(self) -> None: + record = _journal_record( + actor_id=None, + current_task_id=None, + current_work_unit_id=None, + current_operation_id=None, + ) + + result = journal._build_command_args( + ["{actor_id}", "{current_task_id}", "{current_work_unit_id}", "{current_operation_id}"], + record, + ) + + assert result.is_ok() + assert result.unwrap() == ["", "", "", ""] + + def test_unsupported_placeholder_returns_config_error(self) -> None: + result = journal._build_command_args(["{missing}"], _journal_record()) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, workspace_errors.JournalCommandConfigInvalid) + assert error.argument == "{missing}" + + +class TestWriteRecord: + def test_no_configured_command_is_noop(self, mocker: MockerFixture) -> None: + mocker.patch( + "donna.workspaces.config.config", + return_value=Config(journal=JournalConfig(cmd=None)), + ) + run = mocker.patch("subprocess.run") + + result = journal.write_record(_journal_record()) + + assert result.is_ok() + run.assert_not_called() + + def test_runs_configured_command(self, mocker: MockerFixture) -> None: + mocker.patch( + "donna.workspaces.config.config", + return_value=Config(journal=JournalConfig(cmd=["tool", "{message}"])), + ) + run = mocker.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess(args=["tool", "message"], returncode=0, stdout="", stderr=""), + ) + + result = journal.write_record(_journal_record()) + + assert result.is_ok() + run.assert_called_once_with(["tool", "message"], check=False, capture_output=True, text=True) + + def test_reports_command_os_error(self, mocker: MockerFixture) -> None: + mocker.patch( + "donna.workspaces.config.config", + return_value=Config(journal=JournalConfig(cmd=["tool"])), + ) + mocker.patch("subprocess.run", side_effect=OSError("missing")) + + result = journal.write_record(_journal_record()) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, workspace_errors.JournalCommandFailed) + assert error.command == ["tool"] + assert error.returncode is None + + def test_reports_nonzero_command_exit(self, mocker: MockerFixture) -> None: + mocker.patch( + "donna.workspaces.config.config", + return_value=Config(journal=JournalConfig(cmd=["tool"])), + ) + mocker.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess(args=["tool"], returncode=2, stdout="", stderr="bad"), + ) + + result = journal.write_record(_journal_record()) + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, workspace_errors.JournalCommandFailed) + assert error.command == ["tool"] + assert error.returncode == 2 + assert error.details == "exit code 2; stderr: bad" diff --git a/donna/workspaces/tests/test_markdown.py b/donna/workspaces/tests/test_markdown.py new file mode 100644 index 00000000..74f2aae6 --- /dev/null +++ b/donna/workspaces/tests/test_markdown.py @@ -0,0 +1,215 @@ +from markdown_it import MarkdownIt + +from donna.workspaces import errors as workspace_errors +from donna.workspaces import markdown +from donna.workspaces.markdown import SectionLevel +from donna.workspaces.tests import make + + +class TestSectionLevel: + def test_values__match_supported_heading_levels(self) -> None: + assert SectionLevel.h1 == "h1" + assert SectionLevel.h2 == "h2" + + +class TestCodeSource: + def test_structured_data__parses_supported_formats(self) -> None: + assert make.code_source("json", '{"value": 1}').structured_data().unwrap() == {"value": 1} + assert make.code_source("yaml", "value: 1").structured_data().unwrap() == {"value": 1} + assert make.code_source("yml", "value: 1").structured_data().unwrap() == {"value": 1} + assert make.code_source("toml", "value = 1").structured_data().unwrap() == {"value": 1} + + def test_structured_data__script_blocks_return_empty_config(self) -> None: + assert make.code_source("python", "print(1)", script=True).structured_data().unwrap() == {} + + def test_structured_data__rejects_unsupported_format(self) -> None: + result = make.code_source("ini", "value=1", config=True).structured_data() + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, workspace_errors.MarkdownUnsupportedCodeFormat) + assert error.format == "ini" + + +class TestSectionSource: + def test_as_original_markdown__renders_title_and_original_tokens(self) -> None: + tokens = MarkdownIt("commonmark").parse("Body\n") + section = make.section_source(level=SectionLevel.h1, title="Workflow") + section.original_tokens.extend(tokens) + + assert section.as_original_markdown(with_title=True).startswith("# Workflow\n") + assert "Body" in section.as_original_markdown(with_title=False) + + def test_as_analysis_markdown__renders_title_and_analysis_tokens(self) -> None: + tokens = MarkdownIt("commonmark").parse("Analysis\n") + section = make.section_source(level=SectionLevel.h2, title="Step") + section.analysis_tokens.extend(tokens) + + assert section.as_analysis_markdown(with_title=True).startswith("## Step\n") + assert "Analysis" in section.as_analysis_markdown(with_title=False) + + def test_config__returns_empty_dict_without_config_blocks(self) -> None: + assert make.section_source().config().unwrap() == {} + + def test_config__returns_single_config_block_data(self) -> None: + section = make.section_source(configs=[make.code_source("toml", "id = 'section'", config=True)]) + + assert section.config().unwrap() == {"id": "section"} + + def test_config__rejects_multiple_config_blocks(self) -> None: + section = make.section_source( + configs=[ + make.code_source("toml", "id = 'first'", config=True), + make.code_source("toml", "id = 'second'", config=True), + ] + ) + + result = section.config() + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.MarkdownMultipleConfigBlocksInSection) + + def test_script__returns_none_without_script_blocks(self) -> None: + assert make.section_source().script().unwrap() is None + + def test_script__returns_single_script_block_content(self) -> None: + section = make.section_source(configs=[make.code_source("python", "print(1)", script=True)]) + + assert section.script().unwrap() == "print(1)" + + def test_script__rejects_multiple_script_blocks(self) -> None: + section = make.section_source( + configs=[ + make.code_source("python", "print(1)", script=True), + make.code_source("python", "print(2)", script=True), + ] + ) + + result = section.script() + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.MarkdownMultipleScriptBlocksInSection) + + +class TestRenderBack: + def test_renders_tokens_to_markdown(self) -> None: + tokens = MarkdownIt("commonmark").parse("Body\n") + + assert "Body" in markdown.render_back(tokens) + + +class TestClearHeading: + def test_removes_heading_markers_and_surrounding_whitespace(self) -> None: + assert markdown.clear_heading("## Step ") == "Step" + + +class TestParseH1: + def test_parse_h1__creates_primary_section(self) -> None: + result = markdown.parse("# Workflow\n", artifact_id=make.ARTIFACT_ID) + + assert result.is_ok() + section = result.unwrap()[0] + assert section.level == SectionLevel.h1 + assert section.title == "Workflow" + + +class TestParseH2: + def test_parse_h2__creates_tail_section_after_h1(self) -> None: + result = markdown.parse("# Workflow\n\n## Step\n", artifact_id=make.ARTIFACT_ID) + + assert result.is_ok() + section = result.unwrap()[1] + assert section.level == SectionLevel.h2 + assert section.title == "Step" + + +class TestParseHeading: + def test_parse_heading__stores_lower_headings_as_section_content(self) -> None: + result = markdown.parse("# Workflow\n\n### Detail\n", artifact_id=make.ARTIFACT_ID) + + assert result.is_ok() + assert "### Detail" in result.unwrap()[0].as_original_markdown(with_title=False) + + +class TestParseFence: + def test_parse_fence__keeps_non_donna_fences_in_original_tokens(self) -> None: + result = markdown.parse("# Workflow\n\n```python\nprint(1)\n```\n", artifact_id=make.ARTIFACT_ID) + + assert result.is_ok() + section = result.unwrap()[0] + assert section.configs == [] + assert "print(1)" in section.as_original_markdown(with_title=False) + + def test_parse_fence__treats_plain_donna_marker_as_config(self) -> None: + result = markdown.parse("# Workflow\n\n```toml donna\nid = 'primary'\n```\n", artifact_id=make.ARTIFACT_ID) + + assert result.is_ok() + assert result.unwrap()[0].config().unwrap() == {"id": "primary"} + + def test_parse_fence__parses_marker_key_values(self) -> None: + result = markdown.parse( + "# Workflow\n\n```toml donna name=value\nid = 'primary'\n```\n", artifact_id=make.ARTIFACT_ID + ) + + assert result.is_ok() + assert result.unwrap()[0].configs[0].properties["name"] == "value" + + +class TestParseNested: + def test_parse_nested__keeps_nested_blocks_in_section_content(self) -> None: + result = markdown.parse("# Workflow\n\n> Quote\n", artifact_id=make.ARTIFACT_ID) + + assert result.is_ok() + assert "> Quote" in result.unwrap()[0].as_original_markdown(with_title=False) + + +class TestParseOthers: + def test_parse_others__keeps_paragraphs_in_section_content(self) -> None: + result = markdown.parse("# Workflow\n\nBody\n", artifact_id=make.ARTIFACT_ID) + + assert result.is_ok() + assert "Body" in result.unwrap()[0].as_original_markdown(with_title=False) + + +class TestParse: + def test_parse__extracts_h1_h2_content_and_donna_config_blocks(self) -> None: + result = markdown.parse( + """# Workflow + +Intro + +```toml donna +id = "primary" +``` + +## Step + +Body + +```python donna script +print("run") +``` +""", + artifact_id=make.ARTIFACT_ID, + ) + + assert result.is_ok() + sections = result.unwrap() + assert [section.level for section in sections] == [SectionLevel.h1, SectionLevel.h2] + assert [section.title for section in sections] == ["Workflow", "Step"] + assert sections[0].config().unwrap() == {"id": "primary"} + assert sections[1].script().unwrap() == 'print("run")' + assert "Intro" in sections[0].as_original_markdown(with_title=False) + assert "Body" in sections[1].as_original_markdown(with_title=False) + + def test_parse__rejects_h2_before_h1(self) -> None: + result = markdown.parse("## Step\n", artifact_id=make.ARTIFACT_ID) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.MarkdownH1SectionMustBeFirst) + + def test_parse__rejects_multiple_h1_sections(self) -> None: + result = markdown.parse("# First\n\n# Second\n", artifact_id=make.ARTIFACT_ID) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.MarkdownMultipleH1Sections) diff --git a/donna/workspaces/tests/test_markdown_parser.py b/donna/workspaces/tests/test_markdown_parser.py new file mode 100644 index 00000000..89d5f48b --- /dev/null +++ b/donna/workspaces/tests/test_markdown_parser.py @@ -0,0 +1,262 @@ +from typing import ClassVar + +from pytest_mock import MockerFixture + +from donna.core.errors import ErrorsList +from donna.core.result import Err, Ok, Result +from donna.domain.artifact_ids import ArtifactId +from donna.domain.id_paths import NormalizedRawIdPath +from donna.domain.ids import SectionId +from donna.domain.python_path import PythonPath +from donna.machine.artifacts import Artifact, ArtifactSection, ArtifactSectionConfig +from donna.machine.primitives import Primitive +from donna.primitives.sections.text import Text +from donna.workspaces import errors as workspace_errors +from donna.workspaces import markdown_parser +from donna.workspaces.artifacts import RENDER_CONTEXT_VIEW +from donna.workspaces.markdown import CodeSource, SectionLevel, SectionSource +from donna.workspaces.markdown_parser import MarkdownSectionMixin, construct_sections_from_markdown +from donna.workspaces.tests import make + +TEXT_KIND = PythonPath(NormalizedRawIdPath("donna.primitives.sections.text.Text")) +LIB_TEXT_KIND = PythonPath(NormalizedRawIdPath("donna.lib.text")) + + +class _MarkdownPrimitive(MarkdownSectionMixin, Primitive): + config_class: ClassVar[type[ArtifactSectionConfig]] = ArtifactSectionConfig + + +class _FailingMarkdownPrimitive(_MarkdownPrimitive): + def markdown_construct_section( + self, + artifact_id: ArtifactId, + source: SectionSource, + config: dict[str, object], + primary: bool = False, + ) -> Result[ArtifactSection, ErrorsList]: + return Err([workspace_errors.MarkdownArtifactWithoutSections(artifact_id=artifact_id)]) + + +class TestMarkdownSectionConstructor: + def test_protocol__documents_markdown_construct_section_contract(self) -> None: + assert hasattr(markdown_parser.MarkdownSectionConstructor, "markdown_construct_section") + + +class TestMarkdownSectionMixin: + def test_markdown_build_title__uses_source_title_or_empty_string(self) -> None: + primitive = _MarkdownPrimitive() + + assert ( + primitive.markdown_build_title(make.ARTIFACT_ID, make.section_source(title="Title"), make.section_config()) + == "Title" + ) + assert ( + primitive.markdown_build_title(make.ARTIFACT_ID, make.section_source(title=None), make.section_config()) + == "" + ) + + def test_markdown_build_description__uses_original_markdown_without_title(self) -> None: + primitive = _MarkdownPrimitive() + source = make.section_source() + + assert primitive.markdown_build_description(make.ARTIFACT_ID, source, make.section_config()) == "" + + def test_markdown_construct_meta__returns_empty_meta(self) -> None: + primitive = _MarkdownPrimitive() + + result = primitive.markdown_construct_meta(make.ARTIFACT_ID, make.section_source(), make.section_config(), "") + + assert result.is_ok() + assert result.unwrap().cells_meta() == {} + + def test_markdown_construct_section__builds_artifact_section(self) -> None: + primitive = _MarkdownPrimitive() + + result = primitive.markdown_construct_section( + artifact_id=make.ARTIFACT_ID, + source=make.section_source(title="Section"), + config={"id": "section", "kind": str(TEXT_KIND)}, + ) + + assert result.is_ok() + section = result.unwrap() + assert section.id == SectionId("section") + assert section.kind == TEXT_KIND + assert section.title == "Section" + assert not section.primary + + +class TestParseArtifactContent: + def test_returns_original_sections_with_analysis_tokens(self) -> None: + result = markdown_parser.parse_artifact_content(make.ARTIFACT_ID, "# Workflow\n\nBody\n", RENDER_CONTEXT_VIEW) + + assert result.is_ok() + section = result.unwrap()[0] + assert section.title == "Workflow" + assert section.analysis_tokens + + def test_returns_error_for_source_without_sections(self) -> None: + result = markdown_parser.parse_artifact_content(make.ARTIFACT_ID, "", RENDER_CONTEXT_VIEW) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.MarkdownArtifactWithoutSections) + + def test_raises_internal_error_for_analysis_section_count_mismatch(self, mocker: MockerFixture) -> None: + mocker.patch.object( + markdown_parser, + "render", + side_effect=[Ok("# Workflow\n"), Ok("# Workflow\n\n## Step\n")], + ) + + try: + markdown_parser.parse_artifact_content(make.ARTIFACT_ID, "# Workflow\n", RENDER_CONTEXT_VIEW) + except workspace_errors.MarkdownSectionsCountMismatch as error: + assert error.arguments["original_count"] == 1 + assert error.arguments["analyzed_count"] == 2 + else: + raise AssertionError("Expected MarkdownSectionsCountMismatch") + + +class TestConstructArtifactFromBytes: + def test_decodes_bytes_and_constructs_artifact_from_markdown(self, mocker: MockerFixture) -> None: + expected = Artifact(id=make.ARTIFACT_ID, sections=[]) + construct = mocker.patch.object( + markdown_parser, + "construct_artifact_from_markdown_source", + return_value=Ok(expected), + ) + + result = markdown_parser.construct_artifact_from_bytes( + make.ARTIFACT_ID, + b"# Workflow", + RENDER_CONTEXT_VIEW, + default_section_kind=TEXT_KIND, + default_primary_section_kind=TEXT_KIND, + default_primary_section_id=SectionId("primary"), + ) + + assert result.is_ok() + assert result.unwrap() == expected + construct.assert_called_once() + + +class TestConstructArtifactFromMarkdownSource: + def test_constructs_artifact_with_default_primary_and_tail_config(self) -> None: + result = markdown_parser.construct_artifact_from_markdown_source( + make.ARTIFACT_ID, + "# Workflow\n\n## Step\n", + RENDER_CONTEXT_VIEW, + default_section_kind=LIB_TEXT_KIND, + default_primary_section_kind=LIB_TEXT_KIND, + default_primary_section_id=SectionId("primary"), + ) + + assert result.is_ok() + artifact = result.unwrap() + assert artifact.id == make.ARTIFACT_ID + assert [section.title for section in artifact.sections] == ["Workflow", "Step"] + assert artifact.sections[0].primary + + +class TestConstructSectionsFromMarkdown: + def test_parses_raw_string_kind_at_workspace_boundary(self) -> None: + section = SectionSource( + level=SectionLevel.h2, + title="Section", + configs=[ + CodeSource( + format="toml", + properties={"config": True}, + content='id = "section"\nkind = "donna.primitives.sections.text.Text"', + ) + ], + original_tokens=[], + analysis_tokens=[], + ) + + result = construct_sections_from_markdown( + artifact_id=ArtifactId("@/workflow.donna.md"), + sections=[section], + default_section_kind=TEXT_KIND, + primitive_overrides={TEXT_KIND: Text()}, + ) + + assert result.is_ok() + constructed_section = result.unwrap()[0] + assert constructed_section.id == SectionId("section") + assert constructed_section.kind == TEXT_KIND + assert constructed_section.title == "Section" + + def test_generates_missing_section_id(self, mocker: MockerFixture) -> None: + mocker.patch("uuid.uuid4", return_value=type("FakeUuid", (), {"hex": "abc"})()) + section = make.section_source(configs=[CodeSource(format="toml", properties={"config": True}, content="")]) + + result = construct_sections_from_markdown( + artifact_id=make.ARTIFACT_ID, + sections=[section], + default_section_kind=TEXT_KIND, + primitive_overrides={TEXT_KIND: Text()}, + ) + + assert result.is_ok() + assert result.unwrap()[0].id == SectionId("markdownabc") + + def test_collects_section_construction_errors(self) -> None: + section = make.section_source(configs=[CodeSource(format="toml", properties={"config": True}, content="")]) + + result = construct_sections_from_markdown( + artifact_id=make.ARTIFACT_ID, + sections=[section], + default_section_kind=TEXT_KIND, + primitive_overrides={TEXT_KIND: _FailingMarkdownPrimitive()}, + ) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.MarkdownArtifactWithoutSections) + + +class TestResolvePrimitive: + def test_returns_override_when_present(self) -> None: + primitive = Text() + + result = markdown_parser._resolve_primitive(TEXT_KIND, {TEXT_KIND: primitive}) + + assert result.is_ok() + assert result.unwrap() == primitive + + def test_uses_machine_resolver_without_override(self, mocker: MockerFixture) -> None: + primitive = Text() + resolve_primitive = mocker.patch.object(markdown_parser, "resolve_primitive", return_value=Ok(primitive)) + + result = markdown_parser._resolve_primitive(TEXT_KIND) + + assert result.is_ok() + assert result.unwrap() == primitive + resolve_primitive.assert_called_once_with(TEXT_KIND) + + +class TestParsePrimitiveId: + def test_returns_python_path_unchanged(self) -> None: + result = markdown_parser._parse_primitive_id(TEXT_KIND) + + assert result.is_ok() + assert result.unwrap() == TEXT_KIND + + def test_parses_string_python_path(self) -> None: + result = markdown_parser._parse_primitive_id(str(TEXT_KIND)) + + assert result.is_ok() + assert result.unwrap() == TEXT_KIND + + +class TestEnsureMarkdownConstructible: + def test_accepts_markdown_section_mixin(self) -> None: + result = markdown_parser._ensure_markdown_constructible(Text(), TEXT_KIND) + + assert result.is_ok() + + def test_rejects_primitive_without_markdown_support(self) -> None: + result = markdown_parser._ensure_markdown_constructible(Primitive(), TEXT_KIND) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.PrimitiveDoesNotSupportMarkdown) diff --git a/donna/workspaces/tests/test_paths.py b/donna/workspaces/tests/test_paths.py new file mode 100644 index 00000000..93b4da6a --- /dev/null +++ b/donna/workspaces/tests/test_paths.py @@ -0,0 +1,229 @@ +import pathlib + +from donna.domain.artifact_ids import ArtifactId +from donna.domain.paths import ProjectRootPath, ResolvedProjectPath, UntrustedPath +from donna.workspaces import paths +from donna.workspaces.paths import ( + normalize_artifact_id, + normalize_artifact_path, + normalize_artifact_section_id, + normalize_existing_path, + normalize_path, + resolve_project_path, + resolve_project_root, +) + + +class TestAppendNormalizedPart: + def test_appends_regular_parts_and_skips_current_dir(self) -> None: + parts = ["workflows"] + + assert paths._append_normalized_part(parts, ".") + assert paths._append_normalized_part(parts, "nested") + assert parts == ["workflows", "nested"] + + def test_rejects_empty_part_and_root_escape(self) -> None: + assert not paths._append_normalized_part([], "") + assert not paths._append_normalized_part([], "..") + + def test_parent_part_removes_previous_part(self) -> None: + parts = ["workflows", "nested"] + + assert paths._append_normalized_part(parts, "..") + assert parts == ["workflows"] + + +class TestNormalizeParts: + def test_returns_canonical_project_path(self) -> None: + assert paths._normalize_parts("workflows/./nested/../test.donna.md") == "@/workflows/test.donna.md" + + def test_uses_initial_parts_for_relative_artifact_paths(self) -> None: + assert ( + paths._normalize_parts("../plan.donna.md", initial_parts=("workflows", "rfc")) + == "@/workflows/plan.donna.md" + ) + + def test_rejects_empty_root_and_invalid_artifact_paths(self) -> None: + assert paths._normalize_parts("") is None + assert paths._normalize_parts(".") is None + assert paths._normalize_parts("invalid name.donna.md") is None + + +class TestResolveProjectRoot: + def test_returns_resolved_root_path(self, tmp_path: pathlib.Path) -> None: + project = tmp_path / "project" + project.mkdir() + root = project / ".." / "project" + + assert resolve_project_root(UntrustedPath(root)) == project + + +class TestNormalizeRootAnchored: + def test_normalizes_root_anchored_path(self) -> None: + assert paths._normalize_root_anchored("@/workflows/../plan.donna.md") == "@/plan.donna.md" + + def test_rejects_non_root_anchored_path(self) -> None: + assert paths._normalize_root_anchored("workflow.donna.md") is None + + +class TestResolveInsideProject: + def test_returns_resolved_project_path_inside_root(self, tmp_path: pathlib.Path) -> None: + project_file = tmp_path / "workflow.donna.md" + project_file.write_text("", encoding="utf-8") + + assert paths._resolve_inside_project( + UntrustedPath(project_file), + ProjectRootPath(tmp_path), + ) == ResolvedProjectPath(project_file) + + def test_rejects_project_root_and_outside_paths(self, tmp_path: pathlib.Path) -> None: + assert paths._resolve_inside_project(UntrustedPath(tmp_path), ProjectRootPath(tmp_path)) is None + assert paths._resolve_inside_project(UntrustedPath(tmp_path.parent), ProjectRootPath(tmp_path)) is None + + +class TestCanonicalFromResolved: + def test_returns_canonical_path_for_valid_resolved_path(self, tmp_path: pathlib.Path) -> None: + project_file = tmp_path / "workflow.donna.md" + + assert ( + paths._canonical_from_resolved(ResolvedProjectPath(project_file), ProjectRootPath(tmp_path)) + == "@/workflow.donna.md" + ) + + def test_rejects_invalid_artifact_path(self, tmp_path: pathlib.Path) -> None: + project_file = tmp_path / "invalid name.donna.md" + + assert paths._canonical_from_resolved(ResolvedProjectPath(project_file), ProjectRootPath(tmp_path)) is None + + +class TestResolveRootAnchoredPath: + def test_resolves_root_anchored_path_inside_project(self, tmp_path: pathlib.Path) -> None: + project_file = tmp_path / "workflow.donna.md" + project_file.write_text("", encoding="utf-8") + + assert paths._resolve_root_anchored_path("@/workflow.donna.md", ProjectRootPath(tmp_path)) == project_file + + def test_rejects_invalid_root_anchored_path(self, tmp_path: pathlib.Path) -> None: + assert paths._resolve_root_anchored_path("@/invalid name.donna.md", ProjectRootPath(tmp_path)) is None + + +class TestResolveProjectPath: + def test_resolves_root_anchored_path_inside_project(self, tmp_path: pathlib.Path) -> None: + project_file = tmp_path / "workflows" / "test.donna.md" + project_file.parent.mkdir() + project_file.write_text("", encoding="utf-8") + + assert resolve_project_path("@/workflows/test.donna.md", tmp_path) == project_file + + def test_resolves_absolute_path_inside_project(self, tmp_path: pathlib.Path) -> None: + project_file = tmp_path / "workflows" / "test.donna.md" + project_file.parent.mkdir() + project_file.write_text("", encoding="utf-8") + + assert resolve_project_path(str(project_file), tmp_path) == project_file + + def test_rejects_root_escape_and_project_root(self, tmp_path: pathlib.Path) -> None: + assert resolve_project_path("@/../outside.donna.md", tmp_path) is None + assert resolve_project_path("@/.", tmp_path) is None + + def test_rejects_absolute_path_when_not_allowed(self, tmp_path: pathlib.Path) -> None: + project_file = tmp_path / "workflow.donna.md" + project_file.write_text("", encoding="utf-8") + + assert resolve_project_path(str(project_file), tmp_path, allow_absolute=False) is None + + +class TestNormalizePath: + def test_normalizes_root_anchored_path(self, tmp_path: pathlib.Path) -> None: + assert normalize_path("@/workflows/./nested/../test.donna.md", tmp_path) == "@/workflows/test.donna.md" + + def test_normalizes_absolute_path_inside_project(self, tmp_path: pathlib.Path) -> None: + project_file = tmp_path / "workflows" / "test.donna.md" + project_file.parent.mkdir() + project_file.write_text("", encoding="utf-8") + + assert normalize_path(str(project_file), tmp_path) == "@/workflows/test.donna.md" + + def test_normalizes_relative_path_from_cwd(self, tmp_path: pathlib.Path) -> None: + cwd = tmp_path / "workflows" + cwd.mkdir() + + assert normalize_path("test.donna.md", tmp_path, cwd=cwd) == "@/workflows/test.donna.md" + + def test_rejects_path_outside_project(self, tmp_path: pathlib.Path) -> None: + outside = tmp_path.parent / "outside.donna.md" + + assert normalize_path(str(outside), tmp_path) is None + assert normalize_path("../outside.donna.md", tmp_path, cwd=tmp_path) is None + + +class TestNormalizeExistingPath: + def test_normalizes_existing_file(self, tmp_path: pathlib.Path) -> None: + project_file = tmp_path / "workflows" / "test.donna.md" + project_file.parent.mkdir() + project_file.write_text("", encoding="utf-8") + + assert normalize_existing_path(UntrustedPath(project_file), tmp_path) == "@/workflows/test.donna.md" + + def test_rejects_project_root(self, tmp_path: pathlib.Path) -> None: + assert normalize_existing_path(UntrustedPath(tmp_path), tmp_path) is None + + +class TestNormalizeArtifactPath: + def test_normalizes_relative_to_artifact_file(self, tmp_path: pathlib.Path) -> None: + relative_to = ArtifactId("@/workflows/rfc/do.donna.md") + + assert ( + normalize_artifact_path("../plan.donna.md", tmp_path, relative_to=relative_to) + == "@/workflows/plan.donna.md" + ) + + def test_uses_normalize_path_without_artifact_base(self, tmp_path: pathlib.Path) -> None: + assert normalize_artifact_path("@/workflow.donna.md", tmp_path) == "@/workflow.donna.md" + + def test_rejects_invalid_artifact_path(self, tmp_path: pathlib.Path) -> None: + assert ( + normalize_artifact_path("../outside.donna.md", tmp_path, relative_to=ArtifactId("@/file.donna.md")) is None + ) + + assert normalize_artifact_path("", tmp_path) is None + assert normalize_artifact_path(None, tmp_path) is None # type: ignore[arg-type] + + +class TestNormalizeFromArtifact: + def test_normalizes_root_anchored_and_artifact_relative_paths(self) -> None: + relative_to = ArtifactId("@/workflows/rfc/do.donna.md") + + assert paths._normalize_from_artifact("@/plan.donna.md", relative_to) == "@/plan.donna.md" + assert paths._normalize_from_artifact("../plan.donna.md", relative_to) == "@/workflows/plan.donna.md" + + +class TestNormalizeArtifactId: + def test_returns_artifact_id_for_valid_path(self, tmp_path: pathlib.Path) -> None: + assert normalize_artifact_id("@/workflow.donna.md", tmp_path) == ArtifactId("@/workflow.donna.md") + + def test_returns_artifact_id_for_relative_path_from_cwd(self, tmp_path: pathlib.Path) -> None: + cwd = tmp_path / "workflows" + cwd.mkdir() + + assert normalize_artifact_id("test.donna.md", tmp_path, cwd=cwd) == ArtifactId("@/workflows/test.donna.md") + + def test_rejects_invalid_artifact_id_path(self, tmp_path: pathlib.Path) -> None: + assert normalize_artifact_id("@/workflow", tmp_path) is None + + +class TestNormalizeArtifactSectionId: + def test_returns_artifact_section_id_for_valid_input(self, tmp_path: pathlib.Path) -> None: + assert normalize_artifact_section_id("@/workflow.donna.md:step", tmp_path) == "@/workflow.donna.md:step" + + def test_returns_artifact_section_id_relative_to_artifact_file(self, tmp_path: pathlib.Path) -> None: + relative_to = ArtifactId("@/workflows/rfc/do.donna.md") + + assert ( + normalize_artifact_section_id("../plan.donna.md:step", tmp_path, relative_to=relative_to) + == "@/workflows/plan.donna.md:step" + ) + + def test_rejects_missing_or_invalid_section(self, tmp_path: pathlib.Path) -> None: + assert normalize_artifact_section_id("@/workflow.donna.md", tmp_path) is None + assert normalize_artifact_section_id("@/workflow.donna.md:---", tmp_path) is None diff --git a/donna/workspaces/tests/test_sessions.py b/donna/workspaces/tests/test_sessions.py new file mode 100644 index 00000000..26d2fdfe --- /dev/null +++ b/donna/workspaces/tests/test_sessions.py @@ -0,0 +1,112 @@ +import pathlib + +from pytest_mock import MockerFixture + +from donna.domain.constants import STATE_FILE_NAME +from donna.domain.paths import RelativeProjectPath +from donna.workspaces import sessions +from donna.workspaces.config import Config +from donna.workspaces.files import FileFingerprint + + +def _patch_session_globals(mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + mocker.patch("donna.workspaces.sessions.project_dir", return_value=tmp_path) + mocker.patch( + "donna.workspaces.sessions.config", + return_value=Config(session_dir=RelativeProjectPath(pathlib.Path(".session/donna"))), + ) + + +class TestDir: + def test_dir__creates_session_directory(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + _patch_session_globals(mocker, tmp_path) + + session_dir = sessions.dir() + + assert session_dir == tmp_path / ".session" / "donna" + assert session_dir.is_dir() + + +class TestPath: + def test_returns_configured_session_path_without_creating_it( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + _patch_session_globals(mocker, tmp_path) + + assert sessions._path() == tmp_path / ".session" / "donna" + assert not sessions._path().exists() + + +class TestStatePath: + def test_returns_state_file_path_under_session_dir(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + _patch_session_globals(mocker, tmp_path) + + assert sessions._state_path() == tmp_path / ".session" / "donna" / STATE_FILE_NAME + + +class TestEnsureDir: + def test_ensure_dir__creates_session_directory(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + _patch_session_globals(mocker, tmp_path) + + sessions.ensure_dir() + + assert (tmp_path / ".session" / "donna").is_dir() + + +class TestResetDir: + def test_reset_dir__removes_existing_content_and_recreates_directory( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + _patch_session_globals(mocker, tmp_path) + state_path = tmp_path / ".session" / "donna" / STATE_FILE_NAME + state_path.parent.mkdir(parents=True) + state_path.write_bytes(b"state") + + sessions.reset_dir() + + assert state_path.parent.is_dir() + assert not state_path.exists() + + +class TestReadState: + def test_read_state__returns_none_for_missing_state_file( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + _patch_session_globals(mocker, tmp_path) + + assert sessions.read_state() is None + + def test_read_state__returns_state_bytes(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + _patch_session_globals(mocker, tmp_path) + sessions.write_state(b"state") + + assert sessions.read_state() == b"state" + + +class TestWriteState: + def test_write_state__writes_state_bytes(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + _patch_session_globals(mocker, tmp_path) + sessions.dir() + + sessions.write_state(b"state") + + assert (tmp_path / ".session" / "donna" / STATE_FILE_NAME).read_bytes() == b"state" + + +class TestStateFingerprint: + def test_state_fingerprint__returns_none_for_missing_state_file( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + _patch_session_globals(mocker, tmp_path) + + assert sessions.state_fingerprint() is None + + def test_state_fingerprint__returns_fingerprint_for_state_file( + self, mocker: MockerFixture, tmp_path: pathlib.Path + ) -> None: + _patch_session_globals(mocker, tmp_path) + sessions.write_state(b"state") + + assert sessions.state_fingerprint() == FileFingerprint.from_path( + tmp_path / ".session" / "donna" / STATE_FILE_NAME + ) diff --git a/donna/workspaces/tests/test_symbol_coverage.py b/donna/workspaces/tests/test_symbol_coverage.py new file mode 100644 index 00000000..fade7cf2 --- /dev/null +++ b/donna/workspaces/tests/test_symbol_coverage.py @@ -0,0 +1,67 @@ +import ast +import pathlib + + +def _pascal_case(name: str) -> str: + return "".join(part.capitalize() for part in name.strip("_").split("_")) + + +def _expected_test_class_name(symbol_name: str, *, symbol_is_class: bool) -> str: + if symbol_is_class: + return f"Test{symbol_name.lstrip('_')}" + + return f"Test{_pascal_case(symbol_name)}" + + +def _expected_test_class_for_node(node: ast.AST) -> str | None: + if isinstance(node, ast.ClassDef): + return _expected_test_class_name(node.name, symbol_is_class=True) + + if isinstance(node, ast.FunctionDef): + return _expected_test_class_name(node.name, symbol_is_class=False) + + return None + + +def _production_module_symbols(path: pathlib.Path) -> list[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + return [class_name for node in tree.body if (class_name := _expected_test_class_for_node(node)) is not None] + + +def _production_module_paths() -> list[pathlib.Path]: + workspace_dir = pathlib.Path(__file__).parents[1] + return [path for path in sorted(workspace_dir.glob("*.py")) if path.name != "__init__.py"] + + +def _production_symbols() -> dict[str, list[str]]: + symbols = {} + + for path in _production_module_paths(): + symbols[path.name] = _production_module_symbols(path) + + return symbols + + +def _test_class_names() -> set[str]: + tests_dir = pathlib.Path(__file__).parent + names = set() + + for path in tests_dir.glob("test_*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.ClassDef): + names.add(node.name) + + return names + + +class TestWorkspaceSymbolCoverage: + def test_each_module_level_function_and_class_has_a_test_class(self) -> None: + test_classes = _test_class_names() + missing = { + module_name: [class_name for class_name in expected_classes if class_name not in test_classes] + for module_name, expected_classes in _production_symbols().items() + } + missing = {module_name: names for module_name, names in missing.items() if names} + + assert missing == {} diff --git a/donna/workspaces/tests/test_templates.py b/donna/workspaces/tests/test_templates.py new file mode 100644 index 00000000..c4ab7239 --- /dev/null +++ b/donna/workspaces/tests/test_templates.py @@ -0,0 +1,199 @@ +import pytest +from pytest_mock import MockerFixture + +from donna.core.errors import EnvironmentErrorsProxy, ErrorsList +from donna.core.result import Err, Ok, Result +from donna.machine.templates import Directive, RenderMode +from donna.machine.templates_context import DirectiveContext +from donna.workspaces import errors as workspace_errors +from donna.workspaces import templates +from donna.workspaces.artifacts import ArtifactRenderContext +from donna.workspaces.templates import DirectivePathBuilder, render +from donna.workspaces.tests import make + + +class _Directive(Directive): + analyze_id: str = "sample" + + def render_view(self, context: DirectiveContext, *argv: object) -> Result[object, ErrorsList]: + return Ok(f"{context['render_mode']}:{','.join(str(arg) for arg in argv)}") + + +class _FailingDirective(Directive): + analyze_id: str = "failing" + + def render_view(self, context: DirectiveContext, *argv: object) -> Result[object, ErrorsList]: + return Err([workspace_errors.MarkdownArtifactWithoutSections(artifact_id=make.ARTIFACT_ID)]) + + +class _ExplodingDirective(Directive): + analyze_id: str = "exploding" + + def render_view(self, context: DirectiveContext, *argv: object) -> Result[object, ErrorsList]: + raise RuntimeError("boom") + + +sample_directive = _Directive() +failing_directive = _FailingDirective() +exploding_directive = _ExplodingDirective() +not_directive = object() + + +class TestIsImportableModule: + def test_returns_whether_module_can_be_imported(self) -> None: + assert templates._is_importable_module("donna.workspaces.tests.test_templates") + assert not templates._is_importable_module("donna.workspaces.tests.missing") + + +class TestDirectivePathBuilder: + def test_getattr__extends_directive_path(self) -> None: + builder = DirectivePathBuilder(("donna", "workspaces")).tests.test_templates.sample_directive + + result = builder({"render_mode": RenderMode.view, "artifact_id": make.ARTIFACT_ID}, "value") + + assert result == "view:value" + + def test_getitem__extends_directive_path(self) -> None: + builder = DirectivePathBuilder(("donna", "workspaces"))["tests"]["test_templates"]["sample_directive"] + + result = builder({"render_mode": RenderMode.view, "artifact_id": make.ARTIFACT_ID}, "value") + + assert result == "view:value" + + def test_call__applies_directive(self) -> None: + builder = DirectivePathBuilder(("donna", "workspaces", "tests", "test_templates", "sample_directive")) + + result = builder({"render_mode": RenderMode.view, "artifact_id": make.ARTIFACT_ID}, "value") + + assert result == "view:value" + + def test_call__reports_incomplete_path(self) -> None: + builder = DirectivePathBuilder(("donna",)) + + with pytest.raises(EnvironmentErrorsProxy) as exception_info: + builder({"artifact_id": make.ARTIFACT_ID}) + + errors = exception_info.value.arguments["errors"] + assert isinstance(errors, list) + error = errors[0] + assert isinstance(error, workspace_errors.DirectivePathIncomplete) + assert error.path == "donna" + + def test_call__reports_unimportable_module(self) -> None: + builder = DirectivePathBuilder(("donna", "workspaces", "tests", "missing", "sample_directive")) + + with pytest.raises(EnvironmentErrorsProxy) as exception_info: + builder({"artifact_id": make.ARTIFACT_ID}) + + errors = exception_info.value.arguments["errors"] + assert isinstance(errors, list) + error = errors[0] + assert isinstance(error, workspace_errors.DirectiveModuleNotImportable) + assert error.module_path == "donna.workspaces.tests.missing" + + def test_call__reports_unexpected_import_error(self, mocker: MockerFixture) -> None: + builder = DirectivePathBuilder(("donna", "workspaces", "tests", "test_templates", "sample_directive")) + mocker.patch("importlib.import_module", side_effect=RuntimeError("boom")) + + with pytest.raises(EnvironmentErrorsProxy) as exception_info: + builder({"artifact_id": make.ARTIFACT_ID}) + + errors = exception_info.value.arguments["errors"] + assert isinstance(errors, list) + error = errors[0] + assert isinstance(error, workspace_errors.DirectiveUnexpectedError) + assert error.directive_path == "donna.workspaces.tests.test_templates.sample_directive" + + def test_call__reports_missing_directive(self) -> None: + builder = DirectivePathBuilder(("donna", "workspaces", "tests", "test_templates", "missing")) + + with pytest.raises(EnvironmentErrorsProxy) as exception_info: + builder({"artifact_id": make.ARTIFACT_ID}) + + errors = exception_info.value.arguments["errors"] + assert isinstance(errors, list) + error = errors[0] + assert isinstance(error, workspace_errors.DirectiveNotAvailable) + assert error.module_path == "donna.workspaces.tests.test_templates" + assert error.directive_name == "missing" + + def test_call__reports_non_directive_object(self) -> None: + builder = DirectivePathBuilder(("donna", "workspaces", "tests", "test_templates", "not_directive")) + + with pytest.raises(EnvironmentErrorsProxy) as exception_info: + builder({"artifact_id": make.ARTIFACT_ID}) + + errors = exception_info.value.arguments["errors"] + assert isinstance(errors, list) + error = errors[0] + assert isinstance(error, workspace_errors.DirectiveNotDirective) + + def test_call__passes_directive_result_errors(self) -> None: + builder = DirectivePathBuilder(("donna", "workspaces", "tests", "test_templates", "failing_directive")) + + with pytest.raises(EnvironmentErrorsProxy) as exception_info: + builder({"render_mode": RenderMode.view, "artifact_id": make.ARTIFACT_ID}) + + errors = exception_info.value.arguments["errors"] + assert isinstance(errors, list) + error = errors[0] + assert isinstance(error, workspace_errors.MarkdownArtifactWithoutSections) + assert error.artifact_id == make.ARTIFACT_ID + + def test_call__reports_unexpected_directive_error(self) -> None: + builder = DirectivePathBuilder(("donna", "workspaces", "tests", "test_templates", "exploding_directive")) + + with pytest.raises(EnvironmentErrorsProxy) as exception_info: + builder({"render_mode": RenderMode.view, "artifact_id": make.ARTIFACT_ID}) + + errors = exception_info.value.arguments["errors"] + assert isinstance(errors, list) + error = errors[0] + assert isinstance(error, workspace_errors.DirectiveUnexpectedError) + assert error.directive_path == "donna.workspaces.tests.test_templates.exploding_directive" + + +class TestDirectivePathUndefined: + def test_getattr__returns_directive_builder_for_importable_module(self) -> None: + value = templates.DirectivePathUndefined(name="donna").workspaces + + assert isinstance(value, DirectivePathBuilder) + + def test_getattr__returns_undefined_for_non_importable_module(self) -> None: + value = templates.DirectivePathUndefined(name="missing").directive + + assert not isinstance(value, DirectivePathBuilder) + + +class TestEnv: + def test_env__returns_cached_environment_with_directive_undefined(self) -> None: + environment = templates.env() + + assert environment is templates.env() + assert environment.undefined is templates.DirectivePathUndefined + + +class TestRender: + def test_render__applies_template_directives(self) -> None: + context = ArtifactRenderContext(primary_mode=RenderMode.view) + + result = render( + make.ARTIFACT_ID, + "{{ donna.workspaces.tests.test_templates.sample_directive('value') }}", + context, + ) + + assert result.is_ok() + assert result.unwrap() == "view:value" + + def test_render__returns_directive_errors(self) -> None: + context = ArtifactRenderContext(primary_mode=RenderMode.view) + + result = render( + make.ARTIFACT_ID, + "{{ donna.workspaces.tests.test_templates.failing_directive() }}", + context, + ) + + assert result.is_err() + assert isinstance(result.unwrap_err()[0], workspace_errors.MarkdownArtifactWithoutSections) diff --git a/donna/workspaces/tests/test_utils.py b/donna/workspaces/tests/test_utils.py new file mode 100644 index 00000000..e2af278b --- /dev/null +++ b/donna/workspaces/tests/test_utils.py @@ -0,0 +1,42 @@ +import pathlib + +from pytest_mock import MockerFixture + +from donna.workspaces import errors as workspace_errors +from donna.workspaces import utils + + +class TestFirstProjectDirWithConfig: + def test_returns_nearest_parent_with_config(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + project = tmp_path / "project" + nested = project / "nested" + nested.mkdir(parents=True) + (project / "donna.toml").write_text("version = 1", encoding="utf-8") + mocker.patch("pathlib.Path.cwd", return_value=nested) + + assert utils.first_project_dir_with_config("donna.toml") == project + + def test_returns_none_when_config_is_not_found(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + mocker.patch("pathlib.Path.cwd", return_value=tmp_path) + + assert utils.first_project_dir_with_config("donna.toml") is None + + +class TestDiscoverProjectDir: + def test_returns_discovered_project_dir(self, mocker: MockerFixture, tmp_path: pathlib.Path) -> None: + mocker.patch.object(utils, "first_project_dir_with_config", return_value=tmp_path) + + result = utils.discover_project_dir("donna.toml") + + assert result.is_ok() + assert result.unwrap() == tmp_path + + def test_reports_missing_config(self, mocker: MockerFixture) -> None: + mocker.patch.object(utils, "first_project_dir_with_config", return_value=None) + + result = utils.discover_project_dir("donna.toml") + + assert result.is_err() + error = result.unwrap_err()[0] + assert isinstance(error, workspace_errors.WorkspaceConfigNotDiscovered) + assert error.config_name == "donna.toml" diff --git a/donna/workspaces/utils.py b/donna/workspaces/utils.py new file mode 100644 index 00000000..4108cb04 --- /dev/null +++ b/donna/workspaces/utils.py @@ -0,0 +1,26 @@ +import pathlib + +from donna.core.errors import ErrorsList +from donna.core.result import Err, Ok, Result +from donna.domain.paths import ProjectRootPath +from donna.workspaces import errors as workspace_errors + + +def first_project_dir_with_config(config_name: str) -> ProjectRootPath | None: + current_dir = pathlib.Path.cwd().resolve() + + for parent in [current_dir] + list(current_dir.parents): + config_path = parent / config_name + if config_path.is_file(): + return ProjectRootPath(parent) + + return None + + +def discover_project_dir(config_name: str) -> Result[ProjectRootPath, ErrorsList]: + project_dir = first_project_dir_with_config(config_name) + + if project_dir is None: + return Err([workspace_errors.WorkspaceConfigNotDiscovered(config_name=config_name)]) + + return Ok(project_dir) diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 31284d48..00000000 --- a/poetry.lock +++ /dev/null @@ -1,1314 +0,0 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "attrs" -version = "25.4.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, - {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, -] - -[[package]] -name = "autoflake" -version = "2.3.1" -description = "Removes unused imports and unused variables" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840"}, - {file = "autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e"}, -] - -[package.dependencies] -pyflakes = ">=3.0.0" - -[[package]] -name = "bandit" -version = "1.9.2" -description = "Security oriented static analyser for python code." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "bandit-1.9.2-py3-none-any.whl", hash = "sha256:bda8d68610fc33a6e10b7a8f1d61d92c8f6c004051d5e946406be1fb1b16a868"}, - {file = "bandit-1.9.2.tar.gz", hash = "sha256:32410415cd93bf9c8b91972159d5cf1e7f063a9146d70345641cd3877de348ce"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} -PyYAML = ">=5.3.1" -rich = "*" -stevedore = ">=1.20.0" - -[package.extras] -baseline = ["GitPython (>=3.1.30)"] -sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] -test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] -toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] -yaml = ["PyYAML"] - -[[package]] -name = "black" -version = "25.12.0" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "black-25.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f85ba1ad15d446756b4ab5f3044731bf68b777f8f9ac9cdabd2425b97cd9c4e8"}, - {file = "black-25.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:546eecfe9a3a6b46f9d69d8a642585a6eaf348bcbbc4d87a19635570e02d9f4a"}, - {file = "black-25.12.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17dcc893da8d73d8f74a596f64b7c98ef5239c2cd2b053c0f25912c4494bf9ea"}, - {file = "black-25.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:09524b0e6af8ba7a3ffabdfc7a9922fb9adef60fed008c7cd2fc01f3048e6e6f"}, - {file = "black-25.12.0-cp310-cp310-win_arm64.whl", hash = "sha256:b162653ed89eb942758efeb29d5e333ca5bb90e5130216f8369857db5955a7da"}, - {file = "black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a"}, - {file = "black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be"}, - {file = "black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b"}, - {file = "black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5"}, - {file = "black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655"}, - {file = "black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a"}, - {file = "black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783"}, - {file = "black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59"}, - {file = "black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892"}, - {file = "black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43"}, - {file = "black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5"}, - {file = "black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f"}, - {file = "black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf"}, - {file = "black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d"}, - {file = "black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce"}, - {file = "black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5"}, - {file = "black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f"}, - {file = "black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f"}, - {file = "black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83"}, - {file = "black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b"}, - {file = "black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828"}, - {file = "black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -pytokens = ">=0.3.0" - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - -[[package]] -name = "changy" -version = "0.4.3" -description = "Simplest changelog manager, oriented to human editing, not to special message formatting in commits and tags.." -optional = false -python-versions = "<4.0,>=3.11" -groups = ["dev"] -files = [ - {file = "changy-0.4.3-py3-none-any.whl", hash = "sha256:53d34778a0b525c154fb0c39ef02fb41c2179ef2915f7f63cfeaa0a241d0facc"}, - {file = "changy-0.4.3.tar.gz", hash = "sha256:1a38ce222243f3bbf4f51a890bc14e393521954970244dc7d669e2bcbe523f85"}, -] - -[package.dependencies] -jinja2 = ">=3.1" -pydantic = ">=2.5" -pydantic-settings = ">=2.1" -typer = ">=0.9" - -[[package]] -name = "click" -version = "8.3.1" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, - {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "codespell" -version = "2.4.1" -description = "Fix common misspellings in text files" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425"}, - {file = "codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5"}, -] - -[package.extras] -dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] -hard-encoding-detection = ["chardet"] -toml = ["tomli ; python_version < \"3.11\""] -types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] - -[[package]] -name = "cognitive-complexity" -version = "1.3.0" -description = "Library to calculate Python functions cognitive complexity via code" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "cognitive_complexity-1.3.0.tar.gz", hash = "sha256:a0cfbd47dee0b19f4056f892389f501694b205c3af69fb703cc744541e03dde5"}, -] - -[package.dependencies] -setuptools = "*" - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] -markers = "platform_system == \"Windows\"" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "eradicate" -version = "2.3.0" -description = "Removes commented-out code." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "eradicate-2.3.0-py3-none-any.whl", hash = "sha256:2b29b3dd27171f209e4ddd8204b70c02f0682ae95eecb353f10e8d72b149c63e"}, - {file = "eradicate-2.3.0.tar.gz", hash = "sha256:06df115be3b87d0fc1c483db22a2ebb12bcf40585722810d809cc770f5031c37"}, -] - -[[package]] -name = "flake8" -version = "7.3.0" -description = "the modular source code checker: pep8 pyflakes and co" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, - {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.14.0,<2.15.0" -pyflakes = ">=3.4.0,<3.5.0" - -[[package]] -name = "flake8-absolute-import" -version = "1.0.0.3" -description = "flake8 plugin to require absolute imports" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "flake8_absolute_import-1.0.0.3-py3-none-any.whl", hash = "sha256:de62300044f5060f32dbe5cbfa0ad7b345231323c572077997d9c8ae6241b189"}, - {file = "flake8_absolute_import-1.0.0.3.tar.gz", hash = "sha256:84b2bb2dbad98227a333ca4ba30357e073117ecd6b068b46ac5906fa9a7ed39e"}, -] - -[package.dependencies] -flake8 = ">=5.0" - -[[package]] -name = "flake8-annotations-complexity" -version = "0.1.0" -description = "A flake8 extension that checks for type annotations complexity" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "flake8_annotations_complexity-0.1.0-py3-none-any.whl", hash = "sha256:102d75f5ba0c667cde9c563062f4e7c616ca84bf5bfdc9c1f960a2655133ce35"}, - {file = "flake8_annotations_complexity-0.1.0.tar.gz", hash = "sha256:98b86ef87de5331d2b61f3cf472dcf6b8ff1a5ddde46f78bc894b464f06e1414"}, -] - -[package.dependencies] -flake8 = "*" - -[[package]] -name = "flake8-bandit" -version = "4.1.1" -description = "Automated security testing with bandit and flake8." -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "flake8_bandit-4.1.1-py3-none-any.whl", hash = "sha256:4c8a53eb48f23d4ef1e59293657181a3c989d0077c9952717e98a0eace43e06d"}, - {file = "flake8_bandit-4.1.1.tar.gz", hash = "sha256:068e09287189cbfd7f986e92605adea2067630b75380c6b5733dab7d87f9a84e"}, -] - -[package.dependencies] -bandit = ">=1.7.3" -flake8 = ">=5.0.0" - -[[package]] -name = "flake8-cognitive-complexity" -version = "0.1.0" -description = "An extension for flake8 that validates cognitive functions complexity" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "flake8_cognitive_complexity-0.1.0.tar.gz", hash = "sha256:f202df054e4f6ff182b659c261922b9c684628a47beb19cb0973c50d6a7831c1"}, -] - -[package.dependencies] -cognitive_complexity = "*" -setuptools = "*" - -[[package]] -name = "flake8-docstrings" -version = "1.7.0" -description = "Extension for flake8 which uses pydocstyle to check docstrings" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75"}, - {file = "flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af"}, -] - -[package.dependencies] -flake8 = ">=3" -pydocstyle = ">=2.1" - -[[package]] -name = "flake8-eradicate" -version = "1.5.0" -description = "Flake8 plugin to find commented out code" -optional = false -python-versions = ">=3.8,<4.0" -groups = ["dev"] -files = [ - {file = "flake8_eradicate-1.5.0-py3-none-any.whl", hash = "sha256:18acc922ad7de623f5247c7d5595da068525ec5437dd53b22ec2259b96ce9d22"}, - {file = "flake8_eradicate-1.5.0.tar.gz", hash = "sha256:aee636cb9ecb5594a7cd92d67ad73eb69909e5cc7bd81710cf9d00970f3983a6"}, -] - -[package.dependencies] -attrs = "*" -eradicate = ">=2.0,<3.0" -flake8 = ">5" - -[[package]] -name = "flake8-functions" -version = "0.0.8" -description = "A flake8 extension that checks functions" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "flake8_functions-0.0.8-py3-none-any.whl", hash = "sha256:e1a88aa634d1aff6973f8c9dd64f30ab2beaac661e52eea96929ccc7ee7f64df"}, - {file = "flake8_functions-0.0.8.tar.gz", hash = "sha256:5446626673a9faecbf389fb411b90bdc87b002c387b72dc097b208e7a58f2a1c"}, -] - -[package.dependencies] -mr-proper = "*" -setuptools = "*" - -[[package]] -name = "flake8-print" -version = "5.0.0" -description = "print statement checker plugin for flake8" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "flake8-print-5.0.0.tar.gz", hash = "sha256:76915a2a389cc1c0879636c219eb909c38501d3a43cc8dae542081c9ba48bdf9"}, - {file = "flake8_print-5.0.0-py3-none-any.whl", hash = "sha256:84a1a6ea10d7056b804221ac5e62b1cee1aefc897ce16f2e5c42d3046068f5d8"}, -] - -[package.dependencies] -flake8 = ">=3.0" -pycodestyle = "*" - -[[package]] -name = "flake8-pyproject" -version = "1.2.4" -description = "Flake8 plug-in loading the configuration from pyproject.toml" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "flake8_pyproject-1.2.4-py3-none-any.whl", hash = "sha256:ea34c057f9a9329c76d98723bb2bb498cc6ba8ff9872c4d19932d48c91249a77"}, -] - -[package.dependencies] -Flake8 = ">=5" - -[package.extras] -dev = ["Flit (>=3.4)", "pyTest (>=7)", "pyTest-cov (>=7) ; python_version >= \"3.10\""] - -[[package]] -name = "flake8-pytest" -version = "1.4" -description = "pytest assert checker plugin for flake8" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "flake8-pytest-1.4.tar.gz", hash = "sha256:19f543b2d1cc89d61b76f19d0a9e58e9a110a035175f701b3425c363a7732c56"}, - {file = "flake8_pytest-1.4-py2.py3-none-any.whl", hash = "sha256:97328f258ffad9fe18babb3b0714a16b121505ad3ac87d4e33020874555d0784"}, -] - -[package.dependencies] -flake8 = "*" - -[[package]] -name = "isort" -version = "7.0.0" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.10.0" -groups = ["dev"] -files = [ - {file = "isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1"}, - {file = "isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187"}, -] - -[package.extras] -colors = ["colorama"] -plugins = ["setuptools"] - -[[package]] -name = "jinja2" -version = "3.1.6" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, - {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "librt" -version = "0.7.5" -description = "Mypyc runtime library" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "platform_python_implementation != \"PyPy\"" -files = [ - {file = "librt-0.7.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81056e01bba1394f1d92904ec61a4078f66df785316275edbaf51d90da8c6e26"}, - {file = "librt-0.7.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d7c72c8756eeb3aefb1b9e3dac7c37a4a25db63640cac0ab6fc18e91a0edf05a"}, - {file = "librt-0.7.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ddc4a16207f88f9597b397fc1f60781266d13b13de922ff61c206547a29e4bbd"}, - {file = "librt-0.7.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63055d3dda433ebb314c9f1819942f16a19203c454508fdb2d167613f7017169"}, - {file = "librt-0.7.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f85f9b5db87b0f52e53c68ad2a0c5a53e00afa439bd54a1723742a2b1021276"}, - {file = "librt-0.7.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c566a4672564c5d54d8ab65cdaae5a87ee14c1564c1a2ddc7a9f5811c750f023"}, - {file = "librt-0.7.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fee15c2a190ef389f14928135c6fb2d25cd3fdb7887bfd9a7b444bbdc8c06b96"}, - {file = "librt-0.7.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:584cb3e605ec45ba350962cec853e17be0a25a772f21f09f1e422f7044ae2a7d"}, - {file = "librt-0.7.5-cp310-cp310-win32.whl", hash = "sha256:9c08527055fbb03c641c15bbc5b79dd2942fb6a3bd8dabf141dd7e97eeea4904"}, - {file = "librt-0.7.5-cp310-cp310-win_amd64.whl", hash = "sha256:dd810f2d39c526c42ea205e0addad5dc08ef853c625387806a29d07f9d150d9b"}, - {file = "librt-0.7.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f952e1a78c480edee8fb43aa2bf2e84dcd46c917d44f8065b883079d3893e8fc"}, - {file = "librt-0.7.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75965c1f4efb7234ff52a58b729d245a21e87e4b6a26a0ec08052f02b16274e4"}, - {file = "librt-0.7.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:732e0aa0385b59a1b2545159e781c792cc58ce9c134249233a7c7250a44684c4"}, - {file = "librt-0.7.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdde31759bd8888f3ef0eebda80394a48961328a17c264dce8cc35f4b9cde35d"}, - {file = "librt-0.7.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3146d52465b3b6397d25d513f428cb421c18df65b7378667bb5f1e3cc45805"}, - {file = "librt-0.7.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:29c8d2fae11d4379ea207ba7fc69d43237e42cf8a9f90ec6e05993687e6d648b"}, - {file = "librt-0.7.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bb41f04046b4f22b1e7ba5ef513402cd2e3477ec610e5f92d38fe2bba383d419"}, - {file = "librt-0.7.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8bb7883c1e94ceb87c2bf81385266f032da09cd040e804cc002f2c9d6b842e2f"}, - {file = "librt-0.7.5-cp311-cp311-win32.whl", hash = "sha256:84d4a6b9efd6124f728558a18e79e7cc5c5d4efc09b2b846c910de7e564f5bad"}, - {file = "librt-0.7.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab4b0d3bee6f6ff7017e18e576ac7e41a06697d8dea4b8f3ab9e0c8e1300c409"}, - {file = "librt-0.7.5-cp311-cp311-win_arm64.whl", hash = "sha256:730be847daad773a3c898943cf67fb9845a3961d06fb79672ceb0a8cd8624cfa"}, - {file = "librt-0.7.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba1077c562a046208a2dc6366227b3eeae8f2c2ab4b41eaf4fd2fa28cece4203"}, - {file = "librt-0.7.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:654fdc971c76348a73af5240d8e2529265b9a7ba6321e38dd5bae7b0d4ab3abe"}, - {file = "librt-0.7.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6b7b58913d475911f6f33e8082f19dd9b120c4f4a5c911d07e395d67b81c6982"}, - {file = "librt-0.7.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e0fd344bad57026a8f4ccfaf406486c2fc991838050c2fef156170edc3b775"}, - {file = "librt-0.7.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46aa91813c267c3f60db75d56419b42c0c0b9748ec2c568a0e3588e543fb4233"}, - {file = "librt-0.7.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ddc0ab9dbc5f9ceaf2bf7a367bf01f2697660e908f6534800e88f43590b271db"}, - {file = "librt-0.7.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7a488908a470451338607650f1c064175094aedebf4a4fa37890682e30ce0b57"}, - {file = "librt-0.7.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e47fc52602ffc374e69bf1b76536dc99f7f6dd876bd786c8213eaa3598be030a"}, - {file = "librt-0.7.5-cp312-cp312-win32.whl", hash = "sha256:cda8b025875946ffff5a9a7590bf9acde3eb02cb6200f06a2d3e691ef3d9955b"}, - {file = "librt-0.7.5-cp312-cp312-win_amd64.whl", hash = "sha256:b591c094afd0ffda820e931148c9e48dc31a556dc5b2b9b3cc552fa710d858e4"}, - {file = "librt-0.7.5-cp312-cp312-win_arm64.whl", hash = "sha256:532ddc6a8a6ca341b1cd7f4d999043e4c71a212b26fe9fd2e7f1e8bb4e873544"}, - {file = "librt-0.7.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b1795c4b2789b458fa290059062c2f5a297ddb28c31e704d27e161386469691a"}, - {file = "librt-0.7.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2fcbf2e135c11f721193aa5f42ba112bb1046afafbffd407cbc81d8d735c74d0"}, - {file = "librt-0.7.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c039bbf79a9a2498404d1ae7e29a6c175e63678d7a54013a97397c40aee026c5"}, - {file = "librt-0.7.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3919c9407faeeee35430ae135e3a78acd4ecaaaa73767529e2c15ca1d73ba325"}, - {file = "librt-0.7.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26b46620e1e0e45af510d9848ea0915e7040605dd2ae94ebefb6c962cbb6f7ec"}, - {file = "librt-0.7.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9bbb8facc5375476d392990dd6a71f97e4cb42e2ac66f32e860f6e47299d5e89"}, - {file = "librt-0.7.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e9e9c988b5ffde7be02180f864cbd17c0b0c1231c235748912ab2afa05789c25"}, - {file = "librt-0.7.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:edf6b465306215b19dbe6c3fb63cf374a8f3e1ad77f3b4c16544b83033bbb67b"}, - {file = "librt-0.7.5-cp313-cp313-win32.whl", hash = "sha256:060bde69c3604f694bd8ae21a780fe8be46bb3dbb863642e8dfc75c931ca8eee"}, - {file = "librt-0.7.5-cp313-cp313-win_amd64.whl", hash = "sha256:a82d5a0ee43aeae2116d7292c77cc8038f4841830ade8aa922e098933b468b9e"}, - {file = "librt-0.7.5-cp313-cp313-win_arm64.whl", hash = "sha256:3c98a8d0ac9e2a7cb8ff8c53e5d6e8d82bfb2839abf144fdeaaa832f2a12aa45"}, - {file = "librt-0.7.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9937574e6d842f359b8585903d04f5b4ab62277a091a93e02058158074dc52f2"}, - {file = "librt-0.7.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5cd3afd71e9bc146203b6c8141921e738364158d4aa7cdb9a874e2505163770f"}, - {file = "librt-0.7.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cffa3ef0af29687455161cb446eff059bf27607f95163d6a37e27bcb37180f6"}, - {file = "librt-0.7.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f3f088482e2229387eadf8215c03f7726d56f69cce8c0c40f0795aebc9b361"}, - {file = "librt-0.7.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7aa33153a5bb0bac783d2c57885889b1162823384e8313d47800a0e10d0070e"}, - {file = "librt-0.7.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:265729b551a2dd329cc47b323a182fb7961af42abf21e913c9dd7d3331b2f3c2"}, - {file = "librt-0.7.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:168e04663e126416ba712114050f413ac306759a1791d87b7c11d4428ba75760"}, - {file = "librt-0.7.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:553dc58987d1d853adda8aeadf4db8e29749f0b11877afcc429a9ad892818ae2"}, - {file = "librt-0.7.5-cp314-cp314-win32.whl", hash = "sha256:263f4fae9eba277513357c871275b18d14de93fd49bf5e43dc60a97b81ad5eb8"}, - {file = "librt-0.7.5-cp314-cp314-win_amd64.whl", hash = "sha256:85f485b7471571e99fab4f44eeb327dc0e1f814ada575f3fa85e698417d8a54e"}, - {file = "librt-0.7.5-cp314-cp314-win_arm64.whl", hash = "sha256:49c596cd18e90e58b7caa4d7ca7606049c1802125fcff96b8af73fa5c3870e4d"}, - {file = "librt-0.7.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:54d2aef0b0f5056f130981ad45081b278602ff3657fe16c88529f5058038e802"}, - {file = "librt-0.7.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b4791202296ad51ac09a3ff58eb49d9da8e3a4009167a6d76ac418a974e5fd4"}, - {file = "librt-0.7.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e860909fea75baef941ee6436e0453612505883b9d0d87924d4fda27865b9a2"}, - {file = "librt-0.7.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f02c4337bf271c4f06637f5ff254fad2238c0b8e32a3a480ebb2fc5e26f754a5"}, - {file = "librt-0.7.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f51ffe59f4556243d3cc82d827bde74765f594fa3ceb80ec4de0c13ccd3416"}, - {file = "librt-0.7.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b7f080ba30601dfa3e3deed3160352273e1b9bc92e652f51103c3e9298f7899"}, - {file = "librt-0.7.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb565b4219abc8ea2402e61c7ba648a62903831059ed3564fa1245cc245d58d7"}, - {file = "librt-0.7.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a3cfb15961e7333ea6ef033dc574af75153b5c230d5ad25fbcd55198f21e0cf"}, - {file = "librt-0.7.5-cp314-cp314t-win32.whl", hash = "sha256:118716de5ad6726332db1801bc90fa6d94194cd2e07c1a7822cebf12c496714d"}, - {file = "librt-0.7.5-cp314-cp314t-win_amd64.whl", hash = "sha256:3dd58f7ce20360c6ce0c04f7bd9081c7f9c19fc6129a3c705d0c5a35439f201d"}, - {file = "librt-0.7.5-cp314-cp314t-win_arm64.whl", hash = "sha256:08153ea537609d11f774d2bfe84af39d50d5c9ca3a4d061d946e0c9d8bce04a1"}, - {file = "librt-0.7.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:df2e210400b28e50994477ebf82f055698c79797b6ee47a1669d383ca33263e1"}, - {file = "librt-0.7.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d2cc7d187e8c6e9b7bdbefa9697ce897a704ea7a7ce844f2b4e0e2aa07ae51d3"}, - {file = "librt-0.7.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39183abee670bc37b85f11e86c44a9cad1ed6efa48b580083e89ecee13dd9717"}, - {file = "librt-0.7.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191cbd42660446d67cf7a95ac7bfa60f49b8b3b0417c64f216284a1d86fc9335"}, - {file = "librt-0.7.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea1b60b86595a5dc1f57b44a801a1c4d8209c0a69518391d349973a4491408e6"}, - {file = "librt-0.7.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:af69d9e159575e877c7546d1ee817b4ae089aa221dd1117e20c24ad8dc8659c7"}, - {file = "librt-0.7.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0e2bf8f91093fac43e3eaebacf777f12fd539dce9ec5af3efc6d8424e96ccd49"}, - {file = "librt-0.7.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8dcae24de1bc9da93aa689cb6313c70e776d7cea2fcf26b9b6160fedfe6bd9af"}, - {file = "librt-0.7.5-cp39-cp39-win32.whl", hash = "sha256:cdb001a1a0e4f41e613bca2c0fc147fc8a7396f53fc94201cbfd8ec7cd69ca4b"}, - {file = "librt-0.7.5-cp39-cp39-win_amd64.whl", hash = "sha256:a9eacbf983319b26b5f340a2e0cd47ac1ee4725a7f3a72fd0f15063c934b69d6"}, - {file = "librt-0.7.5.tar.gz", hash = "sha256:de4221a1181fa9c8c4b5f35506ed6f298948f44003d84d2a8b9885d7e01e6cfa"}, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, - {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins (>=0.5.0)"] -profiling = ["gprof2dot"] -rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] - -[[package]] -name = "markupsafe" -version = "3.0.3" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, - {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, - {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, - {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, - {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, - {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, - {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, - {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, - {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, -] - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mdformat" -version = "1.0.0" -description = "CommonMark compliant Markdown formatter" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "mdformat-1.0.0-py3-none-any.whl", hash = "sha256:bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b"}, - {file = "mdformat-1.0.0.tar.gz", hash = "sha256:4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928"}, -] - -[package.dependencies] -markdown-it-py = ">=1,<5" - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "mr-proper" -version = "0.0.7" -description = "Static Python code analyzer, that tries to check if functions in code are pure or not and why." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "mr_proper-0.0.7-py3-none-any.whl", hash = "sha256:74a1b60240c46f10ba518707ef72811a01e5c270da0a78b5dd2dd923d99fdb14"}, - {file = "mr_proper-0.0.7.tar.gz", hash = "sha256:03b517b19e617537f711ce418b125e5f2efd82ec881539cdee83195c78c14a02"}, -] - -[package.dependencies] -click = ">=7.1.2" -setuptools = "*" -stdlib-list = ">=0.5.0" - -[[package]] -name = "mypy" -version = "1.19.1" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, - {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, - {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"}, - {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"}, - {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"}, - {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"}, - {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"}, - {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"}, - {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"}, - {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"}, - {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"}, - {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"}, - {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"}, - {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"}, - {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"}, - {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"}, - {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"}, - {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"}, - {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"}, - {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"}, - {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"}, - {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"}, - {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"}, - {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"}, - {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"}, - {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"}, - {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"}, - {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"}, - {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"}, - {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"}, - {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"}, - {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"}, - {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"}, - {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"}, - {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"}, - {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"}, - {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"}, - {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"}, -] - -[package.dependencies] -librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""} -mypy_extensions = ">=1.0.0" -pathspec = ">=0.9.0" -typing_extensions = ">=4.6.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -faster-cache = ["orjson"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, - {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, -] - -[[package]] -name = "packaging" -version = "25.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - -[[package]] -name = "platformdirs" -version = "4.5.1" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"}, - {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"}, -] - -[package.extras] -docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] -type = ["mypy (>=1.18.2)"] - -[[package]] -name = "pycodestyle" -version = "2.14.0" -description = "Python style guide checker" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, - {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, - {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.41.5" -typing-extensions = ">=4.14.1" -typing-inspection = ">=0.4.2" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, - {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, -] - -[package.dependencies] -typing-extensions = ">=4.14.1" - -[[package]] -name = "pydantic-settings" -version = "2.12.0" -description = "Settings management using Pydantic" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809"}, - {file = "pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0"}, -] - -[package.dependencies] -pydantic = ">=2.7.0" -python-dotenv = ">=0.21.0" -typing-inspection = ">=0.4.0" - -[package.extras] -aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] -azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] -gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] -toml = ["tomli (>=2.0.1)"] -yaml = ["pyyaml (>=6.0.1)"] - -[[package]] -name = "pydocstyle" -version = "6.3.0" -description = "Python docstring style checker" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, - {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, -] - -[package.dependencies] -snowballstemmer = ">=2.2.0" - -[package.extras] -toml = ["tomli (>=1.2.3) ; python_version < \"3.11\""] - -[[package]] -name = "pyflakes" -version = "3.4.0" -description = "passive checker of Python programs" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, - {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, -] - -[[package]] -name = "pygments" -version = "2.19.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "python-dotenv" -version = "1.2.1" -description = "Read key-value pairs from a .env file and set them as environment variables" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, - {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, -] - -[package.extras] -cli = ["click (>=5.0)"] - -[[package]] -name = "pytokens" -version = "0.3.0" -description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3"}, - {file = "pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a"}, -] - -[package.extras] -dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] - -[[package]] -name = "pyyaml" -version = "6.0.3" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, - {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, - {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, - {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, - {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, - {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, - {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, - {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, - {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, - {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, - {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, - {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, - {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, -] - -[[package]] -name = "rich" -version = "14.2.0" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.8.0" -groups = ["main", "dev"] -files = [ - {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"}, - {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "setuptools" -version = "80.9.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, - {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] - -[[package]] -name = "shellingham" -version = "1.5.4" -description = "Tool to Detect Surrounding Shell" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, - {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, -] - -[[package]] -name = "snowballstemmer" -version = "3.0.1" -description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*" -groups = ["dev"] -files = [ - {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"}, - {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"}, -] - -[[package]] -name = "stdlib-list" -version = "0.12.0" -description = "A list of Python Standard Libraries (2.7 through 3.14)." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "stdlib_list-0.12.0-py3-none-any.whl", hash = "sha256:df2d11e97f53812a1756fb5510393a11e3b389ebd9239dc831c7f349957f62f2"}, - {file = "stdlib_list-0.12.0.tar.gz", hash = "sha256:517824f27ee89e591d8ae7c1dd9ff34f672eae50ee886ea31bb8816d77535675"}, -] - -[package.extras] -dev = ["build", "stdlib-list[doc,lint,test]"] -doc = ["furo", "sphinx"] -lint = ["mypy", "ruff"] -support = ["sphobjinv"] -test = ["coverage[toml]", "pytest", "pytest-cov"] - -[[package]] -name = "stevedore" -version = "5.6.0" -description = "Manage dynamic plugins for Python applications" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820"}, - {file = "stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945"}, -] - -[[package]] -name = "tomli-w" -version = "1.2.0" -description = "A lil' TOML writer" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90"}, - {file = "tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"}, -] - -[[package]] -name = "typer" -version = "0.20.1" -description = "Typer, build great CLIs. Easy to code. Based on Python type hints." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "typer-0.20.1-py3-none-any.whl", hash = "sha256:4b3bde918a67c8e03d861aa02deca90a95bbac572e71b1b9be56ff49affdb5a8"}, - {file = "typer-0.20.1.tar.gz", hash = "sha256:68585eb1b01203689c4199bc440d6be616f0851e9f0eb41e4a778845c5a0fd5b"}, -] - -[package.dependencies] -click = ">=8.0.0" -rich = ">=10.11.0" -shellingham = ">=1.3.0" -typing-extensions = ">=3.7.4.3" - -[[package]] -name = "types-pyyaml" -version = "6.0.12.20250915" -description = "Typing stubs for PyYAML" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6"}, - {file = "types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3"}, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - -[metadata] -lock-version = "2.1" -python-versions = ">=3.12,<4.0" -content-hash = "abc2a7618eca73586b62af3d5a9449463fbbb314f03b5c1429aa896abe44b9bb" diff --git a/pyproject.toml b/pyproject.toml index c463a4ef..b0133b37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" [project] name = "donna" @@ -17,7 +17,6 @@ classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3 :: Only", @@ -42,30 +41,30 @@ Changelog = "https://github.com/Tiendil/donna/blob/main/CHANGELOG.md" [project.scripts] donna = "donna.cli.application:main" -[tool.poetry.group.dev.dependencies] - -changy = "0.4.*" - -isort = "7.0.*" -black = "25.12.*" -autoflake = "2.3.*" -codespell = "2.4.*" - -flake8 = "7.3.*" -flake8-docstrings = "1.7.*" -flake8-functions = "0.0.*" -flake8-annotations-complexity = "0.1.*" -flake8-cognitive-complexity = "0.1.*" -flake8-pytest = "1.4.*" -flake8-bandit = "4.1.*" -flake8-absolute-import = "1.0.*" -flake8-print = "5.0.*" -flake8-pyproject = "1.2.*" -flake8-eradicate = "1.5.*" - -mypy = "1.19.*" - -types-PyYAML = "6.0.*" +[dependency-groups] +dev = [ + "changy==0.4.*", + "isort==7.0.*", + "black==25.12.*", + "autoflake==2.3.*", + "codespell==2.4.*", + "tach==0.34.*", + "flake8==7.3.*", + "flake8-docstrings==1.7.*", + "flake8-functions==0.0.*", + "flake8-annotations-complexity==0.1.*", + "flake8-cognitive-complexity==0.1.*", + "flake8-pytest==1.4.*", + "flake8-bandit==4.1.*", + "flake8-absolute-import==1.0.*", + "flake8-print==5.0.*", + "flake8-pyproject==1.2.*", + "flake8-eradicate==1.5.*", + "mypy==1.19.*", + "types-PyYAML==6.0.*", + "pytest==9.0.*", + "pytest-mock==3.15.*", +] [tool.isort] profile = "black" @@ -110,10 +109,23 @@ recursive = true plugins = ["pydantic.mypy"] strict = true implicit_reexport = true -allow_untyped_calls = true +disallow_untyped_calls = true +disallow_any_explicit = true [tool.pydantic-mypy] init_forbid_extra = true init_typed = true warn_required_dynamic_aliases = true warn_untyped_fields = true + +[tool.hatch.build.targets.sdist] +include = [ + "/donna", + "/CHANGELOG.md", + "/LICENSE", + "/README.md", + "/pyproject.toml", +] + +[tool.hatch.build.targets.wheel] +packages = ["donna"] diff --git a/specs/architecture/entities.md b/specs/architecture/entities.md new file mode 100644 index 00000000..e1d8a647 --- /dev/null +++ b/specs/architecture/entities.md @@ -0,0 +1,238 @@ +# Entity architecture + +## Goal of the document + +This document describes the architecture of project entities and data structures used to pass artifact, workflow, configuration, session, protocol, and command information between modules. + +## Scope + +The scope of this specification is limited to architectural requirements for Python data structures that represent project concepts. + +The following topics are out of scope: + +- exact class names. +- exact constructor signatures. +- serialization formats for CLI protocols. +- workflow execution algorithms. +- Markdown parsing algorithms. +- validation rules already specified by behavior specifications. + +## Dictionary + +- `entity` — a typed Python object that represents one project concept and can be passed across module boundaries. +- `value entity` — an entity whose equality is based on its data rather than object identity. +- `data entity` — an entity that primarily carries validated data across boundaries or into serialization. +- `domain entity` — an entity that owns behavior, invariants, or state transitions for a project concept. +- `boundary entity` — an entity that is passed between modules with different responsibilities. +- `serialized representation` — a plain data representation prepared for an external protocol such as JSON Lines, Markdown output, logs, or persistent storage. + +## General principles + +Project concepts MUST be represented as explicit typed entities before they cross module boundaries. + +Boundary entities MUST NOT be represented as untyped dictionaries unless the data is already being prepared as a serialized representation. + +Project entities SHOULD use Pydantic v2 for structured data models. + +### Data entities + +Data entities SHOULD limit behavior to validating, normalizing, copying, serializing, and deriving values from their +own fields. + +Data entities MUST NOT access invocation-local context, load artifacts, execute primitives, mutate session state, or +perform low-level infrastructure work. + +### Domain entities + +Domain entities MAY be rich domain objects. + +Domain entities SHOULD keep behavior close to the state, invariants, transitions, and domain operations they own. +They MAY: + +- change their own state or apply state changes. +- derive facts from their own state. +- validate related project objects. +- coordinate behavior through typed collaborator or invocation-local context interfaces. + +Domain entity methods SHOULD keep direct infrastructure details behind collaborator interfaces or module boundaries. + +If an entity directly touches an external resource, the entity MUST be owned by the module responsible for that +resource and SHOULD represent that resource explicitly. + +Entities MUST NOT directly perform low-level infrastructure work unrelated to their owned concept, such as: + +- filesystem access. +- process execution. +- terminal output. +- configuration file discovery. +- workspace loading. + +Entities MAY call typed collaborators or context objects that perform those operations when the behavior and +dependency are part of the module's public contract. + +### Value entities + +Value entities SHOULD be immutable after construction when practical. + +Entities that can be used for de-duplication SHOULD be hashable when practical. + +## Pydantic baseline + +The project accepts Pydantic v2 as the default dependency for entity modeling. + +The project SHOULD provide shared base entity infrastructure owned by the core module. + +Project entities SHOULD inherit from the shared base entity unless they have a specific reason to use Pydantic directly. + +Direct Pydantic usage MAY be used for: + +- configuration objects that mirror external input shapes. +- external transfer objects that intentionally mirror boundary-facing data shapes. +- third-party interfaces that require a direct Pydantic model shape. + +Shared entity defaults SHOULD: + +- strip surrounding whitespace from string values. +- validate default values. +- reject unknown fields. +- prefer immutable value objects where practical. +- validate assignment when mutation is explicitly enabled. +- avoid attribute-based construction unless a boundary explicitly needs it. + +Entities SHOULD use Pydantic field metadata and validators for local field constraints, default factories, discriminators, and model invariants. + +Entities SHOULD use Pydantic serialization methods at boundaries that need model dumps or JSON. + +The shared base entity MUST provide a copy-with-changes operation. + +Very small internal helper values MAY use plain Python classes with `__slots__` when Pydantic would add no practical value. + +Project data structures MUST NOT use `dataclasses.dataclass`. + +## Enumeration conventions + +Closed sets of named values MUST be represented as Python enum classes. + +String-valued external protocols, render modes, record kinds, primitive modes, document names, and similar closed type sets SHOULD use `enum.StrEnum`. + +Integer-valued closed sets SHOULD use `enum.IntEnum` when the integer value is part of the external contract or persisted state. + +Enum classes MUST use `enum.StrEnum` or `enum.IntEnum` instead of `str, enum.Enum` or `int, enum.Enum`. + +Plain strings MUST NOT be used as the primary internal representation for values that have a finite configured, persisted, or specified set of allowed names. + +Enum values that cross external boundaries MUST preserve the specified serialized or persisted value exactly. + +Output protocol values are a closed set of named string values and MUST be represented internally with an enum rather than raw strings or lists of strings. + +## Semantic primitive types + +Semantically specific primitive values MUST have semantically specific Python types before they cross module boundaries. + +For example, an artifact id, artifact section id, section id, action request id, work unit id, task id, project path, or primitive path MUST NOT be represented as an unqualified primitive value in entities or public function signatures when the value has a distinct Donna meaning. + +Semantic primitive types SHOULD use `typing.NewType` when runtime behavior is identical to the underlying primitive. + +Semantic primitive types MAY use small custom classes when they need validation, normalization, ordering, immutability, Pydantic integration, or custom string rendering. + +Raw primitive types MAY be used at parsing, rendering, storage, and serialization boundaries where external data is converted into or out of project types. + +Raw primitive types MAY be used inside local helper code when the value has already been validated or when adding a semantic type would not improve module-boundary clarity. + +Semantic primitive types SHOULD be owned by the module that owns the corresponding project concept. + +Shared semantic primitive types SHOULD belong to the domain module. + +Module-specific semantic primitive types SHOULD belong to the owning module. + +## Entity ownership + +Shared entity infrastructure MUST belong to the core module. + +Shared domain primitive types and universal domain entities MUST belong to the domain module. + +Module-specific entities MUST belong to the module that owns the corresponding responsibility. + +Entity classes that are part of a top-level module's public cross-module API MUST be exported from the owning module's package initializer. + +Top-level modules MUST import public entities owned by another top-level module from the owning module's package root. + +Public re-exports MUST NOT hide ownership. The defining module MUST remain clear from the source tree. + +## Core and domain entities + +The core module MUST contain only shared entity infrastructure. + +Core entity infrastructure MUST NOT contain domain-specific Donna concepts such as artifacts, workflows, sections, sessions, protocols, primitives, or workspace paths. + +The domain layer MUST contain only universal entities and semantic primitive types for concepts shared by all or most other modules. + +Domain entities MUST model universal Donna concepts independently from the concrete interface that created or renders them. + +Domain entities MUST NOT depend on CLI option parsing, output protocol rendering, workspace loading, session storage, or concrete configuration file syntax. + +Domain entities MUST NOT contain subsystem-specific entities when those entities are required only by one narrower module. + +## Configuration entities + +Configuration entities MAY represent parsed TOML data at the configuration loading boundary when their responsibility is to validate the configuration file shape. + +Configuration entities SHOULD use Pydantic validation to reject malformed parsed data before it reaches lower layers. + +Workspace entities MUST model validated configuration concepts independently from raw TOML table shapes before data reaches lower layers. + +Workspace entities MUST expose shared project concepts as domain entities rather than configuration-specific entities. + +Configuration entities MUST preserve enough information to report useful configuration errors. + +Configuration entities MUST NOT execute workflow artifacts, run primitives, write session state, or forward journal records. + +Configuration entities MAY reference domain entities when the referenced concept has already been validated as a domain concept. + +## CLI and command entities + +CLI entities MUST represent parsed user intent before the command is executed. + +CLI entities MUST model command selection and parsed options independently from rendered output. + +CLI entities MUST NOT contain rendered output. + +CLI entities MUST NOT perform command execution. + +CLI entities SHOULD use domain semantic primitive types for parsed ids and paths when the values have already been normalized or validated. + +## Protocol and serialization entities + +Serialized representations MUST be created at protocol boundaries and SHOULD be treated as write-only output data. + +Serialized representations SHOULD be produced from entities through explicit boundary code, not by leaking Pydantic dump shapes into domain behavior. + +Protocol entities MAY contain presentation-oriented metadata when their responsibility is output formatting. + +Domain, machine, workspace, and CLI entities SHOULD NOT depend on concrete serialized protocol record shapes. + +Entity methods that return dictionaries for protocol metadata, logging, or storage MUST return structured values with stable keys and MUST NOT contain terminal formatting. + +## Data structure conventions + +Ordered input from users, workflow artifacts, configuration files, and persisted session state SHOULD be represented with ordered collections. + +Sets MAY be used internally for de-duplication, but externally visible output order MUST be produced explicitly according to the relevant behavior specification. + +Mappings keyed by semantic ids SHOULD use the semantic id type when possible. + +Optional values MUST be represented with `None` instead of sentinel strings. + +Collections that represent stacks, queues, or ordered workflow state MUST preserve order explicitly. + +## Validation boundaries + +Parsing layers SHOULD validate external data before creating entities that are used by lower layers. + +Pydantic model validation MAY validate local invariants that are always true for the entity, but MUST NOT perform +filesystem access, configuration discovery, workspace loading, artifact discovery, primitive execution, subprocess +execution, or command execution. + +Validation that requires those operations MUST live in behavior methods, runtime orchestration, or functions that return Donna-specific errors. + +Invalid external input MUST be reported through the error architecture instead of by returning partially valid entities. diff --git a/specs/architecture/errors.md b/specs/architecture/errors.md new file mode 100644 index 00000000..e2530edf --- /dev/null +++ b/specs/architecture/errors.md @@ -0,0 +1,256 @@ +# Error architecture + +## Goal of the document + +This document describes how project modules represent internal errors, environment errors, and non-fatal problems, and how those values move from lower layers to the CLI. + +## Scope + +The scope of this specification is limited to error and warning architecture inside the Python implementation. + +The following topics are out of scope: + +- exact wording of user-facing messages. +- complete lists of future error codes. +- terminal formatting. +- workflow execution behavior. +- test coverage requirements. + +## Dictionary + +- `internal error` — a programming error, impossible state, unsupported internal operation, or violated invariant inside Donna. +- `environment error` — an expected (user, project, artifact, configuration, filesystem, session, external-tool, etc.) problem that an agent or user may be able to fix. +- `warning` — a non-fatal problem discovered while processing a request. +- `error code` — a stable machine-readable identifier for an environment error. +- `module root error` — a module-owned base class for internal errors or environment errors. +- `exception boundary` — a module boundary where low-level exceptions are converted into Donna-specific errors. + +## General principles + +Expected project failures MUST be represented as Donna-specific errors before they cross module boundaries. + +Internal errors MUST be raised as exceptions. + +Environment errors MUST be returned as `Result[..., ErrorsList]` values unless a temporary exception bridge is required by an external callback boundary. + +Lower-level modules MUST NOT print errors, write protocol records, or terminate the process. + +The CLI layer MUST be responsible for converting environment errors into Donna cells and command exit behavior. + +Environment error codes MUST be stable enough for automation and tests. + +Error codes MUST use lowercase ASCII letters, ASCII digits, `_`, and `.`. + +User-facing messages SHOULD be clear enough to diagnose the problem without exposing implementation stack details. + +## Error kinds + +Donna has two primary error kinds: + +- `InternalError` — raised exception type for bugs and internal invariant failures. +- `EnvironmentError` — structured Pydantic entity for recoverable project or environment problems. + +Internal errors are not expected to be handled by agents or users. + +Environment errors are expected to describe what failed and, when practical, how to fix it. + +## Error ownership + +Base error classes MUST be owned by `donna.core.errors`. + +Each top-level module that owns errors SHOULD define its own error hierarchy in its `errors` submodule. + +A top-level module's internal error root SHOULD be named `InternalError` and inherit from `donna.core.errors.InternalError`. + +A top-level module's environment error root SHOULD inherit from `donna.core.errors.EnvironmentError`. + +The environment root MAY have a more specific name when that name communicates the module boundary, such as `WorkspaceError` or `CliError`. + +Each unique error case SHOULD be represented by its own subclass. + +Each top-level module that owns an `errors` submodule MUST export it from the module package initializer. + +Production errors MUST NOT be defined in test modules. + +Test-only error classes MAY be defined in test modules when they are required to verify error handling behavior. + +`donna.primitives` submodules MAY define primitive-specific errors close to the primitive implementation when the primitive is a self-contained unit. + +`donna.lib` MUST NOT define an `errors` submodule because it is a collection of constructed primitive instances. + +## Internal errors + +`donna.core.errors.InternalError` MUST inherit from `Exception`. + +Internal error subclasses MAY define a parametrized `message` class attribute. + +Internal error instances MUST store constructor keyword arguments for message formatting. + +Internal errors SHOULD be raised with the standard `raise` statement. + +Internal errors SHOULD NOT be converted into Donna cells during normal CLI command handling. + +Internal errors SHOULD be used for: + +- impossible states. +- unsupported internal methods. +- missing process-local context. +- misuse of `Result.unwrap()` and `Result.unwrap_err()`. +- violations of assumptions already guaranteed by prior validation. + +## Environment errors + +`donna.core.errors.EnvironmentError` MUST inherit from Donna's common entity base. + +Environment errors MUST NOT inherit from `Exception`. + +Environment errors MUST define: + +- `code`. +- `message`. +- `cell_kind`. + +Environment errors MAY define: + +- `cell_media_type`. +- `ways_to_fix`. +- structured fields with context needed for rendering, logging, or tests. +- `content_intro()` when the default intro is not specific enough. + +Environment error messages and ways to fix MAY use `{error.}` formatting. + +Leaf environment errors SHOULD define their message and fix guidance in the class body, not at construction sites. + +Construction sites SHOULD pass only the structured fields that vary for that error instance. + +Environment errors MUST be rendered through the protocol module when converted to cells. + +The core error base classes MUST NOT depend on protocol cells, protocol nodes, protocol formatters, or CLI output. + +Protocol conversion code MUST preserve the error code as cell metadata. + +Rendered environment error cells MUST include the error code as metadata. + +Rendered environment error cell metadata SHOULD include structured context fields when those fields are scalar and deterministic. + +## Results + +Functions that can fail with environment errors SHOULD return `Result[T, ErrorsList]`. + +`ErrorsList` MUST be a list of `EnvironmentError` instances. + +Successful results MUST be returned with `Ok(value)`. + +Environment failures MUST be returned with `Err(errors)`. + +When multiple validation errors can be discovered in one pass, code SHOULD collect them and return one `Err(errors)` value. + +Code that propagates an existing compatible error result SHOULD return the original result or original error list instead of unwrapping and wrapping it again. + +Code SHOULD NOT define duplicate functions that differ only by error handling strategy. + +If a function is changed to return environment errors, callers up the call stack MUST be updated to process, propagate, or render those errors according to their layer. + +## `unwrap_to_error` + +The `unwrap_to_error` decorator SHOULD be the preferred way to compose calls to functions that return `Result` objects when it makes unwrapping and propagation simpler. + +The decorator SHOULD be used on functions that return `Result[T, ErrorsList]` and primarily call other `Result`-returning functions. + +Decorated functions MAY call `.unwrap()` on intermediate `Result` values to keep straight-line code readable. + +This style SHOULD be preferred over repeated manual checks like `if result.is_err(): return Err(result.unwrap_err())`. + +When `.unwrap()` raises `UnwrapError`, `unwrap_to_error` MUST convert the unwrapped error value back into `Err(...)`. + +`unwrap_to_error` MUST NOT be used to hide internal errors or arbitrary exceptions. + +## Temporary environment error bridges + +Some external callback boundaries cannot return `Result` directly. + +At those boundaries, Donna MAY use a technical internal exception such as `EnvironmentErrorsProxy` to carry environment errors through the callback stack. + +The proxy MUST be caught at the nearest Donna-controlled boundary and converted back into `Result[..., ErrorsList]`. + +The proxy MUST NOT cross into CLI rendering as an internal error. + +## Exception boundaries + +Modules that call external systems MUST convert relevant low-level failures into environment errors at the boundary where useful context is still available. + +External systems include: + +- filesystem operations. +- TOML parsing. +- Pydantic model validation for external input. +- Markdown and template parsing. +- Python import loading for configured primitives or directives. +- subprocess execution. +- external journal commands. + +Pydantic validation errors MUST NOT be exposed directly across high-level module boundaries for user-provided data. + +Modules that create Pydantic entities from external input MUST convert `pydantic.ValidationError`, `ValueError`, and similar low-level validation failures into Donna environment errors at the nearest useful boundary. + +Unexpected programming errors MAY propagate during development, but code that handles expected user or environment failures MUST convert them into environment errors. + +When converting an exception, details SHOULD preserve enough information for diagnosis without requiring stack traces in user-facing output. + +## CLI mapping + +Typer command line parsing errors MAY use Typer's standard invalid-argument behavior. + +CLI argument parsing MAY raise `typer.BadParameter`, `click.UsageError`, or `typer.Exit` before command execution has fully entered Donna's result-based flow. + +After the selected protocol is installed, environment errors SHOULD be rendered as Donna error cells. + +Human and LLM environment error cells SHOULD be written to stdout like other Donna cells. + +Automation environment error cells SHOULD be written to stdout as JSON Lines cell records. + +Environment errors rendered through Donna error cells currently exit with status `0`. + +The CLI SHOULD write environment error journal records when workspace configuration is loaded and journal forwarding is available. + +Modules outside the CLI module MUST NOT know about CLI exit codes. + +## Warnings + +Warnings represent non-fatal problems discovered while processing a request. + +Warnings MUST be used only when processing can continue and the command can still produce useful requested output. + +Warnings MUST NOT be used for invalid command line arguments, invalid configuration that prevents workspace loading, invalid artifacts that prevent requested validation, or workflow execution failures. + +Donna currently has no shared warning storage or warning protocol record architecture. + +Until such architecture is specified and implemented, modules MUST NOT invent ad hoc warning channels. + +## Naming error classes + +Error class names SHOULD be short and descriptive. + +Leaf error class names SHOULD avoid `Error`, `Exception`, `Failure`, `Internal`, and `Environment` suffixes when the shorter name remains clear. + +Root classification classes MAY use `InternalError`, `EnvironmentError`, or a module-specific boundary name such as `WorkspaceError`. + +## Asserts + +`assert` statements MAY be used as hints for type checkers and linters when the invariant is guaranteed by earlier control flow. + +`assert` statements MUST NOT be used for user input validation, environment validation, artifact validation, or recoverable workflow failures. + +Recoverable failures MUST use environment errors. + +Impossible runtime states SHOULD use internal errors when they need explicit handling. + +## Other exception types + +Production Donna code SHOULD use `InternalError` for internal exceptions unless a third-party interface or Python protocol requires another exception type. + +Other exception types MAY be used when required by third-party libraries, Pydantic validators, Typer/click command parsing, or Python protocols. + +`NotImplementedError` MAY be used as a temporary placeholder only while implementation is still in progress. + +Before a change is considered complete, temporary `NotImplementedError` usages SHOULD be replaced with Donna-specific errors or implemented behavior. diff --git a/specs/architecture/modules_layout.md b/specs/architecture/modules_layout.md new file mode 100644 index 00000000..47c1d81e --- /dev/null +++ b/specs/architecture/modules_layout.md @@ -0,0 +1,207 @@ +# Module structure + +## Goal of the document + +This document describes the intended module structure of the project. + +## Scope + +The scope of this specification is limited to the list of project modules and their intended responsibilities. + +The following topics are out of scope: + +- detailed implementation design. +- runtime behavior. +- migration planning. +- entity, error, and test conventions beyond module placement. + +## Dictionary + +- `module` — a Python package or module that owns a coherent area of project functionality. +- `submodule` — a Python package or module inside another module that owns a narrower part of its parent module's functionality. +- `test submodule` — a module or file containing tests for a corresponding parent module or submodule. + +## Modules + +- `./donna/` — root module of the project, contains all code related to the `donna` tool. +- `./donna/core/` — module responsible for the core functionality not related to domain logic. Contains: + - shared entity base classes. + - shared error base classes. + - shared result types. + - domain-independent utilities. +- `./donna/domain/` — module responsible only for universal domain entities and logic required by all or most other modules. Contains: + - shared domain-specific types. + - shared domain data structures. + - pure domain logic that is independent of more specific subsystems. + - no subsystem-specific rule evaluation, protocol rendering, workspace loading, or CLI behavior. +- `./donna/machine/` — module responsible for Donna workflow execution logic. Contains: + - session state entities. + - task, work unit, and action request entities. + - state changes. + - operation and primitive interfaces. + - workflow execution orchestration that is independent of CLI argument parsing and protocol-specific formatting. + - protocol-facing views of machine-owned concepts expressed as protocol-neutral output values from `./donna/protocol/`. +- `./donna/runtime/` — module responsible for command-independent runtime orchestration. Contains: + - session lifecycle use cases. + - workflow execution loop coordination. + - invocation-local wiring between context, machine, workspaces, and protocol-facing results. + - journal event forwarding orchestration. + - no CLI argument parsing. +- `./donna/context/` — module responsible for invocation-local runtime context. Contains: + - context-local caches. + - context-local execution scopes. + - glue that gives machine and primitive logic access to loaded artifacts, primitive registries, and session state. +- `./donna/primitives/` — module responsible for built-in primitive implementations. Contains: + - artifact primitives. + - section primitives. + - directive primitives. + - primitive-specific validation, rendering, and execution logic. +- `./donna/lib/` — module responsible for stable public names of built-in primitive instances used by Donna artifact configuration. +- `./donna/protocol/` — module responsible for Donna output boundary values and protocol formatting. Contains: + - protocol-neutral output value definitions used to communicate Donna results between modules. + - generic helpers for projecting Donna-owned data and errors into output values. + - protocol enums. + - formatter selection. + - protocol-specific formatters that serialize output values for human, llm, and automation output. + - serialized record construction for external output protocols. + - low-level output boundary infrastructure that MAY be used by any top-level module. +- `./donna/skills/` — module responsible for built-in skill text loaded by the CLI and renderers. +- `./donna/workspaces/` — module responsible for workspace management, including: + - finding and parsing config. + - detecting current project root. + - operations with project files and directories. + - artifact discovery and loading. + - session state storage. + - journal forwarding. +- `./donna/cli/` — module responsible for the CLI interface of the `donna` tool. + +## Submodules + +Modules can have submodules that are responsible for more specific parts of the functionality. + +When a module contains a small closed family of interchangeable components, and each component has meaningful component-specific behavior, the module SHOULD prefer one implementation submodule per component. + +Shared package-level code for such component families SHOULD be limited to common types, public unions, selection helpers, and iteration glue. + +Submodules are optional implementation details unless another specification explicitly requires them. + +Some optional submodules have specific names that reflect their responsibilities and SHOULD be similar across different modules when those submodules exist. + +List of specific submodules: + +- `utils` — submodule responsible for utility functions that are not related to domain logic. +- `errors` — submodule responsible for defining custom exception types. +- `entities` — submodule responsible for defining types and entities related to the module's responsibilities. +- `fixtures` — submodule containing reusable configuration, documentation, or data fixtures owned by the module. +- `tests` — submodule containing module tests. +- `tests.make` — test-only submodule containing constructors for test objects related to the parent module. +- `tests.helpers` — test-only submodule containing reusable test setup, mutation, and workflow helpers. + +The `errors`, `entities`, and `tests` submodules MUST follow the corresponding architecture specifications when they are present. + +The shared `entities` submodule in `./donna/core/` MUST define the common entity base used by higher-level modules. + +### Submodule nuances + +#### `errors` + +The `errors` submodule owns module-specific exception classes and error values. + +Errors SHOULD express project-level failure modes that callers can handle, not low-level library details. + +#### `entities` + +The `entities` submodule owns module-specific types, semantic ids, enums, and entities that represent the module's concepts. + +Entities SHOULD describe domain data and boundary data, not storage implementation details unless storage metadata is itself part of the project concept. + +#### `utils` + +The `utils` submodule owns small helpers that do not naturally belong to entities, settings, integration boundaries, or a more specific submodule. + +Utility functions SHOULD be pure or locally technical when practical. If a helper starts to encode module behavior, it SHOULD move to a more specific submodule. + +Top-level modules SHOULD avoid depending on another top-level module's `utils` submodule because utilities are not a stable cross-module boundary. + +#### `tests` + +The `tests` submodule owns colocated tests for the parent module and its submodules. + +Tests SHOULD exercise behavior through public boundaries when practical, while storage-focused tests MAY verify storage-specific behavior owned by the module. + +Test files SHOULD mirror implementation module names with the `test_` prefix and organize test classes around the tested function or class. + +#### `make` + +The `make` submodule MAY appear only inside tests packages, as `.tests.make`. + +`tests.make` owns constructors and factory helpers for test objects related to the parent module. + +Tests SHOULD put reusable object construction in `tests.make` instead of duplicating constructors across test files. + +Production modules MUST NOT import `tests.make`. + +#### `helpers` + +The `helpers` submodule MAY appear inside tests packages, as `.tests.helpers`. + +`tests.helpers` owns reusable test helpers that perform setup, mutate persisted test state, call module behavior, or wrap common test workflows. + +Object constructors and pure fake data factories SHOULD live in `tests.make`; helpers that perform actions or coordinate multiple calls SHOULD live in `tests.helpers`. + +Production modules MUST NOT import `tests.helpers`. + +## Cross-module dependencies + +### Import boundaries + +Top-level modules MUST expose stable cross-module APIs through declared public import boundaries. + +A public import boundary MAY be: + +- the top-level module package root, such as `donna.domain`. +- a declared public submodule, such as `donna.domain.artifact_ids`. + +Public import boundaries MUST be explicit enough for maintainers to distinguish stable cross-module API from implementation detail. + +A public submodule SHOULD expose cohesive concepts owned by its top-level module. + +A public submodule MUST NOT expose low-level helper functions, temporary implementation details, storage-only helpers, or compatibility shims unless those names are intentionally stable. + +Top-level module package initializers SHOULD export common public names when doing so improves ergonomics. + +Top-level module package initializers MUST NOT be required to re-export every public name from declared public submodules. + +When a top-level module owns an `errors` submodule, the package initializer MUST include the `errors` submodule in the public cross-module API. + +The package initializer SHOULD define `__all__` to list the names that are intended as the module root public cross-module API. + +If a top-level module has no useful module-root public cross-module API, its package initializer MAY be empty or define an empty `__all__`. + +Top-level modules MUST import another top-level module only through that module's declared public import boundaries. + +For example, `donna.cli` MAY import from `donna.workspaces` and from public submodules declared by `donna.workspaces`, but MUST NOT import from undeclared implementation submodules. + +Top-level modules MUST NOT import undeclared implementation submodules from another top-level module. + +Submodules inside the same top-level module MAY import each other directly. + +The `utils` submodule is not a public import boundary unless a top-level module explicitly declares it as one. + +Top-level modules MAY import protocol-owned boundary values and generic projection helpers from `donna.protocol` when they need to expose their owned concepts as Donna output units. + +Constructing protocol-neutral output values is not protocol-specific rendering and MUST NOT be treated as a module-boundary violation. + +Top-level modules outside `./donna/protocol/` and `./donna/cli/` MUST NOT depend on protocol-specific formatter implementation submodules. + +Top-level modules outside `./donna/protocol/` SHOULD NOT own protocol-specific serialization details. + +Protocol-specific rendering means selecting or implementing concrete external serialization for a protocol, such as terminal text framing, LLM-oriented boundary syntax, automation JSON Lines records, byte output, or formatter-specific ordering rules. + +Constructing protocol-neutral output values with kind, content, media type, and metadata is not protocol-specific rendering. + +## Data structures + +Project data structures SHOULD inherit from `donna.core.entities.BaseEntity` unless a third-party interface or standard-library protocol requires another type. + +Project data structures MUST NOT use `dataclass` for domain entities. diff --git a/specs/architecture/naming.md b/specs/architecture/naming.md new file mode 100644 index 00000000..77e3b4dc --- /dev/null +++ b/specs/architecture/naming.md @@ -0,0 +1,50 @@ +# Naming architecture + +## Goal of the document + +This document describes architectural naming conventions for project code symbols and modules. + +## Scope + +The scope of this specification is limited to stable naming rules that help keep project code understandable across modules. + +The following topics are out of scope: + +- exact names for private helpers. +- complete lists of symbol names. +- generated names in external protocols. +- formatting rules covered by language tools. + +## General principles + +Names SHOULD describe the project concept represented by the symbol. + +Names SHOULD be specific enough to avoid ambiguity at module boundaries. + +Names SHOULD NOT use generic words when a more precise project term is available. + +Names SHOULD avoid confusion with common Python standard library, typing, or framework concepts when a clearer project-specific name is available. + +Names SHOULD be consistent with the responsibility of the module that owns the symbol. + +Public names SHOULD remain stable unless the underlying project concept changes. + +## Type names + +Class, enum, and type alias names SHOULD use singular nouns when each instance or member represents one concept. + +Collection-like plural names SHOULD be used only when the type itself represents a collection or registry. + +Enum type names SHOULD describe what one enum member represents, not the set of all possible members. + +Enum member names SHOULD use the serialized value name when the enum crosses an external boundary and the serialized names are stable. + +Type names SHOULD avoid names that collide with common typing abstractions when the project concept is narrower than the abstraction. + +## Module names + +Module names SHOULD describe the responsibility owned by the module. + +Module names SHOULD be plural only when the module primarily contains a group of closely related definitions with no single narrower responsibility. + +Module names SHOULD NOT mention implementation techniques unless the technique is the module's stable responsibility. diff --git a/specs/architecture/tests.md b/specs/architecture/tests.md new file mode 100644 index 00000000..0e4951f5 --- /dev/null +++ b/specs/architecture/tests.md @@ -0,0 +1,359 @@ +# Test architecture + +## Goal of the document + +This document describes the architecture of project tests, including where tests live, how they relate to modules, and how they cover entities, errors, behavior, and CLI boundaries. + +## Scope + +The scope of this specification is limited to test organization and architectural testing expectations for Python code. + +The following topics are out of scope: + +- exact test framework configuration. +- exact fixture names. +- continuous integration configuration. +- package publishing checks. +- performance benchmarks. + +## Dictionary + +- `unit test` — a test focused on one module or one small group of closely related functions or entities. +- `integration test` — a test that checks multiple modules through a public boundary such as configuration loading, artifact loading, workflow execution, or CLI command execution. +- `fixture` — test data or setup used by one or more tests. +- `architecture test` — a test that verifies a project-wide convention from an architecture specification. +- `behavior example test` — a test that verifies an example or rule from a behavior specification. + +## General principles + +Tests MUST be written as part of the Python project. + +Development-related test execution MUST happen through the project development container commands. + +The preferred command form for running tests is: + +```bash +./bin/dev.sh uv run pytest +``` + +The preferred command form for running targeted tests is: + +```bash +./bin/dev.sh uv run pytest +``` + +Tests SHOULD be deterministic for the same repository state and filesystem state. + +Tests that require specific shared state MUST prepare that state at the start of the relevant test or with an autouse fixture at the test class or test module level. + +Tests SHOULD prefer end-to-end coverage through public boundaries when that is practical for the behavior under test. + +Tests SHOULD use mocks, stubs, and monkeypatching as little as possible. + +Tests SHOULD prefer real project code, temporary files, explicit fixtures, and small fakes over mocked collaborators. + +Tests MUST NOT depend on external network access. + +Tests MUST NOT depend on user-specific files outside test-created temporary directories. + +Tests MUST NOT modify Docker configuration or runtime parameters. + +Tests SHOULD verify observable behavior and locally owned validation instead of implementation declarations such as annotations, imports, or exact helper types. + +Tests SHOULD prefer separate tests for orthogonal execution paths or validation cases instead of one combined test that verifies all cases at once. + +Static typing requirements SHOULD be enforced by static analysis, code review, or dedicated architecture checks, not by ordinary unit tests that inspect runtime annotations. + +Tests MAY inspect annotations only in dedicated architecture tests that validate a broad project-wide convention. + +Per-entity unit tests MUST NOT inspect annotations only to restate the entity declaration. + +Tests MUST NOT use identity assertions except for `None` checks. + +Tests MUST NOT use identity assertions for boolean values. Use `assert condition` instead of `assert condition is True`, and `assert not condition` instead of `assert condition is False`. + +## Mocking + +Tests SHOULD prefer real project code, explicit fixtures, test constructors, temporary files, and small fakes over mocks. + +When a test must replace a Python collaborator, setting, method, or attribute, it SHOULD use the project-standard pytest mocking tool. + +Patches SHOULD be scoped to the test that needs them and SHOULD patch the name as it is looked up by the code under test. + +Use `mocker.patch("", ...)` for imported module-level collaborators and settings when `pytest-mock` is available. + +Use `mocker.patch.object(...)` when replacing an attribute on an object or class already available in the test. + +Use direct `unittest.mock.MagicMock` only for local fake objects or callables that are passed into the code under test. + +Tests SHOULD NOT use pytest `monkeypatch` for ordinary Python attribute replacement when the project-standard mocking fixture is available. + +## Test module layout + +Each implementation module or submodule SHOULD have corresponding tests under a `tests` submodule owned by the +nearest package that owns the behavior under test. + +The name of a test file MUST be built from the name of the tested module by adding the `test_` prefix. + +The structure of tests SHOULD mirror the implementation structure when that makes ownership clear. + +When an implementation package contains nested component packages, tests for modules in those component packages +SHOULD live in a `tests` subpackage of the component package instead of being flattened into the top-level module's +test package. + +For example, tests for `./donna/primitives/artifacts/workflow.py` SHOULD live in +`./donna/primitives/artifacts/tests/test_workflow.py`, not in `./donna/primitives/tests/test_artifacts_workflow.py`. + +Examples: + +- `./donna/core/utils.py` -> `./donna/core/tests/test_utils.py` +- `./donna/domain/ids.py` -> `./donna/domain/tests/test_ids.py` +- `./donna/workspaces/config.py` -> `./donna/workspaces/tests/test_config.py` +- `./donna/primitives/sections/output.py` -> `./donna/primitives/sections/tests/test_output.py` + +Cross-module integration tests MAY live under the module that owns the public boundary being exercised. + +CLI integration tests SHOULD live under `./donna/cli/tests/`. + +Test data constructors reused by multiple test modules in the same package SHOULD live in `tests/make`. + +`tests.make` SHOULD contain small factory functions that create valid entities, value objects, workflow artifacts, session state, and other project data for tests. + +`tests.make` MUST NOT contain assertions or behavior-verification helpers. + +Tests MAY reuse constructors, fixtures, and helpers from another module's `tests` package when that avoids duplicating +non-owned setup data or supports cross-module integration coverage. + +Cross-module test helper reuse MUST remain test-only and MUST NOT make production modules depend on `tests` packages. + +Test helper functions reused by multiple test modules in the same package SHOULD live in `tests/helpers`. + +`tests.helpers` SHOULD contain assertion helpers, setup helpers, cleanup helpers, and test workflow utilities. + +`tests.helpers` MUST NOT contain ordinary project data constructors when those constructors fit `tests.make`. + +## Test organization + +Tests SHOULD be organized around the tested function or tested class. + +Each production module-level function MUST have a corresponding test class when the function owns non-trivial behavior. + +Each class SHOULD have a corresponding test class when the class owns non-trivial behavior. + +Test classes MUST use `Test` naming, where `` is the tested function or class name converted to PascalCase. + +Examples: + +- function `load_workspace` -> `class TestLoadWorkspace`. +- class `Artifact` -> `class TestArtifact`. +- class `ActionRequest` -> `class TestActionRequest`. + +Tests for a class MUST group method tests inside the class's test class. + +Tests for a class method MUST use this method name format: + +```text +test___ +``` + +`` MUST be the tested method name in snake case. + +`` MUST describe the execution path or behavior being verified in snake case. + +Examples: + +- `test_parse__invalid_value`. +- `test_validate_artifact__multiple_primary_sections`. +- `test_complete_action_request__invalid_transition`. + +When a test class tests one module-level function, test methods MAY omit the function name and use this format: + +```text +test_ +``` + +Examples: + +- `TestLoadWorkspace.test_success`. +- `TestLoadWorkspace.test_config_not_found`. +- `TestNormalizePath.test_escapes_project_root`. + +Test-only helper functions do not need corresponding test classes. + +Standalone test functions SHOULD be used only for module-level invariants or file-level checks that do not naturally belong to one tested function or class. + +Every meaningful execution path of a tested function or method MUST have a corresponding test method or parametrized test case. + +Execution paths include: + +- successful path. +- default-value path. +- empty-input path. +- invalid-input path. +- handled error path. +- warning-producing path. +- branch-specific path. + +Tests MUST cover corner cases for each tested function or method. + +Corner cases include: + +- boundary values. +- empty collections and empty strings. +- missing optional values. +- duplicate values. +- unsupported values. +- malformed input. +- paths that do not exist. +- values that require normalization. +- repeated calls that may reveal state leaks. + +## Entity tests + +Entity tests SHOULD verify local invariants of entities. + +Entity tests MUST verify behavior or invariants owned by the entity. + +Entity tests SHOULD cover: + +- entity-specific Pydantic field validation. +- entity-specific Pydantic model validation. +- non-trivial defaults or default factories. +- normalization behavior owned by the entity. +- entity-specific methods and properties. +- serialization or deserialization behavior owned by the entity. +- rejection of invalid values that the entity is responsible for rejecting. + +Entity tests MUST NOT be added only to satisfy file-to-module layout symmetry. + +Entity tests MUST NOT verify that constructor arguments are assigned to fields unchanged. + +Entity tests MUST NOT verify simple Pydantic model construction when the entity has no custom validators, constrained fields, non-trivial defaults, normalization, serialization, computed properties, or entity-specific methods. + +Entity tests MUST NOT verify plain `NewType`, enum member existence, or passive data-container fields unless the module owns non-trivial conversion, validation, serialization, persistence compatibility, protocol compatibility, or external-input compatibility behavior for them. + +For entity-only modules that contain only passive entities, the absence of a matching `tests/test_.py` file is acceptable and SHOULD be preferred over meaningless tests. + +Entity tests SHOULD NOT test behavior inherited unchanged from the shared base entity. + +Entity tests SHOULD NOT test trivial Pydantic behavior unless the entity customizes that behavior. + +Entity tests MUST NOT require filesystem access unless the entity itself explicitly owns path normalization that depends on filesystem semantics. + +Entity tests MUST NOT verify CLI rendering. + +Entity tests MAY assert `pydantic.ValidationError` for invalid low-level model construction. + +## Error tests + +Error tests SHOULD verify behavior customized by concrete error classes. + +### Testing `errors.py` modules + +Error modules that contain only declarative error subclasses SHOULD NOT have unit tests solely for file-to-module layout symmetry. + +Error class unit tests SHOULD be added only when the error class owns behavior beyond inherited construction and static class attributes. + +Error class unit tests MAY cover: + +- custom constructor logic. +- custom `content_intro()` behavior. +- custom validation or normalization. +- non-trivial structured metadata derivation. +- behavior added by an intermediate error class. + +Leaf error tests MUST NOT assert only that class-level `code`, `message`, `ways_to_fix`, `cell_kind`, or constructor fields are present unchanged. + +Exact production error message text MUST NOT be asserted in ordinary unit tests unless a behavior specification declares the text as a stable external contract. + +Error tests SHOULD NOT test unchanged inheritance from project or module root error classes. + +Error tests SHOULD NOT test behavior inherited unchanged from the shared base error class. + +A module-level `tests/test_errors.py` file is optional. + +A module-level `tests/test_errors.py` file SHOULD be omitted when all meaningful error behavior is already covered by tests for the functions or entities that produce those errors. + +### Testing error-producing behavior + +Tests for exception boundaries SHOULD verify that expected low-level failures are converted into Donna environment errors. + +Tests for exception boundaries SHOULD verify that `pydantic.ValidationError` from external input is converted into Donna environment errors. + +Tests for `Result`-returning functions SHOULD verify error values through `Result` state rather than by expecting environment errors to be raised. + +Tests that verify produced environment errors SHOULD assert the expected error type, stable error code, and relevant structured fields through the behavior boundary that returns the error. + +Internal error tests MAY assert raised `InternalError` subclasses when the tested behavior is an internal invariant. + +CLI tests SHOULD verify that rendered environment errors use the expected Donna cell shape when command execution has entered Donna's protocol layer. + +## Behavior coverage + +Behavior specifications are the source of expected externally visible behavior. + +Tests SHOULD cover examples and rules in behavior specifications when the corresponding behavior is implemented and runtime-testable. + +Configuration tests SHOULD cover: + +- configuration discovery. +- `--config` behavior. +- supported TOML structure. +- default values. +- path normalization. +- invalid configuration failures. +- journal command configuration. + +Artifact and workflow tests SHOULD cover: + +- artifact id normalization. +- artifact section id normalization. +- Markdown artifact parsing. +- workflow artifact validation. +- primitive resolution. +- workflow execution until finish, failure, or action request. +- action request completion and transition validation. + +CLI tests SHOULD cover: + +- command forms. +- option parsing. +- output protocol selection. +- command output cell shape. +- artifact listing and ordering. +- artifact rendering modes. +- validation selection with explicit artifact ids and `--all`. +- session status and details. +- errors and exit behavior. + +Architecture specifications are the source of expected project-wide conventions. + +Architecture tests SHOULD cover examples and rules in architecture specifications when the corresponding behavior is implemented and runtime-testable. + +## Fixtures and temporary data + +Tests that need files SHOULD create those files in temporary directories. + +Tests SHOULD keep fixture data as small as possible while preserving the behavior being verified. + +Inline fixture data SHOULD be preferred for short configuration files, short workflow artifacts, short Markdown inputs, and short expected outputs. + +Reusable fixture files MAY be added when inline data would obscure the test. + +Fixture paths SHOULD use forward slashes in expected normalized identifiers. + +Tests that change the current working directory MUST restore it before the test ends. + +Tests that modify process-level global state, context variables, protocol mode, installed workspace configuration, or mocks MUST restore them before the test ends. + +## Assertions + +Tests SHOULD assert structured values before rendered text when structured values are available. + +Rendered output tests SHOULD assert exact output only for stable protocol, CLI, or persisted-state contracts. + +Rendered output tests MAY assert selected lines, fields, or records when exact text is intentionally outside the relevant specification. + +Automation protocol tests SHOULD parse JSON Lines output before asserting record contents. + +Tests SHOULD assert both positive and negative outcomes when a branch is expected to exclude another branch. diff --git a/specs/behavior/cli.md b/specs/behavior/cli.md new file mode 100644 index 00000000..667ca76c --- /dev/null +++ b/specs/behavior/cli.md @@ -0,0 +1,587 @@ +# CLI Interface + +## Goal of the document + +This document describes how `donna` behaves as a command line interface, including: + +- how agents, users, and tools invoke it. +- which commands and arguments are accepted. +- which output protocols are supported. +- what each command does at the CLI boundary. + +## Scope + +The scope of this specification is limited to CLI behavior. + +The following topics are out of scope: + +- workflow operation semantics. +- Markdown artifact parsing rules. +- configuration file field semantics. +- internal session state representation. +- exact prose emitted by built-in skill documents. + +This specification may refer to the following concepts only to describe how the CLI accepts arguments and renders output: + +- Donna project roots. +- Donna artifact ids. +- artifact section ids. +- workflow artifacts. +- action requests. +- session state. + +## General behavior + +`donna` is a command line tool that helps agents run predefined workflows in a deterministic way. It maintains project-local session state, discovers workflow artifacts, runs workflow operations, emits action requests for agents, and accepts agent reports about the next operation to run. + +The CLI has four primary command areas: + +- `donna run ...` — start a workflow artifact in the current session. +- `donna continue` and `donna complete-action-request ...` — advance existing session work. +- `donna list`, `donna render ...`, and `donna validate ...` — inspect and validate workflow artifacts. +- `donna skill [DOCUMENT]` — print built-in agent-oriented documentation. + +The root command MUST be a command group. + +Global options, when present, MUST be provided before the subcommand: + +```bash +donna [GLOBAL_OPTIONS] COMMAND [COMMAND_OPTIONS] +``` + +The CLI MUST write requested command output to stdout. + +Diagnostics that are not part of the requested output SHOULD be represented as Donna error cells when the selected protocol has already been installed. + +For `automation` output, stdout MUST contain only JSON Lines records when command output is produced through Donna cells or journal records. + +The CLI MUST produce deterministic output for the same: + +- input. +- configuration. +- working directory. +- project state. + +Commands that load a workspace MUST discover or use a Donna configuration file before executing command-specific behavior. + +`donna skill ...`, `donna init`, and `donna version` MUST NOT require an existing Donna project configuration. + +## Commands + +The CLI MUST support these commands and command forms: + +- `donna [GLOBAL_OPTIONS] init` — create a starter `donna.toml`. +- `donna [GLOBAL_OPTIONS] list` — list discovered workflow artifacts. +- `donna [GLOBAL_OPTIONS] render [OPTIONS] ARTIFACT` — render one artifact. +- `donna [GLOBAL_OPTIONS] validate [OPTIONS] [ARTIFACT...]` — validate selected artifacts or every discovered artifact. +- `donna [GLOBAL_OPTIONS] new-session` — create fresh session state. +- `donna [GLOBAL_OPTIONS] continue` — continue queued workflow execution in the current session. +- `donna [GLOBAL_OPTIONS] status` — show concise session status. +- `donna [GLOBAL_OPTIONS] details` — show detailed session state. +- `donna [GLOBAL_OPTIONS] run WORKFLOW` — start a workflow artifact in the current session. +- `donna [GLOBAL_OPTIONS] complete-action-request ACTION_REQUEST_ID NEXT_OPERATION` — complete an action request and continue with the selected operation. +- `donna [GLOBAL_OPTIONS] skill [DOCUMENT]` — print built-in agent-oriented documentation for using `donna`. +- `donna [GLOBAL_OPTIONS] version` — print the tool version. +- `donna --help` — print root help information. + +The root command MUST NOT start or continue workflow execution directly. + +## Output behavior + +All output MUST use UTF-8. + +Command output produced through Donna's protocol layer MUST be represented as Donna cells. + +The `render` command MUST write rendered Markdown directly. + +The `version` command MUST print a plain version line. + +Help and command line parsing output MAY use Typer's standard rendering. + +Commands MAY emit Donna journal records while executing. Journal records are command output when they are printed by the selected protocol formatter. + +Output MUST NOT contain terminal color or styling escape sequences. + +## Output protocols + +The CLI MUST support three output protocols: + +- `human` — default protocol for terminal users. +- `llm` — text protocol optimized for coding agents that invoke `donna` as a tool. +- `automation` — protocol optimized for programs; output is serialized as JSON Lines. + +`human` and `llm` MUST be separate protocols. + +For commands that support output protocols, the output protocol MUST be selected with the global option: + +```bash +--protocol PROTOCOL +-p PROTOCOL +``` + +Allowed values MUST be: + +- `human` +- `llm` +- `automation` + +If no protocol is provided, the default protocol MUST be `human`. + +### Human output + +Human output SHOULD be compact terminal text. + +Human cell output MUST include a visible cell boundary header with the generated cell id. + +Human cell output MUST render cell metadata as `key = value` lines. + +Human journal output SHOULD include the time, current task id when present, actor id, and message. + +### LLM output + +The `llm` protocol MUST be used when a coding agent invokes `donna` as a tool. + +LLM cell output MUST use explicit machine-readable boundary lines: + +```text +--DONNA-CELL BEGIN-- +--DONNA-CELL END-- +``` + +LLM cell output MUST render cell metadata as `key=value` lines. + +LLM output SHOULD be stable and self-contained for coding agents that receive the output as a tool result. + +LLM journal output SHOULD include the full timestamp, current task id, actor id, current work unit id, current operation id, and message. + +### Automation output + +Automation output MUST be serialized as JSON Lines. + +Automation output MUST write one JSON object per line. + +Automation output MUST use stable field names. + +Automation cell output MUST include: + +```json +{"id":"generated cell id","content":"cell content or null"} +``` + +Automation cell output MUST include cell metadata as top-level JSON object fields. + +Automation output MUST sort JSON object keys. + +Automation journal output MUST serialize the journal record as one JSON object per line. + +Additional fields MAY be added in future versions. Consumers MUST ignore unknown fields. + +## Donna cells + +A Donna cell is the protocol-level output unit used by most CLI commands. + +Each Donna cell MUST have: + +- a generated `id`. +- a `kind`. +- optional `media_type`. +- optional `content`. +- metadata fields. + +Cell ids MAY be generated at runtime. Consumers MUST NOT treat generated cell ids as deterministic identifiers. + +Cell metadata fields MUST be rendered in deterministic order by metadata key when the selected formatter emits ordered metadata. + +Cell content MUST have a media type when content is present. + +Commands MAY emit multiple cells for one invocation. + +### Human cell example + +Human protocol cell output SHOULD follow this shape: + +```text +----- DONNA CELL ----- +kind = session_state_status +media_type = text/markdown +pending_action_requests = 0 +queued_work_units = 0 +tasks = 0 + +The session is IDLE. + +``` + +### LLM cell example + +LLM protocol cell output SHOULD follow this shape: + +```text +--DONNA-CELL BEGIN-- +kind=session_state_status +media_type=text/markdown +pending_action_requests=0 +queued_work_units=0 +tasks=0 + +The session is IDLE. +--DONNA-CELL END-- +``` + +### Automation cell example + +Automation protocol cell output SHOULD follow this shape: + +```json +{"content":"The session is IDLE.","id":"","pending_action_requests":0,"queued_work_units":0,"tasks":0} +``` + +## Global options + +### `-h`, `--help` + +`-h` and `--help` MUST print help information and exit with status `0`. + +Example: + +```bash +donna --help +``` + +### `-p`, `--protocol PROTOCOL` + +`-p` and `--protocol PROTOCOL` MUST be global options accepted before the subcommand. + +The selected protocol MUST be available to every subcommand. + +Subcommands that render Donna cells or journal records MUST use the selected protocol. + +Allowed values MUST be: + +- `human` +- `llm` +- `automation` + +### `--config PATH` + +`--config PATH` MUST be a global option accepted before the subcommand. + +The config path MUST identify a local TOML configuration file for commands that load a workspace. + +When provided to a command that loads a workspace, `PATH` MUST be used as the active Donna configuration file and Donna MUST NOT perform upward discovery. + +When `PATH` is relative, it MUST be resolved relative to the current working directory. + +The project root MUST be the directory containing the active configuration file. + +When omitted, commands that load a workspace MUST discover `donna.toml` by searching from the current working directory toward the filesystem root. + +Subcommands that do not load workspace configuration MAY use `PATH` to derive their target directory or target configuration file. + +## Artifact id arguments + +CLI arguments that identify Donna artifacts MUST be accepted as: + +- root-anchored artifact ids. +- relative filesystem paths that resolve inside the Donna project root. +- absolute filesystem paths that resolve inside the Donna project root. + +Accepted artifact arguments MUST normalize to canonical root-anchored artifact ids before command-specific behavior uses them. + +Artifact arguments used by workflow artifact commands MUST identify files with the Donna artifact extension: + +```text +.donna.md +``` + +Artifact arguments that load existing workflow artifacts MUST identify artifacts visible through configured workflow directories. + +Artifact section arguments MUST use artifact section id syntax: + +```text +@/path/to/workflow.donna.md:section_id +``` + +Artifact section arguments MUST normalize the artifact part as an artifact id and validate the section id part as a Donna section id. + +## `donna init` command + +The `init` command MUST create a starter Donna configuration file. + +```bash +donna init +donna --config /path/to/project/donna.toml init +``` + +When no `--config` path is provided, the command MUST create `donna.toml` in the current working directory. + +When `--config PATH` is provided, the command MUST create the configuration file at that path and use the directory containing the file as the project root. + +When `--config PATH` is provided, the directory containing `PATH` MUST exist. + +The command MUST NOT discover an existing configuration file in parent directories. + +The command MUST NOT overwrite an existing configuration file. + +The generated configuration MUST be valid TOML and use schema version `1`. + +The command MUST render a success cell when initialization succeeds. + +The command MUST NOT accept artifact arguments, session arguments, or skill document arguments. + +## `donna list` command + +The `list` command MUST list workflow artifacts discovered under configured workflow directories. + +```bash +donna list +``` + +The command MUST load workspace configuration. + +The command MUST render one status cell per discovered artifact. + +Discovered artifacts MUST be ordered deterministically by configured workflow directory order and filesystem traversal order. + +Duplicate artifact ids discovered through multiple workflow directories MUST be emitted once. + +Missing workflow directories MUST be ignored. + +The command MUST NOT accept artifact arguments or session arguments. + +## `donna render` command + +The `render` command MUST render one artifact with the selected render mode and write rendered Markdown to stdout. + +```bash +donna render --mode MODE ARTIFACT +``` + +`ARTIFACT` MUST be an artifact id or path accepted by Donna artifact id normalization. + +The `--mode MODE` option MUST be required. + +Allowed render modes MUST include: + +- `view` +- `execute` +- `analysis` + +The rendered artifact output MUST be written as raw Markdown rather than wrapped in a Donna cell. + +This command MAY still emit Donna journal records before the rendered Markdown when artifact rendering logs command activity. + +## `donna validate` command + +The `validate` command MUST validate selected workflow artifacts or every discovered workflow artifact. + +```bash +donna validate ARTIFACT... +donna validate --all +``` + +The command MUST require exactly one of: + +- one or more artifact arguments. +- `--all`. + +The command MUST fail when `--all` is used together with one or more artifact arguments. + +When artifact arguments are provided, the command MUST normalize and validate each artifact id. + +When `--all` is provided, the command MUST validate every discovered workflow artifact. + +If validation finds errors, the command MUST render error cells. + +If validation succeeds, the command MUST render a success cell. + +## `donna new-session` command + +The `new-session` command MUST create fresh session state. + +```bash +donna new-session +``` + +The command MUST load workspace configuration. + +The command MUST operate on the session stored under the configured session directory. + +The command MUST render resulting session cells. + +## `donna status` command + +The `status` command MUST show concise session status. + +```bash +donna status +``` + +The command MUST load workspace configuration. + +The command MUST operate on the session stored under the configured session directory. + +The output MUST include whether Donna is idle or has pending action requests. + +## `donna details` command + +The `details` command MUST show detailed session state. + +```bash +donna details +``` + +The command MUST load workspace configuration. + +The command MUST operate on the session stored under the configured session directory. + +The output MUST include action requests when they are present in the session state. + +## `donna continue` command + +The `continue` command MUST continue queued workflow execution. + +```bash +donna continue +``` + +The command MUST load workspace configuration. + +The command MUST operate on the session stored under the configured session directory. + +The command MUST advance queued workflow execution until the workflow finishes, workflow execution fails, or Donna emits an action request for the agent. + +The command MUST emit resulting cells. + +## `donna run` command + +The `run` command MUST start a workflow artifact in the current session. + +```bash +donna run WORKFLOW +``` + +The command MUST load workspace configuration. + +The command MUST operate on the session stored under the configured session directory. + +The command MUST normalize `WORKFLOW` as an artifact id. + +The command MUST load the workflow artifact before starting it. + +The command MUST execute the started workflow until the workflow finishes, workflow execution fails, or Donna emits an action request for the agent. + +## `donna complete-action-request` command + +The `complete-action-request` command MUST complete an action request and continue workflow execution. + +```bash +donna complete-action-request ACTION_REQUEST_ID NEXT_OPERATION +``` + +The command MUST load workspace configuration. + +The command MUST operate on the session stored under the configured session directory. + +The command MUST: + +- validate the action request id format. +- normalize `NEXT_OPERATION` as an artifact section id. +- mark the action request as completed. +- queue the selected next operation. +- continue workflow execution immediately. + +After the selected next operation is queued, the command MUST advance workflow execution until the workflow finishes, workflow execution fails, or Donna emits an action request for the agent. + +## `donna skill` command + +The `skill` command MUST print built-in documentation for coding agents. + +```bash +donna skill +donna skill usage +donna skill configuration +donna skill initialization +donna skill workflows +``` + +The command output SHOULD be suitable for coding agents that receive the output as a tool result. + +The command MUST NOT load workspace configuration. + +The `skill` command MUST accept an optional document argument. + +Allowed document argument values MUST be: + +- `usage` — print general command usage documentation. +- `configuration` — print configuration documentation. +- `initialization` — print initialization documentation. +- `workflows` — print workflow authoring and execution documentation. + +When no document argument is provided, `donna skill` MUST behave like `donna skill usage`. + +Unknown document argument values MUST fail as invalid command line arguments. + +## `donna version` command + +The `version` command MUST print the installed Donna package version and exit with status `0`. + +Version output MUST be a single line containing only the version number. + +```bash +donna version +``` + +The `version` command MUST NOT load workspace configuration. + +The `version` command MAY ignore global options that do not affect version output. + +## Help Examples + +### Help + +Command: + +```bash +donna --help +``` + +Help output SHOULD be autogenerated from: + +- command definitions. +- argument definitions. +- option definitions. + +## Errors and exit codes + +Typer command line parsing errors SHOULD use Typer's standard invalid-arguments behavior. + +Workspace, artifact, validation, and environment errors SHOULD be rendered as Donna error cells when possible. + +Environment errors rendered through Donna error cells currently exit with status `0`. + +Human and LLM error cells SHOULD be written to stdout like other Donna cells. + +For automation output, rendered fatal errors SHOULD be written to stdout as JSON Lines cell records when possible. + +If command line parsing fails before Donna installs an output protocol, diagnostics MAY be written by Typer using its standard behavior. + +## Compatibility rules + +The CLI SHOULD preserve backward compatibility for: + +- command names. +- option names. +- output protocol names. +- artifact id syntax. +- artifact section id syntax. +- automation JSONL field meanings. + +Backward-compatible additions MAY include: + +- new commands. +- new options. +- new output cell metadata fields. +- new skill documents. + +Backward-incompatible changes MUST be documented in this specification before implementation. diff --git a/specs/behavior/config.md b/specs/behavior/config.md new file mode 100644 index 00000000..b2b7a881 --- /dev/null +++ b/specs/behavior/config.md @@ -0,0 +1,295 @@ +# Configuration + +## Goal of the document + +This document describes the behavior and semantics of the `donna.toml` configuration file, including: + +- where it is found. +- how it is interpreted. +- how session storage is configured. +- how workflow artifact discovery is configured. +- how Markdown section defaults and journal forwarding are configured. + +## Scope + +The scope of this specification is limited to configuration file behavior. + +The following topics are out of scope: + +- CLI invocation details. +- output protocol formatting. +- workflow operation semantics. +- Markdown artifact source format. +- internal session state schema. +- project module ownership. + +This specification defines configuration semantics that the implementation MUST honor, but it does not require any particular implementation strategy. + +## Dictionary + +- `configuration file` — a TOML file named `donna.toml` that configures one Donna project. +- `project root` — the directory that contains the active configuration file. +- `session directory` — the project directory where Donna stores runtime session state and session-created artifacts. +- `workflow directory` — a project directory recursively scanned for Donna workflow artifacts. +- `section default` — a fallback Markdown section configuration value used when an artifact section omits the value. +- `journal command` — an optional external command invoked for Donna journal records. + +## Configuration file discovery + +The canonical configuration file name MUST be `donna.toml`. + +When a workspace-loading command is invoked without `--config`, `donna` MUST discover the configuration file by searching from the current working directory toward the filesystem root. + +Discovery MUST stop at the first directory that contains `donna.toml`. + +The directory containing the discovered file MUST be the project root. + +When `--config PATH` is provided to a workspace-loading command, `donna` MUST use that file as the configuration file and MUST NOT perform upward discovery. + +If `PATH` is relative, it MUST be resolved relative to the current working directory. + +When `--config PATH` is provided, the directory containing the resolved file MUST be the project root. + +When `--config PATH` is provided to a workspace-loading command, the resolved file MUST exist. + +If no configuration file can be found or the configured path cannot be loaded, workspace loading MUST fail. + +Configuration loading MUST be deterministic for the same: + +- configuration file content. +- current working directory. +- filesystem state. + +## TOML structure + +The configuration file MUST be valid TOML. + +The top-level configuration MAY contain these fields: + +- `version` — configuration schema version. +- `session_dir` — path to Donna runtime session storage. +- `workflow_dirs` — list of directories scanned for workflow artifacts. +- `defaults` — fallback configuration for Markdown artifact sections. +- `journal` — optional external journal forwarding configuration. + +Unknown top-level fields MUST cause configuration loading to fail. + +The initial schema version MUST be `1`. + +If `version` is omitted, `donna` MUST treat the configuration as schema version `1`. + +If `version` is present, it MUST be an integer. + +If `version` is not supported, configuration loading MUST fail. + +Minimal example: + +```toml +version = 1 +``` + +Starter example: + +```toml +version = 1 + +session_dir = ".session/donna" + +workflow_dirs = [ + "./workflows", + "./.session/donna", +] +``` + +## Session directory + +The `session_dir` field MAY be omitted. + +If omitted, `session_dir` MUST default to: + +```toml +session_dir = ".session/donna" +``` + +The `session_dir` field MUST be a project-relative path. + +The `session_dir` field MUST NOT be an absolute host filesystem path. + +The `session_dir` field MUST NOT contain parent-directory references. + +The path MUST be resolved from the Donna project root. + +Donna commands that use session state MUST store runtime state under this directory. + +Session directories MAY be created lazily by runtime commands. + +## Workflow directories + +The `workflow_dirs` field MAY be omitted. + +If omitted, `workflow_dirs` MUST default to: + +```toml +workflow_dirs = [ + "./workflows", + "./.session/donna", +] +``` + +If present, `workflow_dirs` MUST be a list of project-relative paths. + +Each workflow directory MUST NOT be an absolute host filesystem path. + +Each workflow directory MUST NOT contain parent-directory references. + +Duplicate workflow directory entries MUST be removed while preserving the first occurrence. + +Workflow directory paths MUST be resolved from the Donna project root. + +Missing workflow directories MUST be ignored during artifact discovery. + +Donna MUST recursively scan workflow directories for files ending with `.donna.md`. + +Donna MUST ignore files without the `.donna.md` suffix when discovering workflow artifacts. + +Artifact discovery MUST be deterministic for the same `workflow_dirs` order and filesystem state. + +## Defaults + +The `defaults` field MAY be omitted. + +If present, `defaults` MUST be a TOML table. + +The `defaults` table MAY contain these fields: + +- `tail_section_kind` — default primitive path for H2 sections. +- `primary_section_kind` — default primitive path for the H1 section. +- `primary_section_id` — default id for the H1 section. + +Unknown `defaults` fields MUST cause configuration loading to fail. + +If omitted, the effective defaults MUST be: + +```toml +[defaults] +tail_section_kind = "donna.lib.text" +primary_section_kind = "donna.lib.workflow" +primary_section_id = "primary" +``` + +Default primitive paths MUST be valid Python-path-like Donna primitive identifiers. + +`primary_section_id` MUST be a valid Donna section id. + +Explicit section config in a Markdown artifact MUST override configuration defaults. + +Projects SHOULD NOT change section defaults unless they intentionally use custom Donna primitives or a different artifact convention. + +## Journal + +The `journal` field MAY be omitted. + +If present, `journal` MUST be a TOML table. + +The `journal` table MAY contain: + +- `cmd` — optional command argument list used to forward Donna journal records to an external command. + +Unknown `journal` fields MUST cause configuration loading to fail. + +If `journal.cmd` is omitted or `None`, Donna MUST NOT execute an external journal command. + +If present, `journal.cmd` MUST be a non-empty list of command arguments. + +Each argument MUST be a string. + +Donna MUST execute the configured journal command once per journal record. + +Donna MUST execute the command directly as an argument list, not through a shell. + +Donna MUST treat a journal command execution failure as an environment error. + +### Journal placeholders + +Donna MUST recognize placeholders only when the whole command argument starts with `{` and ends with `}`. + +The supported placeholders MUST be: + +- `{timestamp}` — record creation time formatted as ISO-8601. +- `{actor_id}` — actor that created the record, or an empty string. +- `{message}` — journal message. +- `{current_task_id}` — current task id, or an empty string. +- `{current_work_unit_id}` — current work unit id, or an empty string. +- `{current_operation_id}` — current operation artifact section id, or an empty string. + +Unsupported placeholder names MUST cause configuration loading or journal command argument construction to fail. + +Arguments that contain placeholder-like text as only part of the value MUST be treated as literal arguments. + +For example, `{message}` is a placeholder, but `message:{message}` is a literal argument. + +Example: + +```toml +[journal] +cmd = [ + "./bin/journal-tool.sh", + "record", + "{timestamp}", + "{actor_id}", + "{current_task_id}", + "{current_operation_id}", + "{message}", +] +``` + +Donna MUST still print newly created journal records through the selected output protocol when `journal.cmd` is omitted. + +## Starter configuration + +The `donna init` command MUST create a starter configuration based on the packaged base config fixture. + +The starter configuration MUST: + +- use schema version `1`. +- set `session_dir` to `.session/donna`. +- set `workflow_dirs` to `./workflows` and `./.session/donna`. +- include commented examples for `defaults`. +- include commented examples for `journal.cmd`. + +The starter configuration MUST be valid TOML after comments are ignored. + +## Invalid configuration + +Configuration loading MUST fail for: + +- invalid TOML. +- unsupported schema version. +- unknown top-level fields. +- unknown fields in known nested tables. +- invalid `session_dir`. +- invalid `workflow_dirs`. +- invalid default primitive paths. +- invalid default primary section id. +- empty `journal.cmd`. +- unsupported journal placeholders. + +## Compatibility rules + +The configuration schema SHOULD preserve backward compatibility for: + +- the `donna.toml` file name. +- schema version semantics. +- existing top-level field names. +- existing nested field names. +- default `session_dir`. +- default `workflow_dirs`. +- supported journal placeholder meanings. + +Backward-compatible additions MAY include: + +- new optional fields. +- new supported schema versions. +- new journal placeholders. + +Backward-incompatible changes MUST be documented in this specification before implementation. diff --git a/specs/behavior/file_paths.md b/specs/behavior/file_paths.md new file mode 100644 index 00000000..5562efc0 --- /dev/null +++ b/specs/behavior/file_paths.md @@ -0,0 +1,298 @@ +# File paths + +## Goal of the document + +This document describes the syntax, semantics, and resolution rules for local project paths and artifact identifiers used by `donna`. + +## Scope + +The scope of this specification is limited to path identifiers that refer to files and artifact sections inside the active Donna project. + +The following topics are out of scope: + +- workflow operation semantics. +- artifact source parsing details. +- filesystem discovery algorithms, except for canonical path representation. +- output protocol formatting details, except for canonical path representation. + +## Dictionary + +- `project root` — the root directory of the active Donna project. +- `project path` — a file-like path identifier that addresses a non-root location inside the project root and can be represented as a canonical root-anchored id. +- `artifact id` — a canonical project-root-anchored identifier for a Donna artifact file. +- `artifact section id` — an artifact id plus a section id separated by `:`. +- `root-anchored path` — a project path that starts with `@/` and is resolved from the project root. +- `relative path` — a path that does not start with `@/` and is resolved against an explicit base path or the command current working directory. +- `canonical path` — the normalized root-anchored representation of a project path. +- `base path` — a project file or directory used by a context to resolve relative paths. + +## Project root + +The project root MUST be a local filesystem directory. + +For commands that load `donna.toml`, the project root MUST be the directory that contains the active configuration file. + +When `--config PATH` is provided, the directory containing `PATH` MUST be treated as the project root by commands that load workspace configuration. + +A project path MUST identify a non-root location inside the project root. + +A project path MUST NOT identify a location outside the project root after path normalization. + +A project path MUST NOT use an absolute host filesystem path as its canonical representation. + +A canonical project path MUST satisfy Donna artifact id path syntax, including a suffixed final path segment. + +## Root-anchored syntax + +The canonical syntax for a project path MUST be: + +```text +@/path/inside/project.ext +``` + +The `@/` marker MUST represent the project root. + +The `/` character MUST separate path segments. + +The path after `@/` MUST contain at least one path segment. + +The canonical representation MUST NOT contain: + +- empty path segments. +- `.` path segments. +- `..` path segments. +- a trailing `/`. + +Examples of valid canonical paths: + +```text +@/README.md +@/workflows/polish.donna.md +@/.session/donna/plans/implement-feature.donna.md +``` + +Examples of invalid canonical paths: + +```text +@ +@/ +@/workflows/../README.md +@/workflows//polish.donna.md +@/workflows/ +/home/user/project/workflows/polish.donna.md +``` + +## Path semantics + +A project path identifies a local project location by its normalized position under the project root. + +Path identity MUST be based on the canonical path, not on the textual form originally provided by a user, command source, or other input. + +Two path inputs that normalize to the same canonical path MUST identify the same project path. + +The existence of a project path MUST be checked by the context that uses it. + +A field that declares existing-file semantics MUST reject or skip canonical paths that do not correspond to an existing regular file, according to that field's behavior. + +A field that declares reference semantics MAY accept canonical paths that do not exist yet. + +## Path normalization + +Path normalization MUST produce a canonical root-anchored path. + +Normalization MUST: + +- resolve the input against the project root, command current working directory, or an explicit base path. +- remove redundant `.` path segments. +- process `..` path segments. +- reject the path if processing `..` escapes the project root. +- use `/` as the path separator in the canonical representation. +- preserve meaningful path segment case. + +Normalization MUST NOT require the referenced file to exist unless the calling context requires an existing file. + +Implementations MUST reject inputs that cannot be normalized to a project path inside the project root. + +## Artifact ids + +An artifact id MUST be a canonical root-anchored project path. + +Artifact ids accepted by workflow commands MUST identify files with the Donna artifact extension: + +```text +.donna.md +``` + +Artifact id path segments MUST contain only: + +- ASCII letters. +- ASCII digits. +- `.`. +- `_`. +- `-`. + +Each artifact id path segment MUST contain at least one character that is not `.` or `-`. + +The last path segment MUST have a file suffix. + +Artifact ids SHOULD be written as root-anchored paths in workflow instructions, agent notes, and persisted session state. + +Examples: + +```text +@/workflows/polish.donna.md +@/workflows/rfc/do.donna.md +@/.session/donna/plans/feature.donna.md +``` + +## Artifact section ids + +An artifact section id MUST combine an artifact id and a local section id with `:`. + +The canonical syntax MUST be: + +```text +@/path/to/artifact.donna.md:section_id +``` + +The artifact part MUST be a valid artifact id. + +The section id part MUST be a valid Donna section id. + +Section ids MUST contain only: + +- ASCII letters. +- ASCII digits. +- `.`. +- `_`. +- `-`. + +The section id MUST contain at least one character that is not `.` or `-`. + +Artifact section ids are used by `complete-action-request` to identify the next operation selected by an agent. + +## Root-anchored resolution + +A root-anchored path MUST be resolved from the project root. + +For example, in a project rooted at `/project`, the input: + +```text +@/workflows/polish.donna.md +``` + +resolves to: + +```text +/project/workflows/polish.donna.md +``` + +The normalized canonical representation remains: + +```text +@/workflows/polish.donna.md +``` + +Root-anchored inputs MAY contain redundant `.` or `..` segments before normalization, but the canonical representation MUST NOT contain them. + +For example: + +```text +@/workflows/rfc/../polish.donna.md +``` + +normalizes to: + +```text +@/workflows/polish.donna.md +``` + +If a root-anchored input attempts to escape the project root, normalization MUST fail. + +## Relative path resolution + +Relative paths MUST be accepted only by contexts that explicitly define a base path. + +CLI artifact path arguments MUST resolve relative paths from the command's current working directory. + +Artifact-local relative paths MAY be resolved relative to the directory that contains the source artifact. + +When the base path is a file, the relative path MUST be resolved against the directory that contains the base file. + +When the base path is a directory, the relative path MUST be resolved against that directory. + +After resolving a relative path, Donna MUST normalize the result to a canonical root-anchored path. + +For example, with base file: + +```text +@/workflows/rfc/do.donna.md +``` + +the relative input: + +```text +../polish.donna.md +``` + +normalizes to: + +```text +@/workflows/polish.donna.md +``` + +With the same base file, the relative input: + +```text +../../../outside.donna.md +``` + +MUST fail if it escapes the project root. + +New CLI examples and workflow instructions SHOULD prefer root-anchored paths unless relative addressing is central to the example. + +## CLI path inputs + +CLI input parameters that accept artifact paths MUST accept: + +- root-anchored paths. +- classical relative filesystem paths. +- classical absolute filesystem paths. + +CLI logic MUST normalize every accepted path input to a canonical root-anchored path before artifact loading, artifact validation, workflow execution, or action request completion. + +When a CLI input parameter receives a root-anchored path, the path MUST be resolved from the project root and normalized to the canonical root-anchored form. + +When a CLI input parameter receives a classical absolute filesystem path, the path MUST be normalized to a canonical root-anchored path if it points inside the project root. + +If a classical absolute filesystem path points outside the project root, the CLI MUST reject it. + +When a CLI input parameter receives a classical relative filesystem path, the path MUST first be resolved to an absolute filesystem path relative to the command's current working directory. + +The resolved absolute filesystem path MUST then be normalized to a canonical root-anchored path if it points inside the project root. + +If a classical relative filesystem path resolves outside the project root, the CLI MUST reject it. + +New CLI examples and protocol output SHOULD use canonical root-anchored paths unless demonstrating classical relative or absolute filesystem input compatibility. + +## Configuration paths + +Configuration paths such as `session_dir` and `workflow_dirs` MUST be project-relative paths. + +Configuration paths MUST be resolved from the Donna project root. + +Configuration paths MUST NOT be absolute host filesystem paths. + +Configuration paths MUST NOT contain parent-directory references. + +Configuration examples SHOULD use `./` prefixes for directory paths when doing so improves readability. + +## Host filesystem paths + +Absolute host filesystem paths MUST NOT be canonical project path identifiers. + +Implementations MAY accept an absolute host filesystem path as input only when the accepting context explicitly supports host path input. + +CLI artifact path parameters are one such context. + +When accepted, an absolute host filesystem path MUST resolve to a location inside the project root and MUST normalize to a canonical root-anchored path. diff --git a/specs/behavior/skill_fixtures.md b/specs/behavior/skill_fixtures.md new file mode 100644 index 00000000..3db53ca1 --- /dev/null +++ b/specs/behavior/skill_fixtures.md @@ -0,0 +1,172 @@ +# Skill Fixtures + +## Goal of the document + +This document describes the structure and content requirements for built-in skill documentation fixtures. + +Built-in skill fixtures are Markdown files embedded in the package and printed by `donna skill`. + +## Scope + +The scope of this specification is limited to built-in skill documentation fixture files. + +The following topics are out of scope: + +- exact fixture prose. +- CLI option parsing. +- output protocol rendering. +- project configuration semantics beyond examples needed in documentation. +- external Codex or agent skill installation. + +## Fixture location + +Built-in skill documentation fixtures MUST live under: + +```text +./donna/skills/fixtures/ +``` + +Each fixture MUST be a UTF-8 Markdown file. + +Each fixture SHOULD be loaded through `./donna/skills/fixtures.py` instead of direct filesystem reads from CLI or renderer code. + +## Fixture set + +The built-in fixture set MUST include one document per documentation area: + +- `usage.md` — general `donna` command usage. +- `configuration.md` — `donna.toml` configuration syntax and examples. +- `initialization.md` — project initialization workflow. +- `workflows.md` — Donna workflow artifact format, execution, and authoring guidance. + +Each fixture MUST start with a level-one heading that identifies the document: + +```markdown +# `donna` Usage +# `donna` Configuration +# `donna` Initialization +# `donna` Workflows +``` + +The fixture document names exposed by the CLI MUST be: + +- `usage`. +- `configuration`. +- `initialization`. +- `workflows`. + +## Usage fixture + +The usage fixture MUST describe how to invoke `donna` commands and interpret their output. + +It MUST describe available output protocols. + +It MUST describe project-root discovery and the `--config` option. + +It MUST describe root-anchored artifact ids and artifact section ids. + +It MUST describe how to access the other built-in skill documents. + +It MUST include agent safety rules for using Donna in an existing session. + +It MUST describe common workflow execution patterns, including: + +- starting a workflow. +- continuing queued workflow execution. +- completing action requests. +- nested workflows. +- creating or editing workflows when instructed. + +It MUST describe session commands. + +It MUST describe workflow artifact commands. + +It SHOULD keep configuration syntax brief and refer detailed configuration behavior to the configuration fixture. + +It MAY include concise configuration examples when they directly explain command output or agent-facing usage, such as journal forwarding. + +It MUST NOT tell an agent to run workflows without developer, project, or Donna instructions. + +## Configuration fixture + +The configuration fixture MUST describe `donna.toml` structure. + +It SHOULD include examples for: + +- a minimal config. +- the starter config generated by `donna init`. +- `session_dir`. +- `workflow_dirs`. +- `defaults`. +- `journal.cmd`. + +The configuration fixture MUST keep examples compatible with the implemented configuration schema. + +It MUST describe that the presence of `donna.toml` marks the Donna project root. + +It MUST describe supported journal placeholders. + +It SHOULD recommend project-owned workflow directories and session directories consistent with the default configuration. + +It SHOULD tell readers how to validate configuration changes with Donna commands. + +## Initialization fixture + +The initialization fixture MUST describe how to create a starter configuration. + +It MUST mention the `donna init` command. + +It MUST describe that initialization does not overwrite an existing configuration file. + +It MUST describe how `--config` affects the initialization target file and project root. + +It MUST describe that `donna init` creates `donna.toml` and does not create workflow files by itself. + +It MUST describe that session directories may be created lazily by runtime commands. + +It SHOULD include guidance for checking or filling the starter configuration after initialization. + +It SHOULD include guidance for creating first workflow files after initialization when the developer explicitly wants workflows. + +It SHOULD include a validation loop using current Donna commands. + +## Workflows fixture + +The workflows fixture MUST describe Donna workflow artifact format. + +It MUST describe that workflow files are Markdown files ending with `.donna.md`. + +It MUST describe the H1 and H2 section structure used by Donna artifacts. + +It MUST describe artifact ids, section ids, and artifact section ids. + +It MUST describe Donna config code fences and script code fences. + +It MUST describe standard operation kinds currently exposed through `donna.lib`, including: + +- `donna.lib.request_action`. +- `donna.lib.run_script`. +- `donna.lib.output`. +- `donna.lib.finish`. + +It MUST describe `donna.lib.text` as a text section kind rather than a workflow operation kind. + +It MUST describe `donna.lib.goto` for action request transitions. + +It MUST describe `donna.lib.task_variable` for rendering task variables. + +It MUST include at least one complete minimal workflow example. + +It MUST describe validation expectations for workflows. + +## Style + +Fixtures MUST be written as tool documentation. + +Fixtures SHOULD use concise Markdown sections, command examples, TOML examples, workflow examples, and representative output examples. + +Fixtures SHOULD prefer stable command forms and implemented behavior over speculative features. + +Fixtures MUST NOT require readers to inspect source code or specifications to understand documented command usage. + +Fixtures SHOULD use `donna -p llm ...` examples when documenting agent-facing command usage. diff --git a/specs/core/error_handling.md b/specs/core/error_handling.md deleted file mode 100644 index d79673ff..00000000 --- a/specs/core/error_handling.md +++ /dev/null @@ -1,305 +0,0 @@ - -# Error handling - -This document describes how Donna handles errors that may occur during its operation, including error propagation, logging, and recovery strategies. - -## Error kinds - -There are two different kinds of errors in Donna: - -- **Internal errors** — represented by the base class `InternalError(Exception)` — these errors indicate that something went wrong inside Donna itself, such as bugs in the code, unexpected states, or violations of internal invariants. These errors are not expected to be handled by agents or users, and usually indicate a need for fixing Donna's code. -- **Environment errors** — represented by the base class `EnvironmentError` (not an `Exception`) — these errors indicate that something went wrong in the external environment where Donna operates, such as missing artifacts, invalid artifacts formats, network issues, etc. These errors are expected to be handled by agents or users, and may have recovery strategies. - -Environment errors has functionality to describe themselves in a user-friendly way, according to `donna.protocol`. - -## Defining errors - -Base error classes are defined in the `donna.core.errors` module. - -Each top-level submodule of Donna MUST define its own error hierarchy by subclassing from `InternalError` and `EnvironmentError`. By convention, all error definitions MUST be located in the `donna..errors` module. - -The exceptions for this rule are: - -- `donna.primitives` — each submodule of primitives should be treated as self-contained unit, so error definitions SHOULD be located in the same module as primitive's code itself. -- `donna.lib` MUST not contain `errors` module, as it is just a collection of constructed primitives — no new errors should be defined there. - -Here is an example of defining a hierarchy of errors in `donna.xxx.errors`: - -```python -from donna.core import errors as core_errors - -################# -# Internal errors -################# - -class InternalError(core_errors.InternalError): - """Base class for internal errors in donna.xxx submodule.""" - pass - -class SomethingGoneWrong(InternalError): - """Indicates that something went wrong in donna.xxx submodule in a single specific case.""" - pass - -class DomainOrEntityError(InternalError): - """Base class for domain-specific or entity-specific internal errors.""" - pass - -class EntityGoneWrongCaseA(DomainOrEntityError): - """Indicates that something went wrong with an entity in a single case.""" - pass - -class EntityGoneWrongCaseB(DomainOrEntityError): - """Indicates that something else went wrong with an entity in another case.""" - pass - -#################### -# Environment errors -#################### - -class EnvironmentError(core_errors.EnvironmentError): - """Base class for environment errors in donna.xxx submodule.""" - pass - -# The logic of defining subclasses is similar to internal errors. - -``` - -**Each unique error case MUST be represented by its own subclass.** This allows precise error handling and testing. - -## Internal errors behavior - -Subclasses of internal errors can define parametrized error message: - -``` -... - -class MyInternalError(InternalError): - message = "Something gone wrong with entity {entity_id} due to {reason}." -``` - -**Internal errors are raised** using standard `raise` statement: - -``` -def produce_error(entity_id: str, reason: str): - raise MyInternalError(entity_id=entity_id, reason=reason) -``` - -## Environment errors behavior - -Environment errors are subclasses of Pydantic models and allow specifying structured data about the error. - -Domain specific environment errors MUST redefine `cell_kind` and domain-level attributes. For example: - -```python -class ArtifactValidationError(EnvironmentError): - cell_kind: str = "artifact_validation_error" - artifact_id: FullArtifactId - section_id: ArtifactSectionId | None = None - - def content_intro(self) -> str: - if self.section_id: - return f"Error in artifact '{self.artifact_id}', section '{self.section_id}'" - - return f"Error in artifact '{self.artifact_id}'" -``` - -Leaf/case environment errors must provide explicit information about the error and how the user can fix it. For example: - -```python -class FinalOperationHasTransitions(ArtifactValidationError): - code: str = "donna.workflows.final_operation_has_transitions" - message: str = "Final operation `{error.workflow_section_id}` should not have outgoing transitions." - ways_to_fix: list[str] = [ - "Approach A: Remove all outgoing transitions from this operation.", - "Approach B: Change the `fsm_mode` of this operation from `final` to `normal`", - "Approach C: Remove the `fsm_mode` setting from this operation, as `normal` is the default.", - ] - workflow_section_id: ArtifactSectionId -``` - -**Environment errors are returned** from functions as part of `Result` type: - -```python -... -from donna.core.errors import ErrorsList -from donna.core.result import Err, Ok, Result -... - -def validate_artifact(self) -> Result[None, ErrorsList]: - ... - - errors: ErrorsList = [] - - ... - - errors.append( - FinalOperationHasTransitions( - artifact_id=artifact.id, section_id=section_id, workflow_section_id=workflow_section.id - ) - ) - ... - - if errors: - return Err(errors) - - return Ok(None) -``` - -## Naming error classes - -Follow these guidelines when naming error classes: - -- Try to keep names short but descriptive. -- Prefer not to use `Error`, `Exception`, `Failure` suffixes if possible. -- Prefer not to use `Internal` or `Environment` in the class names. - -## Exceptions besides `InternalError` - -- **Use only `InternalError` for all Donna's internal exceptions, until the developer or the specification explicitly requires otherwise.** -- You MAY use other exception types if the third-party library you are working with requires it. For example, Pydantic models require `ValidationError` exceptions. -- You MAY use `NotImplementedError` as temporary code when: - - You need a temporary placeholder for the code that is not implemented yet, but will be in the scope of the current task. - - You need to raise an exception, but you have no established error hierarchy yet. - -You are encouraged to follow the next strategy when implementing new code: - -1. Use `NotImplementedError` as a temporary placeholders whenever you need to raise an exception. -2. When the code is ready, you review all `NotImplementedError` usages implement proper error hierarchy by defining new error classes as needed. -3. Replace all `NotImplementedError` usages with proper error classes. - -## Asserts - -Use `assert` statements as a hint for type checkers and linters to confirm invariants that are guaranteed by the code logic but type checkers cannot infer them automatically. - -Don't use `assert` statements for any other purpose, replace them with proper error handling code. - -## Do and Don'ts - -### No duplicated logic - -**DO NOT** define multiple functions with the same logic but different error handling strategies (e.g., one function raises exceptions, another returns `Result`). Instead define a single function with one error handling strategy that are explicitly specified by the developer or logically deduced from the context. - -### Update all calls up the call stack - -If you modify function to return environment errors, you **MUST** update all functions up the call stack that call this function to handle the returned errors properly: process, propagate or output (according to the context). - -### Long error definitions — short construction - -Define all possible environment error parameters in the body of subclass, not in the place where you construct the error object. It simplifies writing unit tests and shorten the code. - -Good example: - -```python -class FinalOperationHasTransitions(ArtifactValidationError): - code: str = "donna.workflows.final_operation_has_transitions" - message: str = "Final operation `{error.workflow_section_id}` should not have outgoing transitions." - ways_to_fix: list[str] = [ - "Approach A: Remove all outgoing transitions from this operation.", - "Approach B: Change the `fsm_mode` of this operation from `final` to `normal`", - "Approach C: Remove the `fsm_mode` setting from this operation, as `normal` is the default.", - ] - workflow_section_id: ArtifactSectionId - -... - -error = FinalOperationHasTransitions( - artifact_id=artifact.id, section_id=section_id, workflow_section_id=workflow_section.id -) -``` - -Bad Example: - -```python - -class FinalOperationHasTransitions(ArtifactValidationError): - workflow_section_id: ArtifactSectionId - -... - -error = FinalOperationHasTransitions( - artifact_id=artifact.id, - section_id=section_id, - workflow_section_id=workflow_section.id, - message="Final operation `{error.workflow_section_id}` should not have outgoing transitions.", - ways_to_fix=[ - "Approach A: Remove all outgoing transitions from this operation.", - "Approach B: Change the `fsm_mode` of this operation from `final` to `normal`", - "Approach C: Remove the `fsm_mode` setting from this operation, as `normal` is the default.", - ], -) -``` - -### Propagate original errors - -If you need to propagate errors and the function expected to return the same result type, do not unwrap it and wrap again, just return the original result. - -```python - -def some_function_a() -> Result[SomeType, SomeErrorType]: - ... - -def some_function_b() -> Result[SomeType, SomeErrorType]: - result = some_function_a() - - if result.is_err(): - return result # good: propagate the original errors - -def bad_example() -> Result[SomeType, SomeErrorType]: - result = some_function_a() - - if result.is_err(): - return Err(result.unwrap_err()) # Bad: unwrapping and wrapping again is unnecessary -``` - -### Use `unwrap_to_error` to make code shorter - -The `unwrap_to_error` decorator catches exceptions from `.unwrap()` calls and translate them to results with original errors. - -Use it whenever you see a construction like - -```python -result = some_function() -if result.is_err(): - return Err(result.unwrap_err()) -``` - -Good example: - -```python -... -from donna.core.result import Err, Ok, Result, unwrap_to_error -from donna.workspaces.artifacts import RENDER_CONTEXT_VIEW - -@unwrap_to_error -def resolve(target_id: FullArtifactSectionId) -> Result[ArtifactSection, ErrorsList]: - from donna.context.context import context - - artifact = context().artifacts.load(target_id.full_artifact_id, RENDER_CONTEXT_VIEW).unwrap() - - section = artifact.get_section(target_id.local_id).unwrap() - - return Ok(section) -``` - - -Bad example: - -```python -... -from donna.core.result import Err, Ok, Result, unwrap_to_error - -def resolve(target_id: FullArtifactSectionId) -> Result[ArtifactSection, ErrorsList]: - artifact_result = context().artifacts.load(target_id.full_artifact_id, RENDER_CONTEXT_VIEW) - - if artifact_result.is_err(): - return Err(artifact_result.unwrap_err()) - - artifact = artifact_result.unwrap() - - section_result = artifact.get_section(target_id.local_id) - - if section_result.is_err(): - return Err(section_result.unwrap_err()) - - return Ok(section_result.unwrap()) -``` diff --git a/specs/core/top_level_architecture.md b/specs/core/top_level_architecture.md deleted file mode 100644 index f83e8749..00000000 --- a/specs/core/top_level_architecture.md +++ /dev/null @@ -1,33 +0,0 @@ - -# Top-level architecture - -This document describes the top-level architecture of the Donna project, providing an overview of its main components and their interactions. - -## Basic statements - -- Donna is a CLI tool (`donna`) implemented in Python. - -## Code organization - -All the Donna's code is located in the `./donna/` directory. - -The code is separated by layers/subsystems into subpackages: - -- `donna.core` — code that not in the Donna's domain, but required to its functioning: domain-independent utils, basic classes for errors, exceptions and other entities, etc. -- `donna.domain` — code that is required by all Donna'specific logic: ID classes, common types, etc. -- `donna.machine` — code that implements the core Donna's logic — how Donna works regardless of external environments, i.e. pure domain behavior. -- `donna.context` — code that stores and provides execution-scoped runtime context for Donna's domain logic: artifact/state/primitive caches and scoped values like current actor/work unit/operation identifiers. -- `donna.workspaces` — code that integrates Donna with the project root and filesystem: runtime configuration, artifact discovery/loading, session storage, source parsing, and project initialization. -- `donna.protocol` — code that implements protocol via which Donna's core domain logic interacts with external environments: CLI, API, etc. Includes basic classes for information representing (for the external environments) and its formatting. -- `donna.cli` — code that implements the `donna` CLI tool, its commands, arguments parsing, etc. -- `donna.primitives` — code that implements basic building blocks for Donna's behavior: concrete implementations of various classes from the `donna.machine`. -- `donna.lib` — module that contains constructed primitives to be used in donna artifacts by referencing them by python import path. Like `donna.lib.workflow`, `donna.lib.goto`, etc. -- `donna.skills` — code and built-in text documents used by the `donna skill` command. - -## Data structures - -- Do not use `dataclass` for data structures. Use `donna.core.entities.BaseEntity` (subclass of the `pydantic.BaseModel`) for complex data structures and Python classes with `__slots__` for very simple ones (like cache keys). - -## Autotests - -- No autotests in the project for now. diff --git a/specs/dictionary.md b/specs/dictionary.md new file mode 100644 index 00000000..8e878fa8 --- /dev/null +++ b/specs/dictionary.md @@ -0,0 +1,28 @@ +# Dictionary + +## Goal of the document + +This document defines terms that are specific to the `donna` project and are shared by multiple specifications. + +## Scope + +The scope of this specification is limited to project-specific terminology. + +The following topics are out of scope: + +- detailed behavior. +- implementation requirements. +- configuration schemas. + +## Terms + +- `artifact` — a Markdown document interpreted by `donna`; currently expected to be a local file with the `.donna.md` extension. +- `workflow` — a state-machine-like graph of operations that guides an agent's work. +- `operation` — a workflow step that Donna can execute, render, or present as an action request. +- `action request` — a request emitted by Donna when workflow execution needs the agent to perform work or choose the next operation. +- `session` — project-local runtime state that Donna uses to continue workflow execution across CLI invocations. +- `protocol` — a CLI output contract selected by `--protocol`. +- `human protocol` — output protocol optimized for terminal users. +- `llm protocol` — output protocol optimized for coding agents that invoke `donna` as a tool. +- `automation protocol` — output protocol optimized for programs; output is serialized as JSON Lines. +- `warning` — a non-fatal problem discovered while processing a request. diff --git a/specs/documentation/changelog.md b/specs/documentation/changelog.md new file mode 100644 index 00000000..0fca6363 --- /dev/null +++ b/specs/documentation/changelog.md @@ -0,0 +1,84 @@ +# Changelog Documentation + +## Goal of the document + +This document describes the expected tooling, source files, structure, and entry format for the project changelog. + +## Scope + +The scope of this specification is limited to changelog documentation artifacts. + +The following topics are out of scope: + +- release version selection. +- package publishing. +- Git tagging. +- release automation implementation details. +- project documentation files other than changelog artifacts. + +## Dictionary + +- `changelog artifact` - a file that is either a Changy source file in `changes/` or the generated root `CHANGELOG.md`. +- `version record` - the Markdown content that describes changes for one released or unreleased version. + +## Tooling + +The project MUST use [Changy](https://github.com/Tiendil/changy/) to manage the changelog. + +Changelog source files MUST live in `changes/`. + +The root `CHANGELOG.md` MUST be generated from Changy source files. + +Unreleased changes MUST be recorded in `changes/unreleased.md`. + +## Version Record Structure + +A single changelog version record MAY contain these parts, in this order: + +1. A short introductory description of the whole version when the version contains large coordinated changes that benefit from context before individual entries. +2. A `Migration` section with instructions for users to migrate to the new version when there are breaking changes or required manual upgrade steps. +3. A `Changes` section with a bullet list of all notable changes in the version. +4. A `Deprecations` section with a bullet list of features or behaviors that are now deprecated. + +The `Migration`, `Changes`, and `Deprecations` sections MUST use `h3` Markdown headings. + +The `Changes` section SHOULD be present when the version contains notable user-visible, developer-visible, or project-maintenance changes. + +The `Migration` section MUST be present when users need to perform manual steps before, during, or after upgrading. + +The `Deprecations` section MUST be present when the version deprecates features, behaviors, APIs, commands, configuration fields, or documented workflows. + +Additional `h3` sections MAY be used when a version needs a distinct category that is not covered by `Migration`, `Changes`, or `Deprecations`. + +## Entry Format + +Each bullet entry SHOULD be linked to a task, issue, or pull request when such a reference exists. + +When an entry references a GitHub issue, the reference SHOULD use the `gh-` form. + +When an entry references a pull request or another task tracker, the reference SHOULD use the shortest stable project convention for that tracker. + +Each bullet entry SHOULD include a short description of the change, deprecation, migration instruction, or other notable item. + +Additional details MAY be added as nested bullet points under the main entry when the detail helps users understand the impact or required action. + +## Example + +```markdown + + +### Migration + +- gh-xxx - +- gh-yyy - + - + - + +### Changes + +- gh-zzz - + +### Deprecations + +- gh-www - +``` diff --git a/specs/documentation/readme.md b/specs/documentation/readme.md new file mode 100644 index 00000000..ad1568df --- /dev/null +++ b/specs/documentation/readme.md @@ -0,0 +1,143 @@ +# README Documentation + +## Goal of the document + +This document describes the expected content, structure, and tone of the project `README.md`. + +## Scope + +The scope of this specification is limited to the repository root `README.md`. + +The following topics are out of scope: + +- package publishing. +- generated command help. +- detailed CLI behavior. +- detailed configuration syntax. +- detailed workflow artifact syntax. +- development environment rules for agents. +- documentation files other than the root `README.md`. + +## Audience + +`README.md` MUST be written primarily for humans who are discovering the project. + +`README.md` MAY mention coding agents because Donna is designed for agent workflows, but it MUST NOT duplicate the full built-in agent skill documentation. + +`README.md` SHOULD help readers quickly understand: + +- what problem Donna solves. +- what Donna does. +- how to try the main commands. +- where to find detailed usage, configuration, and workflow documentation. +- how to work on the project. + +## Source Material + +`README.md` MUST reflect the current behavior and architecture described in `./specs/`. + +`README.md` MAY use project metadata from `pyproject.toml` for stable package identity, repository links, and installation context. + +Existing `README.md` prose MUST NOT be treated as a source of truth when it conflicts with `./specs/` or package metadata. + +## Structure + +`README.md` MUST start with a single h1 heading containing the project name. + +The first paragraphs after the h1 heading SHOULD explain the core value proposition in plain language. + +`README.md` MUST use the following h2 sections in this order: + +1. `Features` +2. `Example` +3. `Installation` +4. `Configuration` +5. `Quick Usage` +6. `Workflow Files` +7. `Specifications` +8. `Development` + +The `Features` section MUST be the first h2 section after the introductory paragraphs. + +The `Features` section MUST use compact bullet items with bold feature names followed by short descriptions. + + +Additional h2 sections MUST NOT be added without updating this specification first. Lower-level subsections MAY be added under the required h2 sections when they help human readers. + +`README.md` SHOULD avoid deep implementation details. + +## Content Requirements + +`README.md` MUST mention that Donna is a CLI tool. + +`README.md` MUST explain that Donna helps agents run predefined workflows in a deterministic way. + +`README.md` MUST explain that Donna interprets workflows as state-machine-like graphs of operations. + +`README.md` MUST explain that Donna maintains project-local session state, discovers workflow artifacts, emits action requests for agents, and accepts agent reports about the next operation to run. + +`README.md` MUST explain that a Donna project is configured by `donna.toml`. + +`README.md` MUST explain that workflow artifacts are Markdown files ending with `.donna.md`. + +`README.md` MUST link to the configuration behavior specification when describing configuration. + +`README.md` MUST link to the CLI behavior specification when describing command behavior. + +`README.md` MUST link to the skill fixture specification or mention built-in skill documentation when describing detailed user documentation. + +`README.md` MUST mention that this repository uses Donna and that its `donna.toml` and `./workflows` directory can be used as real project examples. + +`README.md` MUST explain how to create a starter configuration with `donna init`. + +`README.md` MUST include installation examples for installing Donna from PyPI with `uv` and `pip`. + +`README.md` MUST explain that the generated starter configuration should be reviewed and edited for the project. + +`README.md` MUST mention that a user can ask a coding agent to help create or adapt workflow files. + +`README.md` MUST include a short `AGENTS.md` instruction snippet that tells agents to use Donna only when instructed by a developer, project instructions, or Donna itself. + +`README.md` MUST mention the primary workflows: + +- initializing a Donna project. +- listing discovered workflow artifacts. +- starting a new session. +- starting a workflow. +- inspecting the current session. +- continuing queued workflow execution. +- completing an action request. +- reading built-in skill-style documentation. + +`README.md` MUST include command examples for: + +- `donna init`. +- `donna list`. +- `donna validate`. +- `donna render ...`. +- `donna run ...`. +- `donna continue`. +- `donna complete-action-request ...`. +- `donna skill usage`. + +Human-facing command examples SHOULD use command defaults. + +`README.md` MUST mention that `--protocol llm` is intended for coding agents and that `human` is the default protocol for terminal users. + +`README.md` MUST point readers to `./specs/` as the source of project behavior and architecture. + +`README.md` MUST describe development commands through `./bin/dev.sh` and `./bin/dev-tests.sh`. + +`README.md` MUST state that development commands are run through Docker-backed project scripts. + +## Style + +`README.md` SHOULD be concise and practical. + +`README.md` SHOULD prefer short explanations, bullet lists, and command examples. + +`README.md` SHOULD use a confident but accurate tone. + +`README.md` SHOULD avoid promising behavior that is only planned or not implemented. + +`README.md` SHOULD not be an exhaustive user manual; detailed command, configuration, and workflow guidance belongs in built-in skill documentation and specifications. diff --git a/specs/intro.md b/specs/intro.md index 2030c729..95fb48d6 100644 --- a/specs/intro.md +++ b/specs/intro.md @@ -1,58 +1,36 @@ +# List of specifications -# Introduction to the Donna development -This document provides an introduction to the Donna project for agents and developers who want to understand how to work with the Donna codebase. +## Goal of the document -## Project overview +This document lists all specification documents and specification directories in the project and briefly describes their purpose. -`Donna` is a CLI tool that helps manage the work of AI agents like Codex. +## Scope -It is designed to invert control flow: instead of agents deciding what to do next, the `donna` tells agents what to do next by following predefined workflows. +The scope of this specification is limited to the specification index. -The core idea is that most high-level workflows are more algorithmic than it may seem at first glance. For example, it may be difficult to fix a particular type issue in the codebase, but the overall process of polishing the codebase is quite linear: +Detailed requirements for individual specifications are out of scope except for brief descriptions needed to keep the index useful. -1. Ensure all tests pass. -2. Ensure the code is formatted correctly. -3. Ensure there are no linting errors. -4. Go to the step 1 if you changed something in the process. -5. Finish. +## Specification directories -We may need coding agents on each step of the process, but there is no reason for agents to manage the whole grooming loop by themselves — it takes longer time, spends tokens and may lead to confusion of agents. +- `./specs/` — directory with all specifications. +- `./specs/architecture/` — specifications related to the architecture of the system. +- `./specs/behavior/` — specifications related to the behavior of the system. +- `./specs/documentation/` — specifications related to project documentation artifacts. +- `./specs/meta/` — specifications related to requirements for specification documents. -## Dictionary +## Specification documents -- **Action request** — an instruction to the agent (who runs Donna) to perform the specified operations. Action requests are created by operations, like `donna.lib.request_action`. After finishing following the instructions of an action request, the agent MUST report back to Donna specifying the next operation to continue with. The list of next operations is specified in the action request itself. -- **Artifact** — any text or binary document managed by Donna in the project filesystem; text artifacts are typically Markdown templates with metadata and are the primary units of knowledge and instructions. -- **Artifact Section** — a part of a text artifact separated by markdown headers, has its own configuration block and semantics depending on section kind. -- **Configuration block** — a fenced code block with the `donna` keyword (preferably TOML) that configures an artifact or its section. -- **Directive** — a Jinja2 helper like `donna.lib.goto(...)` that adds meta information or special behavior to an artifact. -- **Environment error** — a structured, user-facing error describing problem in the environment Donna operates in (e.g., missing artifact, invalid config). These errors are expected to be handled by agents or users. -- **Head section** — the H1 section of a markdown artifact (before the first H2) that contains the primary description and mandatory config block. -- **Internal error** — an error caused by a bug or unexpected state in Donna itself. These errors are not expected to be handled by agents or users. -- **Protocol** — the output/interaction mode for Donna (e.g., `llm`) that governs CLI behavior and rendering. -- **Session** — the active unit of work tracked by Donna; its state and artifacts live under `/.session/donna`. -- **Specification** — a normal Markdown document that describes behavior, rules, or project guidance. -- **Story** — a semantically consistent scope of work within a session; a conceptual unit not directly represented in the tool. -- **Tail section** — each H2 section of an artifact. -- **Workspace** — the Donna project rooted at the directory containing `donna.toml`; runtime state lives under the configured session directory. -- **Workflow** — a `donna.lib.workflow` artifact that encodes a finite-state machine of operations guiding the agent's work. -- **Workflow operation** — a single step in a workflow, defined by a tail section with an `id`, `kind`, and instructions. - -## Points of interest - -- `./donna/` — a directory containing source code of project — `donna` CLI tool. -- `./specs/` — a directory containing project-specific documentation. -- `./workflows/` — a directory containing project-specific Donna workflows used to manage the work of AI agents on this project. -- `./.session/donna/` — the configured temporary session directory used by Donna for runtime state and session artifacts. - -## Documentation of interest - -Since this is the repository that contains the Donna project itself, you MUST pay additional attention to which project-scoped artifact ids you are viewing. - -- `@/.agents/donna/**` contains local Donna documentation for this repository. -- `@/specs/**` contains project-specific documentation for developing the Donna codebase. You access it when you need to understand how to introduce changes to this repository. -- `@/workflows/**` contains project-specific workflows for developing the Donna codebase. You change them when you change the development processes of the Donna project as a software project. - -Check the next documents: - -- Read the `@/specs/core/top_level_architecture.md` file when you need to introduce any changes in Donna or to research its code. -- Read the `@/specs/core/error_handling.md` file when you need to implement any new feature in Donna that may produce, process or propagate errors. +- `./specs/intro.md` — this file, contains a list of all specifications and their brief descriptions. +- `./specs/dictionary.md` — shared project-specific terminology used by multiple specifications. +- `./specs/architecture/entities.md` — specification of project entity and data structure architecture. +- `./specs/architecture/errors.md` — specification of project error handling architecture. +- `./specs/architecture/modules_layout.md` — specification of the intended project module structure and ownership boundaries. +- `./specs/architecture/naming.md` — specification of project code naming conventions. +- `./specs/architecture/tests.md` — specification of project test organization and testing expectations. +- `./specs/behavior/cli.md` — specification of the `donna` command line interface. +- `./specs/behavior/config.md` — specification of the `donna.toml` configuration file behavior. +- `./specs/behavior/file_paths.md` — specification of Donna local project path, artifact id, and artifact section id syntax and resolution behavior. +- `./specs/behavior/skill_fixtures.md` — specification of built-in skill documentation fixture behavior. +- `./specs/documentation/changelog.md` — specification of changelog tooling, source files, version record structure, and entry format. +- `./specs/documentation/readme.md` — specification of the root `README.md` content, structure, source material, and tone. +- `./specs/meta/general.md` — general requirements for specification documents. diff --git a/specs/meta/general.md b/specs/meta/general.md new file mode 100644 index 00000000..ff57d783 --- /dev/null +++ b/specs/meta/general.md @@ -0,0 +1,109 @@ +# General specification requirements + +## Goal of the document + +This document describes the general requirements for specifications in this project. + +## Scope + +The scope of this specification is limited to requirements for specification documents in this project. + +The following topics are out of scope except when they affect how specification documents should be written: + +- project behavior. +- implementation requirements. +- product requirements. + +## Dictionary + +- `specification` — a Markdown document in `./specs/` that describes requirements, behavior, terminology, or documentation rules for the project. +- `top-level section` — a section introduced by an `h2` Markdown header. +- `nested section` — a section introduced by an `h3` or deeper Markdown header. + +## Sections + +A specification MUST contain a single `h1` header with the name of the specification, which SHOULD be unique across all specifications. + +Top-level information SHOULD be organized in sections with `h2` headers. + +Nested sections MAY use `h3`, `h4`, and deeper headers when they make the document easier to navigate. + +Nested sections SHOULD be used for details that belong to a parent top-level section, such as: + +- examples. +- option descriptions. +- record fields. +- subsections of a larger topic. + +Sections that are mandatory for all specifications: + +- `Goal of the document` — a brief description of what the specification is about and what it aims to achieve. +- `Scope` — a brief description of what the specification covers and what it intentionally does not cover. + +Optional sections: + +- `Dictionary` — a list of terms that are specific to the specification. + +The first sections of a specification SHOULD be placed in this order: + +1. `Goal of the document` +2. `Scope` +3. `Dictionary`, when the section exists + +The `Goal of the document` section MUST describe the content and purpose of the document. + +The `Goal of the document` section MUST NOT define requirements for the document itself, such as saying that the document: + +- MUST define something. +- MUST list something. +- MUST describe something. + +The `Scope` section MUST describe the boundaries of the specification. It SHOULD be descriptive rather than normative when it explains what the document covers. It SHOULD explicitly mention important topics that are out of scope when those boundaries are useful for readers or future authors. It MUST NOT explain where to find requirements that belong to other specifications. + +The `Dictionary` section SHOULD be placed immediately after the `Scope` section. It SHOULD contain only terms that are specific to the specification. Terms that are used by multiple specifications SHOULD be defined in `./specs/dictionary.md`. + +## Style + +- Specifications MUST use Markdown syntax for formatting the document. +- Specifications MUST follow [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119). +- Specifications MUST NOT break long lines to fit within 80 characters or any other number; they MUST use as many characters as needed to express the idea clearly. +- Long enumerations SHOULD be organized as Markdown lists when possible. + +## Abstraction level + +Specifications MUST describe project behavior, architecture, constraints, terminology, and compatibility contracts at the highest level that is still precise enough to guide implementation. + +Specifications SHOULD define: + +- externally visible behavior and data contracts. +- stable architectural boundaries and ownership responsibilities. +- constraints that must hold across implementations. +- technology choices when they are part of the intended architecture. +- examples that clarify the requirement being specified. + +Specifications MUST NOT define incidental implementation details. + +Incidental implementation details include: + +- private helper function names. +- exact class names that are not part of a stable project convention or public boundary. +- exact file paths for code that is not owned by a module-layout or ownership requirement. +- local constructor signatures. +- temporary implementation strategies. +- repeated examples that restate ownership already defined elsewhere without adding a new constraint. + +Specifications MAY name concrete files, modules, symbols, commands, or formats when the name itself is a stable contract. + +Stable contracts include: + +- public CLI commands, options, arguments, and output records. +- configuration file names, fields, and values. +- module ownership boundaries defined by the module-layout specification. +- naming conventions that all implementations are expected to follow. +- concrete dependencies or language features that are intentional architectural choices. + +When a requirement can be expressed either as an implementation detail or as a general architectural rule, the specification MUST prefer the general rule. + +For example, a specification SHOULD require closed sets of named values to use enums instead of raw strings. It SHOULD NOT require a specific enum class name or file location unless that class name or location is itself a stable architectural boundary. + +Examples in specifications SHOULD illustrate behavior or ownership. Examples SHOULD NOT be treated as a place to enumerate every current implementation file or symbol. diff --git a/tach.toml b/tach.toml new file mode 100644 index 00000000..d2338249 --- /dev/null +++ b/tach.toml @@ -0,0 +1,159 @@ +source_roots = ["."] +root_module = "ignore" +ignore_type_checking_imports = true +forbid_circular_dependencies = true +layers_explicit_depends_on = true + +exclude = [ + ".git/", + ".mypy_cache/", + ".pytest_cache/", + ".session/", + ".venv/", + ".venv-donna/", + "build/", + "dist/", + "**/__pycache__/", +] + +layers = [ + "meta-tests", + "cli", + "runtime", + "lib", + "primitives", + "context", + "workspace", + "machine", + "protocol", + "domain", + "support", + "core", +] + +[[modules]] +path = "donna" +depends_on = [] +visibility = [] + +[[modules]] +path = "donna.**.tests" +depends_on = [ + "donna.**", + "donna.**.tests", +] +layer = "meta-tests" + +[[modules]] +path = "donna.cli" +depends_on = [ + "donna.context", + "donna.core", + "donna.domain", + "donna.lib", + "donna.machine", + "donna.primitives", + "donna.protocol", + "donna.runtime", + "donna.skills", + "donna.workspaces", +] +layer = "cli" + +[[modules]] +path = "donna.runtime" +depends_on = [ + "donna.context", + "donna.core", + "donna.domain", + "donna.machine", + "donna.protocol", + "donna.workspaces", +] +layer = "runtime" + +[[modules]] +path = "donna.lib" +depends_on = [ + "donna.primitives", +] +layer = "lib" + +[[modules]] +path = "donna.primitives" +depends_on = [ + "donna.context", + "donna.core", + "donna.domain", + "donna.machine", + "donna.protocol", + "donna.workspaces", +] +layer = "primitives" + +[[modules]] +path = "donna.context" +depends_on = [ + "donna.core", + "donna.domain", + "donna.machine", + "donna.protocol", + "donna.workspaces", +] +layer = "context" + +[[modules]] +path = "donna.workspaces" +depends_on = [ + "donna.core", + "donna.domain", + "donna.machine", + "donna.protocol", +] +layer = "workspace" + +[[modules]] +path = "donna.protocol" +depends_on = [ + "donna.core", + "donna.domain", +] +layer = "protocol" + +[[modules]] +path = "donna.machine" +depends_on = [ + "donna.core", + "donna.domain", + "donna.protocol", +] +layer = "machine" + +[[modules]] +path = "donna.domain" +depends_on = [ + "donna.core", +] +layer = "domain" + +[[modules]] +path = "donna.skills" +depends_on = [] +layer = "support" + +[[modules]] +path = "donna.core" +depends_on = [] +layer = "core" + +[external] +exclude = [ + "pytest", + "pytest_mock", + "tach", + "typer", +] + +[rules] +unused_ignore_directives = "warn" +require_ignore_directive_reasons = "off" diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..5b87cb34 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1165 @@ +version = 1 +revision = 3 +requires-python = ">=3.12, <4.0" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "autoflake" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/0b/70c277eef225133763bf05c02c88df182e57d5c5c0730d3998958096a82e/autoflake-2.3.3.tar.gz", hash = "sha256:c24809541e23999f7a7b0d2faadf15deb0bc04cdde49728a2fd943a0c8055504", size = 16515, upload-time = "2026-02-20T05:01:43.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/21/26f1680ec3a598ea31768f9ebcd427e42986d077a005416094b580635532/autoflake-2.3.3-py3-none-any.whl", hash = "sha256:a51a3412aff16135ee5b3ec25922459fef10c1f23ce6d6c4977188df859e8b53", size = 17715, upload-time = "2026-02-20T05:01:42.137Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + +[[package]] +name = "black" +version = "25.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" }, + { url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/08/2c64830cb6616278067e040acca21d4f79727b23077633953081c9445d61/black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", size = 1426197, upload-time = "2025-12-08T01:45:51.198Z" }, + { url = "https://files.pythonhosted.org/packages/d4/60/a93f55fd9b9816b7432cf6842f0e3000fdd5b7869492a04b9011a133ee37/black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", size = 1237266, upload-time = "2025-12-08T01:45:10.556Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" }, + { url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" }, + { url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" }, + { url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" }, + { url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" }, + { url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" }, +] + +[[package]] +name = "changy" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/ed/92e13e901b46547e314befcce921ad4471fa6dbaf4bdbcadf2afd3285e26/changy-0.4.3.tar.gz", hash = "sha256:1a38ce222243f3bbf4f51a890bc14e393521954970244dc7d669e2bcbe523f85", size = 7272, upload-time = "2024-12-10T09:45:43.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/67/2bfe58c28eb5f49af43982da78aa63296fbfff39291fe6d65fda88dd835f/changy-0.4.3-py3-none-any.whl", hash = "sha256:53d34778a0b525c154fb0c39ef02fb41c2179ef2915f7f63cfeaa0a241d0facc", size = 8948, upload-time = "2024-12-10T09:45:40.611Z" }, +] + +[[package]] +name = "click" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, +] + +[[package]] +name = "codespell" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" }, +] + +[[package]] +name = "cognitive-complexity" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/ff/3cd46792fcbf742458083527407bc336efe382b168595583a06c70bf8e54/cognitive_complexity-1.3.0.tar.gz", hash = "sha256:a0cfbd47dee0b19f4056f892389f501694b205c3af69fb703cc744541e03dde5", size = 5650, upload-time = "2022-08-09T07:07:52.952Z" } + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "donna" +version = "0.3.0" +source = { editable = "." } +dependencies = [ + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdformat" }, + { name = "pydantic" }, + { name = "tomli-w" }, + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "autoflake" }, + { name = "black" }, + { name = "changy" }, + { name = "codespell" }, + { name = "flake8" }, + { name = "flake8-absolute-import" }, + { name = "flake8-annotations-complexity" }, + { name = "flake8-bandit" }, + { name = "flake8-cognitive-complexity" }, + { name = "flake8-docstrings" }, + { name = "flake8-eradicate" }, + { name = "flake8-functions" }, + { name = "flake8-print" }, + { name = "flake8-pyproject" }, + { name = "flake8-pytest" }, + { name = "isort" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "tach" }, + { name = "types-pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "jinja2", specifier = ">=3.1,<3.2" }, + { name = "markdown-it-py", specifier = ">=4.0,<4.1" }, + { name = "mdformat", specifier = ">=1.0.0,<1.1.0" }, + { name = "pydantic", specifier = ">=2.12,<2.13" }, + { name = "tomli-w", specifier = ">=1.2,<1.3" }, + { name = "typer", specifier = ">=0.20,<0.21" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "autoflake", specifier = "==2.3.*" }, + { name = "black", specifier = "==25.12.*" }, + { name = "changy", specifier = "==0.4.*" }, + { name = "codespell", specifier = "==2.4.*" }, + { name = "flake8", specifier = "==7.3.*" }, + { name = "flake8-absolute-import", specifier = "==1.0.*" }, + { name = "flake8-annotations-complexity", specifier = "==0.1.*" }, + { name = "flake8-bandit", specifier = "==4.1.*" }, + { name = "flake8-cognitive-complexity", specifier = "==0.1.*" }, + { name = "flake8-docstrings", specifier = "==1.7.*" }, + { name = "flake8-eradicate", specifier = "==1.5.*" }, + { name = "flake8-functions", specifier = "==0.0.*" }, + { name = "flake8-print", specifier = "==5.0.*" }, + { name = "flake8-pyproject", specifier = "==1.2.*" }, + { name = "flake8-pytest", specifier = "==1.4.*" }, + { name = "isort", specifier = "==7.0.*" }, + { name = "mypy", specifier = "==1.19.*" }, + { name = "pytest", specifier = "==9.0.*" }, + { name = "pytest-mock", specifier = "==3.15.*" }, + { name = "tach", specifier = "==0.34.*" }, + { name = "types-pyyaml", specifier = "==6.0.*" }, +] + +[[package]] +name = "eradicate" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/e1/665186aedea2d6ebf0415cf97c0629c8123a721e7afc417deeade5598215/eradicate-2.3.0.tar.gz", hash = "sha256:06df115be3b87d0fc1c483db22a2ebb12bcf40585722810d809cc770f5031c37", size = 8536, upload-time = "2023-06-09T06:31:41.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/c2/533e1338429aeba1f089566a2314d69d3e78ab57a73006f16a923bf2b24c/eradicate-2.3.0-py3-none-any.whl", hash = "sha256:2b29b3dd27171f209e4ddd8204b70c02f0682ae95eecb353f10e8d72b149c63e", size = 6113, upload-time = "2023-06-09T06:31:40.209Z" }, +] + +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "flake8-absolute-import" +version = "1.0.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flake8" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/b3/99967e98c60d82884886c6d0b607d80ccb4a474c69734c60f0533cf1f9d5/flake8_absolute_import-1.0.0.3.tar.gz", hash = "sha256:84b2bb2dbad98227a333ca4ba30357e073117ecd6b068b46ac5906fa9a7ed39e", size = 10900, upload-time = "2025-11-28T02:12:01.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/97/a2f279f6c749912ad7d254af9c8ec845736352f6d162fb0fc25000d26e22/flake8_absolute_import-1.0.0.3-py3-none-any.whl", hash = "sha256:de62300044f5060f32dbe5cbfa0ad7b345231323c572077997d9c8ae6241b189", size = 5905, upload-time = "2025-11-28T02:12:08.884Z" }, +] + +[[package]] +name = "flake8-annotations-complexity" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flake8" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/68/ab6f136df474fbbddad1bff3965b9857a89327d819a385c815549a884cd7/flake8_annotations_complexity-0.1.0.tar.gz", hash = "sha256:98b86ef87de5331d2b61f3cf472dcf6b8ff1a5ddde46f78bc894b464f06e1414", size = 5365, upload-time = "2025-03-04T10:33:02.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/8c/dcd25a2c5b727851c28e1875a04dadd8e22b31564502eea63d5977cd31a8/flake8_annotations_complexity-0.1.0-py3-none-any.whl", hash = "sha256:102d75f5ba0c667cde9c563062f4e7c616ca84bf5bfdc9c1f960a2655133ce35", size = 5539, upload-time = "2025-03-04T10:33:00.558Z" }, +] + +[[package]] +name = "flake8-bandit" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bandit" }, + { name = "flake8" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/1c/4f66a7a52a246d6c64312b5c40da3af3630cd60b27af81b137796af3c0bc/flake8_bandit-4.1.1.tar.gz", hash = "sha256:068e09287189cbfd7f986e92605adea2067630b75380c6b5733dab7d87f9a84e", size = 5403, upload-time = "2022-08-29T13:48:41.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/5f/55bab0ac89f9ad9f4c6e38087faa80c252daec4ccb7776b4dac216ca9e3f/flake8_bandit-4.1.1-py3-none-any.whl", hash = "sha256:4c8a53eb48f23d4ef1e59293657181a3c989d0077c9952717e98a0eace43e06d", size = 4828, upload-time = "2022-08-29T13:48:39.737Z" }, +] + +[[package]] +name = "flake8-cognitive-complexity" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cognitive-complexity" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/d6/2bb09fab21521424d5afc836aa0057d15a92f5e738e506a3e3cb035be517/flake8_cognitive_complexity-0.1.0.tar.gz", hash = "sha256:f202df054e4f6ff182b659c261922b9c684628a47beb19cb0973c50d6a7831c1", size = 3061, upload-time = "2020-08-01T05:49:18.353Z" } + +[[package]] +name = "flake8-docstrings" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flake8" }, + { name = "pydocstyle" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/24/f839e3a06e18f4643ccb81370909a497297909f15106e6af2fecdef46894/flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af", size = 5995, upload-time = "2023-01-25T14:27:13.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/7d/76a278fa43250441ed9300c344f889c7fb1817080c8fb8996b840bf421c2/flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75", size = 4994, upload-time = "2023-01-25T14:27:12.32Z" }, +] + +[[package]] +name = "flake8-eradicate" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "eradicate" }, + { name = "flake8" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/72/a3975dfa4287396e9fb8fc2b4ee94a80d0809babbf92abed5af9c8e29c95/flake8_eradicate-1.5.0.tar.gz", hash = "sha256:aee636cb9ecb5594a7cd92d67ad73eb69909e5cc7bd81710cf9d00970f3983a6", size = 4508, upload-time = "2023-05-31T09:57:15.484Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/a9/1319b9e5eeb7d948f6db0b0ed4209bae0ec12d30ab3ee43a0ac1d8ce455f/flake8_eradicate-1.5.0-py3-none-any.whl", hash = "sha256:18acc922ad7de623f5247c7d5595da068525ec5437dd53b22ec2259b96ce9d22", size = 5144, upload-time = "2023-05-31T09:57:13.589Z" }, +] + +[[package]] +name = "flake8-functions" +version = "0.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mr-proper" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/f4/af475b75382a6403a6a184f01ae42e8c9bdd97ee8e84b4c4d6660da0f4d9/flake8_functions-0.0.8.tar.gz", hash = "sha256:5446626673a9faecbf389fb411b90bdc87b002c387b72dc097b208e7a58f2a1c", size = 5496, upload-time = "2023-04-10T15:53:16.099Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/1a/2ff8d074e42ad864fe5378cff28ccf758d4b039167c5384480b703aea222/flake8_functions-0.0.8-py3-none-any.whl", hash = "sha256:e1a88aa634d1aff6973f8c9dd64f30ab2beaac661e52eea96929ccc7ee7f64df", size = 6946, upload-time = "2023-04-10T15:53:14.448Z" }, +] + +[[package]] +name = "flake8-print" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flake8" }, + { name = "pycodestyle" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/a6/770c5832a6b563e023def7d81925d1b9f3079ebc805e48be0a5ee206f716/flake8-print-5.0.0.tar.gz", hash = "sha256:76915a2a389cc1c0879636c219eb909c38501d3a43cc8dae542081c9ba48bdf9", size = 5166, upload-time = "2022-04-30T16:19:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/2c/aa2ffda404b5d9c89dad8bcc4e0f4af673ab2de67e96997d13f04ad68b5b/flake8_print-5.0.0-py3-none-any.whl", hash = "sha256:84a1a6ea10d7056b804221ac5e62b1cee1aefc897ce16f2e5c42d3046068f5d8", size = 5687, upload-time = "2022-04-30T16:19:24.307Z" }, +] + +[[package]] +name = "flake8-pyproject" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flake8" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/6a/cdee9ff7f2b7c6ddc219fd95b7c70c0a3d9f0367a506e9793eedfc72e337/flake8_pyproject-1.2.4-py3-none-any.whl", hash = "sha256:ea34c057f9a9329c76d98723bb2bb498cc6ba8ff9872c4d19932d48c91249a77", size = 5694, upload-time = "2025-11-28T21:40:01.309Z" }, +] + +[[package]] +name = "flake8-pytest" +version = "1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flake8" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/aa/dfb17bc4660b4ef199c1cd9211a77456ee6d93b8bf79e6b8c46108435147/flake8-pytest-1.4.tar.gz", hash = "sha256:19f543b2d1cc89d61b76f19d0a9e58e9a110a035175f701b3425c363a7732c56", size = 3434, upload-time = "2022-08-08T13:47:21.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/ca/1baaca42e1ff50bc0758277e4e58452d3fd5f193418070830df90a07a10e/flake8_pytest-1.4-py2.py3-none-any.whl", hash = "sha256:97328f258ffad9fe18babb3b0714a16b121505ad3ac87d4e33020874555d0784", size = 3702, upload-time = "2022-08-08T13:47:19.321Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mdformat" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", hash = "sha256:4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928", size = 56953, upload-time = "2025-10-16T12:05:03.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", hash = "sha256:bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b", size = 53288, upload-time = "2025-10-16T12:05:02.607Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mr-proper" +version = "0.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "setuptools" }, + { name = "stdlib-list" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/35/a50ba9e3097ee0d71232c996c626a17f80424140b365122c8f1bc9933118/mr_proper-0.0.7.tar.gz", hash = "sha256:03b517b19e617537f711ce418b125e5f2efd82ec881539cdee83195c78c14a02", size = 11028, upload-time = "2021-10-27T13:06:03.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/b0/2b19f1c38cc1ff29af21c1851c1160cfb185d4516834a37b7227d7df01b2/mr_proper-0.0.7-py3-none-any.whl", hash = "sha256:74a1b60240c46f10ba518707ef72811a01e5c270da0a78b5dd2dd923d99fdb14", size = 11537, upload-time = "2021-10-27T13:06:01.852Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +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 = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pydocstyle" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "snowballstemmer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/d5385ca59fd065e3c6a5fe19f9bc9d5ea7f2509fa8c9c22fb6b2031dd953/pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1", size = 36796, upload-time = "2023-01-17T20:29:19.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/ea/99ddefac41971acad68f14114f38261c1f27dac0b3ec529824ebc739bdaa/pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019", size = 38038, upload-time = "2023-01-17T20:29:18.094Z" }, +] + +[[package]] +name = "pydot" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "stdlib-list" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/25/f1540879c8815387980e56f973e54605bd924612399ace31487f7444171c/stdlib_list-0.12.0.tar.gz", hash = "sha256:517824f27ee89e591d8ae7c1dd9ff34f672eae50ee886ea31bb8816d77535675", size = 60923, upload-time = "2025-10-24T19:21:22.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/3d/2970b27a11ae17fb2d353e7a179763a2fe6f37d6d2a9f4d40104a2f132e9/stdlib_list-0.12.0-py3-none-any.whl", hash = "sha256:df2d11e97f53812a1756fb5510393a11e3b389ebd9239dc831c7f349957f62f2", size = 87615, upload-time = "2025-10-24T19:21:20.619Z" }, +] + +[[package]] +name = "stevedore" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/88/35e4d27d9177d7df76d060e0a18f69c6c5794c96960c94042e20a12c8ba2/stevedore-5.8.0.tar.gz", hash = "sha256:b49867b32ca3016e94100e68dbf26e72aa7b8708d0a3f73c08aeb220370ac715", size = 514710, upload-time = "2026-05-18T09:15:27.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/ac/19f9941c74add59d17694930ec8105d5eddeee4ce56dd8632b765ca16d6c/stevedore-5.8.0-py3-none-any.whl", hash = "sha256:88eede9e66ca80e34085b9174e2327da2c61ac91f24f70e41c3ad76e4bb4872b", size = 54553, upload-time = "2026-05-18T09:15:25.82Z" }, +] + +[[package]] +name = "tach" +version = "0.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitpython" }, + { name = "networkx" }, + { name = "prompt-toolkit" }, + { name = "pydot" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/5b/a82de1482ede35e245951a6e039d608cdc86111aa8207344fffb8c927623/tach-0.34.1.tar.gz", hash = "sha256:58b5a8f9dd4f5c9fc9b1ade875aa0b31d3ac2f2f6802c655c05197a503d5acde", size = 765955, upload-time = "2026-04-03T06:12:28.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/3f/d071b841bf634da94068b84d7a2098c99c18deb21b3418969f29e152d793/tach-0.34.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6dff869e7f8933be23055f407a751e6f9e4c8ecb0bf3a93a962a0815e106c313", size = 4100880, upload-time = "2026-04-03T06:12:20.276Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/aeb7f67c34b5a9ee43d1aa556b16c6e60424cafeb3f23e6fbf3298a968cd/tach-0.34.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:70d55ec38dfcc15f1fff9e420ba17d63c36bb1b27f92abb8e8c910ffb71fd233", size = 3969195, upload-time = "2026-04-03T06:12:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/12e565a0374436ba2b9c00ba68c3a9bf9999081ac546faf33da77162316e/tach-0.34.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:862d240a7fc297283a51c857eb269d8c56163f14b7e398f757b284bed1e3df4f", size = 4335854, upload-time = "2026-04-03T06:12:11.065Z" }, + { url = "https://files.pythonhosted.org/packages/24/f7/3c75beb144017a22229808b497bfb22cd79acc11d836c9870dc8708185a7/tach-0.34.1-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcb8ca825287160c1dd37b1cb164b88675e01ee97304ce14d50d7e8e1d3b853c", size = 4237244, upload-time = "2026-04-03T06:12:14.931Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/3459ce8c55a0aa4fa4533faf66cbbfef37eb5927aab1b87f8439c6db228b/tach-0.34.1-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b272ee726d8b0ef3887d3a3057f618922ab0f7471e1b1d6096f78f2ae93727e4", size = 4648719, upload-time = "2026-04-03T06:12:33.559Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d3/75b46da7c62a929ddde6c9eb4bb54f775693ee5a006f1b9a069a9aa2f5f2/tach-0.34.1-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6042b4f040c5941d28229963451b94a8cf2b08cdbb0ab0e338a04a46887c712f", size = 5203497, upload-time = "2026-04-03T06:12:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/12/f7/838d3c2bc0972626eddb3f69d13fdf3eaa7cc46c6132ad965fe7839fe452/tach-0.34.1-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b52a70262f16b701aaa03781656d808f88a3eb6a263762f8cea726ea9ffb980", size = 4349388, upload-time = "2026-04-03T06:12:26.288Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a0/25ac1fb225aa07faacb29c2470dc49633583fed11214c9e128eae65dece9/tach-0.34.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1cba2d957371fde09c64cb1b70349844e33fae3d87f55e6b002929587c5c7826", size = 4593579, upload-time = "2026-04-03T06:12:39.73Z" }, + { url = "https://files.pythonhosted.org/packages/8c/54/29ffea4faf1c0c623bc2e97e8affc658fbaf38fcf8babf758fa63864dc1b/tach-0.34.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c600ea6f8748ea13183aaf802fb733c148238e252cc0e66a85ac8d6de9939906", size = 4513785, upload-time = "2026-04-03T06:12:13.145Z" }, + { url = "https://files.pythonhosted.org/packages/66/5e/c716a12ed58a7cc23b27f61f52e31e60410914b2a4afdb0b1a88d16008a7/tach-0.34.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:7be15f69c99b89cdadc2835368fc3cfb3ad45e74c7a704a7d42bcdd2917608b2", size = 4512695, upload-time = "2026-04-03T06:12:16.831Z" }, + { url = "https://files.pythonhosted.org/packages/44/f6/8538205bba54d77d8b5e3f4afe8203d705bc430578f59bf4d108b3b6da47/tach-0.34.1-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:d917fc81adae85e9b3f2d29e135ba572a69dc677661727012b3adf95e0216e9e", size = 4649718, upload-time = "2026-04-03T06:12:37.908Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/340e9ba6821d4e97f1bfeea5ad36a07c888f3422bb68f4805c74c1dc576c/tach-0.34.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f94909630d699806e8682662962de0ac76ecc05dfcf7893af2c7e1a5c71de57e", size = 5333192, upload-time = "2026-04-03T06:12:24.289Z" }, + { url = "https://files.pythonhosted.org/packages/04/c2/d0e46185745af3a3d4057fd73eeac9ec003f054d5591c54e2fd835f4cbe4/tach-0.34.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1246b2718cfffecd4e6d66d6e232323fc4b3f2c68b4c990af0063464808f85fa", size = 4641868, upload-time = "2026-04-03T06:12:44.163Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d5/0209e74d57b0d32057b6ea997b77db847e38d649bd27e6261a4bd58e1da5/tach-0.34.1-cp37-abi3-win32.whl", hash = "sha256:abd9d1e468a9b8bec1e0037c06797b31e9c23c62adf13bcfacf057a21310ad3f", size = 3431420, upload-time = "2026-04-03T06:12:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6c/51f1d58eb3ebd1f79d2944f4813806c18c9c715178956424a4528e8bd281/tach-0.34.1-cp37-abi3-win_amd64.whl", hash = "sha256:0220c002da4bf4656b121c6317313490e8660627e5200251e0bca3e00a27d0c4", size = 3737055, upload-time = "2026-04-03T06:12:29.714Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "typer" +version = "0.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/c1/933d30fd7a123ed981e2a1eedafceab63cb379db0402e438a13bc51bbb15/typer-0.20.1.tar.gz", hash = "sha256:68585eb1b01203689c4199bc440d6be616f0851e9f0eb41e4a778845c5a0fd5b", size = 105968, upload-time = "2025-12-19T16:48:56.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/52/1f2df7e7d1be3d65ddc2936d820d4a3d9777a54f4204f5ca46b8513eff77/typer-0.20.1-py3-none-any.whl", hash = "sha256:4b3bde918a67c8e03d861aa02deca90a95bbac572e71b1b9be56ff49affdb5a8", size = 47381, upload-time = "2025-12-19T16:48:53.679Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] diff --git a/workflows/examples/time_to_drink_tea.donna.md b/workflows/examples/time_to_drink_tea.donna.md new file mode 100644 index 00000000..cad53bf2 --- /dev/null +++ b/workflows/examples/time_to_drink_tea.donna.md @@ -0,0 +1,55 @@ +# Is it time to drink tea? + +This workflow checks the current time, asks the agent whether it is tea time, +and branches on the answer. + +## Get Current Time + +```toml donna +id = "get_current_time" +kind = "donna.lib.run_script" +save_stdout_to = "current_time" +goto_on_success = "ask_about_tea" +goto_on_failure = "finish" +``` + +```bash donna script +#!/usr/bin/env bash +date +%H:%M +``` + +## Ask About Tea + +```toml donna +id = "ask_about_tea" +kind = "donna.lib.request_action" +``` + +The current time is: + +```text +{{ donna.lib.task_variable("current_time") }} +``` + +Is it time to drink tea? + +1. If yes, `{{ donna.lib.goto("turn_on_kettle") }}`. +1. If no, `{{ donna.lib.goto("finish") }}`. + +## Turn On Kettle + +```toml donna +id = "turn_on_kettle" +kind = "donna.lib.request_action" +``` + +Turn on the kettle, then `{{ donna.lib.goto("finish") }}`. + +## Finish + +```toml donna +id = "finish" +kind = "donna.lib.finish" +``` + +The workflow is complete. Report the result to the developer. diff --git a/workflows/polish.donna.md b/workflows/polish.donna.md index 69929b1f..58aebe80 100644 --- a/workflows/polish.donna.md +++ b/workflows/polish.donna.md @@ -2,12 +2,44 @@ Initiate operations to polish and refine the donna codebase: running & fixing tests, formatting code, fixing type annotations, etc. This workflow MUST NOT be used to introduce new logic into the project or refactor it — only to fix existing issues. +## Run Tach + +```toml donna +id = "run_tach_script" +kind = "donna.lib.run_script" +fsm_mode = "start" +save_stdout_to = "tach_output" +goto_on_success = "run_autoflake_script" +goto_on_failure = "fix_tach" +``` + +```bash donna script +#!/usr/bin/env bash + +./bin/dev.sh uv run tach check 2>&1 +``` + +## Fix Tach Issues + +```toml donna +id = "fix_tach" +kind = "donna.lib.request_action" +``` + +``` +{{ donna.lib.task_variable("tach_output") }} +``` + +1. Fix the tach issues based on the output above that you are allowed to fix. +2. Ask the developer to fix any remaining issues manually. +3. Ensure your changes are saved. +4. `{{ donna.lib.goto("run_tach_script") }}` + ## Run Autoflake ```toml donna id = "run_autoflake_script" kind = "donna.lib.run_script" -fsm_mode = "start" save_stdout_to = "autoflake_output" goto_on_success = "run_isort_script" goto_on_failure = "fix_autoflake" @@ -16,7 +48,7 @@ goto_on_failure = "fix_autoflake" ```bash donna script #!/usr/bin/env bash -autoflake ./donna +./bin/dev.sh uv run autoflake ./donna ``` ## Fix Autoflake Issues @@ -32,7 +64,7 @@ kind = "donna.lib.request_action" 1. Fix the autoflake issues based on the output above. 2. Ensure your changes are saved. -3. `{{ donna.lib.goto("run_autoflake_script") }}` +3. `{{ donna.lib.goto("run_tach_script") }}` ## Run isort @@ -47,7 +79,7 @@ goto_on_failure = "fix_isort" ```bash donna script #!/usr/bin/env bash -isort ./donna +./bin/dev.sh uv run isort ./donna ``` ## Fix isort Issues @@ -63,7 +95,7 @@ kind = "donna.lib.request_action" 1. Fix the isort issues based on the output above. 2. Ensure your changes are saved. -3. `{{ donna.lib.goto("run_autoflake_script") }}` +3. `{{ donna.lib.goto("run_tach_script") }}` ## Run Black @@ -78,7 +110,7 @@ goto_on_failure = "fix_black" ```bash donna script #!/usr/bin/env bash -black ./donna +./bin/dev.sh uv run black ./donna ``` ## Fix Black Issues @@ -94,7 +126,7 @@ kind = "donna.lib.request_action" 1. Fix the Black issues based on the output above. 2. Ensure your changes are saved. -3. `{{ donna.lib.goto("run_autoflake_script") }}` +3. `{{ donna.lib.goto("run_tach_script") }}` ## Run Codespell @@ -109,7 +141,7 @@ goto_on_failure = "fix_codespell" ```bash donna script #!/usr/bin/env bash -codespell ./donna 2>&1 +./bin/dev.sh uv run codespell ./donna 2>&1 ``` ## Fix Codespell Issues @@ -125,7 +157,7 @@ kind = "donna.lib.request_action" 1. Fix the codespell issues based on the output above. 2. Ensure your changes are saved. -3. `{{ donna.lib.goto("run_autoflake_script") }}` +3. `{{ donna.lib.goto("run_tach_script") }}` ## Run Flake8 @@ -140,7 +172,7 @@ goto_on_failure = "fix_flake8" ```bash donna script #!/usr/bin/env bash -flake8 ./donna 2>&1 +./bin/dev.sh uv run flake8 ./donna 2>&1 ``` ## Fix Flake8 Issues @@ -156,7 +188,7 @@ kind = "donna.lib.request_action" 1. Fix the flake8 issues based on the output above. 2. Ensure your changes are saved. -3. `{{ donna.lib.goto("run_autoflake_script") }}` +3. `{{ donna.lib.goto("run_tach_script") }}` Instructions on fixing special cases: @@ -171,14 +203,14 @@ Instructions on fixing special cases: id = "run_mypy_script" kind = "donna.lib.run_script" save_stdout_to = "mypy_output" -goto_on_success = "finish" +goto_on_success = "run_tests_script" goto_on_failure = "fix_mypy" ``` ```bash donna script #!/usr/bin/env bash -mypy ./donna +./bin/dev.sh uv run mypy ./donna ``` ## Fix Mypy Issues @@ -195,7 +227,7 @@ kind = "donna.lib.request_action" 1. Fix the mypy issues based on the output above that you are allowed to fix. 2. Ask the developer to fix any remaining issues manually. 3. Ensure your changes are saved. -4. `{{ donna.lib.goto("run_autoflake_script") }}` +4. `{{ donna.lib.goto("run_tach_script") }}` Issues you are allowed to fix: @@ -215,6 +247,38 @@ Changes you are not allowed to make: - Adding `type: ignore[import-untyped]`. If you need to use it, ask the developer to install the missing types first or to fix the issue manually. - Adding or removing attributes to classes. If you need to do it, ask the developer to fix the problem manually. +## Run Tests + +```toml donna +id = "run_tests_script" +kind = "donna.lib.run_script" +save_stdout_to = "tests_output" +goto_on_success = "finish" +goto_on_failure = "fix_tests" +``` + +```bash donna script +#!/usr/bin/env bash + +./bin/dev.sh uv run pytest donna -o cache_dir=/tmp/donna-pytest-cache 2>&1 +``` + +## Fix Test Issues + +```toml donna +id = "fix_tests" +kind = "donna.lib.request_action" +``` + +``` +{{ donna.lib.task_variable("tests_output") }} +``` + +1. Fix the test failures based on the output above that you are allowed to fix. +2. Ask the developer to fix any remaining failures manually. +3. Ensure your changes are saved. +4. `{{ donna.lib.goto("run_tach_script") }}` + ## Finish ```toml donna diff --git a/workflows/rfc/design.donna.md b/workflows/rfc/design.donna.md index 40370f0b..55ef9996 100644 --- a/workflows/rfc/design.donna.md +++ b/workflows/rfc/design.donna.md @@ -11,7 +11,7 @@ fsm_mode = "start" ``` 1. Read the `workflows/rfc/specs/design.md` file if you haven't done it yet. -2. Read the artifact instructions by running `donna skill artifacts` if you haven't done it yet. +2. Read the workflow instructions by running `donna skill workflows` if you haven't done it yet. 3. `{{ donna.lib.goto("ensure_rfc_artifact_exists") }}` ## Ensure RFC artifact exists diff --git a/workflows/rfc/plan.donna.md b/workflows/rfc/plan.donna.md index d5eb3e39..59fbfae4 100644 --- a/workflows/rfc/plan.donna.md +++ b/workflows/rfc/plan.donna.md @@ -13,7 +13,7 @@ fsm_mode = "start" 1. Read the Design document that the developer or parent workflow wants you to implement. 2. Read the RFC document that the developer or parent workflow wants you to implement, if it exists. -3. Read the artifact instructions by running `donna skill artifacts` if you haven't done it yet. +3. Read the workflow instructions by running `donna skill workflows` if you haven't done it yet. 4. `{{ donna.lib.goto("prepare_workflow_artifact") }}` ## Prepare workflow artifact diff --git a/workflows/rfc/request.donna.md b/workflows/rfc/request.donna.md index 26409b8f..8c1caba0 100644 --- a/workflows/rfc/request.donna.md +++ b/workflows/rfc/request.donna.md @@ -12,7 +12,7 @@ fsm_mode = "start" ``` 1. Read the `workflows/rfc/specs/request_for_change.md` file if you haven't done it yet. -2. Read the artifact instructions by running `donna skill artifacts` if you haven't done it yet. +2. Read the workflow instructions by running `donna skill workflows` if you haven't done it yet. 3. `{{ donna.lib.goto("ensure_work_description_exists") }}` ## Ensure work description exists diff --git a/workflows/rfc/specs/design.md b/workflows/rfc/specs/design.md index 56ba5d97..4075b415 100644 --- a/workflows/rfc/specs/design.md +++ b/workflows/rfc/specs/design.md @@ -36,7 +36,7 @@ The RFC document is a Markdown file with the next structure: ## General language and format - You MUST follow [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119.txt) for keywords like MUST, SHOULD, MAY, etc. -- You MUST follow `donna skill artifacts`. +- You MUST follow `donna skill workflows`. - You MUST follow the structure specified in this document. ### List format diff --git a/workflows/rfc/specs/request_for_change.md b/workflows/rfc/specs/request_for_change.md index f5f93355..4e34fac5 100644 --- a/workflows/rfc/specs/request_for_change.md +++ b/workflows/rfc/specs/request_for_change.md @@ -30,7 +30,7 @@ The RFC document is a Markdown file with the next structure: ## General language and format - You MUST follow [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119.txt) for keywords like MUST, SHOULD, MAY, etc. -- You MUST follow `donna skill artifacts`. +- You MUST follow `donna skill workflows`. - You MUST follow the structure specified in this document. ### List format