diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..c53b96a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,110 @@ +name: CI Docs + +on: + push: + branches: + - main + pull_request: + +jobs: + build-docs: + name: "Build Docs" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # Full history for accurate page timestamps + + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install package and dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + python -m pip install great-docs + + - name: Set up Quarto + uses: quarto-dev/quarto-actions/setup@v2 + + - name: Build docs + run: great-docs build + + - name: Save docs artifact + uses: actions/upload-artifact@v7 + with: + name: docs-html + path: great-docs/_site + include-hidden-files: true + + - name: Upload build timings + uses: actions/upload-artifact@v7 + with: + name: build-timings + path: great-docs/_site/build-timings.json + + publish-docs: + name: "Publish Docs" + runs-on: ubuntu-latest + needs: "build-docs" + if: github.ref == 'refs/heads/main' + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/download-artifact@v7 + with: + name: docs-html + path: great-docs/_site + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5 + with: + path: great-docs/_site + include-hidden-files: true + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + + preview-docs: + name: "Preview Docs" + runs-on: ubuntu-latest + needs: "build-docs" + if: github.event_name == 'pull_request' + permissions: + deployments: write + pull-requests: write + steps: + - uses: actions/download-artifact@v7 + with: + name: docs-html + path: great-docs/_site + + # Start deployment + - name: Configure pull release name + if: ${{ github.event_name == 'pull_request' }} + run: | + echo "RELEASE_NAME=pr-${{ github.event.number }}" >> $GITHUB_ENV + + - name: Configure branch release name + if: ${{ github.event_name != 'pull_request' }} + run: | + # use branch name, but replace slashes. E.g. feat/a -> feat-a + echo "RELEASE_NAME=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV + + # Deploy + - name: Create Github Deployment + uses: bobheadxi/deployments@v1 + id: deployment + if: ${{ !github.event.pull_request.head.repo.fork }} + with: + step: start + token: ${{ secrets.GITHUB_TOKEN }} + env: ${{ env.RELEASE_NAME }} + ref: ${{ github.head_ref }} + logs: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.gitignore b/.gitignore index dc40cae..5864d31 100644 --- a/.gitignore +++ b/.gitignore @@ -143,9 +143,6 @@ venv.bak/ # Rope project settings .ropeproject -# mkdocs documentation -/site - # mypy .mypy_cache/ .dmypy.json @@ -178,6 +175,10 @@ cython_debug/ # Built docs site/ +great-docs/ + +# Git worktrees +.worktrees/ # MacOS .DS_Store diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 6d2d657..7393992 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -13,7 +13,7 @@ Types of Contributions Report Bugs ~~~~~~~~~~~ -Report bugs at https://github.com/vshekar/recap/issues. +Report bugs at https://github.com/NSLS2/recap/issues. If you are reporting a bug, please include: @@ -42,7 +42,7 @@ or even on the web in blog posts, articles, and such. Submit Feedback ~~~~~~~~~~~~~~~ -The best way to send feedback is to file an issue at https://github.com/vshekar/recap/issues. +The best way to send feedback is to file an issue at https://github.com/NSLS2/recap/issues. If you are proposing a feature: @@ -61,11 +61,9 @@ Ready to contribute? Here's how to set up `pyrecap` for local development. $ git clone git@github.com:your_name_here/recap.git -3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: +3. Install the development environment with Pixi:: - $ mkvirtualenv recap - $ cd recap/ - $ python setup.py develop + $ pixi install -e dev 4. Create a branch for local development:: @@ -73,13 +71,12 @@ Ready to contribute? Here's how to set up `pyrecap` for local development. Now you can make your changes locally. -5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: +5. When you're done making changes, run linting and tests:: - $ flake8 recap tests - $ python setup.py test - $ tox + $ pixi run -e dev lint + $ pixi run -e dev test + $ pixi run -e dev test-all - To get flake8 and tox, just pip install them into your virtualenv. 6. Commit your changes and push your branch to GitHub:: @@ -98,6 +95,5 @@ Before you submit a pull request, check that it meets these guidelines: 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.md. -3. The pull request should work for Python 2.7, 3.3, 3.4, 3.5 and for PyPy. Check - https://travis-ci.org/vshekar/recap/pull_requests - and make sure that the tests pass for all supported Python versions. +3. The pull request should support Python 3.10 through 3.13. Run the configured + test suite before submitting it. diff --git a/README.md b/README.md index 7b5501d..0ac237c 100644 --- a/README.md +++ b/README.md @@ -1,1231 +1,144 @@ -# RECAP (Reproducible Experiment Capture and Provenance) - -

- RECAP logo -

- -A scientific framework for reproducible experiment capture, tracking, and metadata management. - -## Overview - -RECAP is a Python framework that captures **experimental provenance** using a SQL database backend (via SQLAlchemy 2.0+). It provides: - -- Pydantic validators -- A DSL builder API -- SQLAlchemy ORM persistence -- A unified provenance graph - -## Table of Contents - -- [Overview](#overview) -- [What Recap does](#what-recap-does) -- [What its _not_ meant for](#what-its-_not_-meant-for) -- [Installation](#installation) -- [Getting started](#getting-started) -- [Core concepts](#core-concepts) -- [Resources](#resources) - - [Resource Hierarchy](#resource-hierarchy) - - [Resource Template](#resource-template) - - [Properties and Property Groups](#properties-and-property-groups) -- [ProcessTemplates and ProcessRuns](#processtemplates-and-processruns) -- [Instantiating a ProcessRun](#instantiating-a-processrun) -- [Adding child resources and steps during runtime](#adding-child-resources-and-steps-during-runtime) -- [Querying Data](#querying-data) - - [Getting a QueryDSL Handle](#getting-a-querydsl-handle) - - [Basic Filtering](#basic-filtering) - - [Filtering Resource Templates by Type](#filtering-resource-templates-by-type) - - [Filtering Resources by Properties](#filtering-resources-by-properties) - - [Filtering Process Runs by Step Parameters](#filtering-process-runs-by-step-parameters) - - [Eager Loading Related Data](#eager-loading-related-data-with-include) - - [Accessing Assigned Resources](#accessing-assigned-resources) - - [Accessing Resource Children](#accessing-resource-children) - - [Provenance Queries](#provenance-queries) - - [Pagination and Ordering](#pagination-and-ordering) -- [Performance Guide](#performance-guide) -- [Roadmap](#roadmap) - -## What Recap does - -Lifecycle of experiments can be arbitrarily long, managing high throughput experiment plans and data capture is difficult and relationships between phases of experiments can be complex. - -Recap provides an "experiment data management" framework which unifies different stages of an experiment under one scalable provenance rich backbone. -It allows a complete audit trail of experiments and answers questions like, "Who ran it?", "When and with what settings?" - -Recap is ideal for high throughput experiments and models physical and digital artifacts and defines relationships between them. - -## What its _not_ meant for - -- Recap is not an Electronic Lab Notebook (ELN) or Lab Inventory Management Software (LIMS), although you can write an application on top of Recap. -- Recap does not deal with instrument control -- Does not perform computations, analyses - -## Installation - -Recap is available on PyPI. Install with: -``` -pip install pyrecap -``` - -## Getting started - -As of Dec 2025, Recap clients connect directly to SQLite databases; a REST API backend is on the roadmap. - -To create and connect to a temporary SQLite database: -```python +# RECAP + +## Reproducible provenance for high-throughput experiments + +RECAP (Reproducible Experiment Capture and Provenance) is a Python framework for +organizing the metadata, resources, and workflows generated by large-scale +experiments. + +High-throughput experiments produce more than data files. They produce samples, +plates, detector outputs, processing results, instrument configurations, +workflow parameters, and relationships between all of them. RECAP records those +relationships so results remain traceable after the experiment is complete. + +## What RECAP provides + +RECAP helps you: + +- Define reusable templates for samples, plates, files, instruments, and other + experimental resources. +- Record hierarchical resources such as plates containing wells or collections + containing samples. +- Define repeatable workflows as process templates with ordered steps, + parameters, and resource slots. +- Record each execution as a process run with its actual inputs, outputs, and + parameter values. +- Organize experiments with hierarchical namespaces. +- Query resources, process runs, properties, parameters, and provenance + relationships. +- Preserve lineage when immutable resources need to be modified. +- Work with local SQLite databases or authenticated remote RECAP servers. + +## How the data is organized + +RECAP separates reusable definitions from experimental instances: + +```text +ResourceTemplate + └── Resource + +ProcessTemplate + └── ProcessRun + ├── assigned Resources + ├── ordered Steps + └── recorded Parameters +``` +Resources and process runs form a provenance graph: +```text +Sample + └── Sample Preparation + └── Prepared Sample + └── Data Collection + └── Raw Data File + └── Data Processing + └── Processed Result +``` +This structure lets you answer questions such as: +- Which sample and preparation conditions produced this result? +- Which resources were used by this process run? +- Which parameters controlled a particular analysis? +- Which results depend on a specific input? +- What metadata belongs to all experiments in a namespace? +Why this is useful for high-throughput science +High-throughput workflows often repeat the same operations across many samples, +plates, visits, or processing batches. Manually maintaining relationships in +notebooks, filenames, and spreadsheets makes provenance difficult to query and +easy to lose. +RECAP turns those relationships into structured records. Templates capture the +stable parts of a workflow, while process runs record what happened for one +specific experiment. Resource hierarchies preserve relationships within physical +and digital artifacts, and namespaces provide organization and access +boundaries as projects grow. +The result is a provenance layer that can support automation, quality control, +reproducibility, and downstream analysis without requiring every application to +invent its own data model. +Minimal example from recap.client import RecapClient -client = RecapClient.from_sqlite() -print(client.database_path) # print path of your database -``` - -If you want a database at a specific path, pass it in. You can also point at an existing database the same way: - -```python -client = RecapClient.from_sqlite("/path/to/database.db") -``` - -## Core concepts - -Data provenance is captured using a combination of `Resources` and `ProcessRuns`. A `Resource` is any trackable entity such as samples, raw detector data, or processed data. A `ProcessRun` is a workflow that describes interactions with `Resources`. Typically, `Resources` are inputs and outputs to a `ProcessRun`. We can chain multiple `ProcessRuns` together either by using the output `Resource` of one process as the input to another, or re-using the same `Resource` as inputs to different `ProcessRuns`. The figure below illustrates how this chain can be created at the MX beamline for a particular set of samples. - -

- Resource Template Schema -

- -Circles represent `Resources` and the rounded boxes represent `ProcessRuns`. Different phases of the experiment process is represented by dashed boxes. By chaining resources together, one can query the database for information such as, given a processing result file, what were the sample preparation conditions? This kind of data provenance is particularly useful to build statistical or machine-learning models to optimize sample preparation or even data acquisition parameters. The next few sections will dive deeper into the way one can use Recap to define [Resources](#resources) and [ProcessRuns](#processtemplates-and-processruns). - - -## Resources - -As mentioned previously, in RECAP any trackable entity is called a **Resource**. A resource can be a physical item (samples, plates, robots), a digital item (raw detector files, processed datasets), or a logical item (intermediate computation results). Think of resources as first-class objects: they carry identity, metadata, lineage, and can be nested inside each other. - -### Resource Hierarchy - -Resources may contain child resources. For example, a 96-well plate includes 96 wells: - -``` -Plate - ├── A01 - ├── A02 - ├── ... - └── H12 -``` - -Each child is also a resource with its own attributes. Before creating a resource you define a `ResourceTemplate`, which is the canonical blueprint for what gets instantiated. - -**Note**: `resource.children` is a `dict[str, ResourceSchema]` keyed by the child resource's name — not a list. Use `resource.children["A01"]` for direct access or `.values()` to iterate all children. See [Accessing Resource Children](#accessing-resource-children). - -### Resource Template - -Example: creating a plate template with child wells: - -```python -with client.build_resource_template(name="Xtal Plate", - type_names=["container", "plate", "xtal_plate"]) as template_builder: - for row in "ABC": - for col in range(1, 4): - template_builder.add_child(f"{row}{col:02d}", ["container", "well"]).close_child() -``` - -Here we build or update a template in the database. A template needs a unique name and one or more `type_names` (tags) so it can be matched to slots in a workflow (`ProcessRun`). The example also shows adding child templates (`well`). - - -### Properties and Property Groups - -Resources can carry metadata organized into groups of related properties. - -Example: Here we add properties to the plate template. The group is called `dimensions` and contains two parameters, `rows` and `columns`. Each entry declares the data `type` and a `default` value; add a `unit` if it matters. We follow up by adding wells as child resource templates, and add properties specific to wells. - -```python -with client.build_resource_template(name="Library Plate", - type_names=["container", "plate", "library_plate"]) as template_builder: - template_builder.add_properties({ - "dimensions": [ - {"name": "rows", "type": "int", "default": 3}, - {"name": "columns", "type": "int", "default": 4}, - ] - }) - - for row in "ABC": - for col in range(1, 4): - template_builder.add_child(f"{row}{col:02d}", ["container", "well"])\ - .add_properties({ - "status": [ - {"name": "used", "type": "bool", "default": False}, - ], - "content": [ - {"name": "catalog_id", "type": "str", "default": ""}, - {"name": "smiles", "type": "str", "default": ""}, - {"name": "sequence", "type": "int", "default": col}, - {"name": "volume", "type": "float", "default": 10.0, "unit": "uL", "metadata": {"min": 0, "max": 20.0}}, - ] - })\ - .close_child() -``` - -The following table shows the available data types that can be assigned to properties. Metadata is an optional dictionary that can be provided for additional data validation. - -#### AttributeValue types and metadata - -| Value Type | Python Type | Metadata keys (optional) | -|------------|-------------|----------------------------------------------| -| `int` | `int` | `min`, `max` | -| `float` | `float` | `min`, `max` | -| `bool` | `bool` | _(none)_ | -| `str` | `str` | _(none)_ | -| `datetime` | `datetime` | _(none)_ | -| `array` | `list` | _(none)_ | -| `enum` | `str` | `choices` (list of allowed string values) | - -Visualizing ResourceTemplates, PropertyGroups and Properties - -

- Resource Template Schema -

- -96 well plates are not the only resources you can model, you can also model an NXMX HDF5 file ([example](docs/resource_examples.md#nexus-hdf5-files)), UR5 robot ([example](docs/resource_examples.md#ur5-robot)) or even a scientist ([example](docs/resource_examples.md#scientist)). - - -### Resources - -Once the template is defined, you can create a Resource instance. Instantiation also materializes any child resources from the template. For example, - -```python -plate = client.create_resource(name="Plate A", template_name="Library Plate") -``` - -creates a Resource from the `Library Plate` template; children are created automatically and properties are initialized with their defaults. - -#### Loading an existing resource - -To load a resource that already exists, use `get_resource`, identifying it by -name and template: - -```python -# Lightweight reference (ResourceRef) — no children or properties hydrated -ref = client.get_resource(name="Plate A", template_name="Library Plate") - -# Full hydration (ResourceSchema) — template, properties, and the entire -# child subtree are loaded in a single bulk query -plate = client.get_resource( - name="Plate A", template_name="Library Plate", expand=True -) -plate.children["A01"].properties.status.used.value # False -``` - -`template_version` defaults to `"1.0"`. `get_resource` raises if no matching -active resource is found. With `expand=True` the whole child hierarchy is -fetched with a bounded, depth-independent number of statements (see the -[Performance Guide](#loadeager-on-resource-queries-fetches-the-whole-subtree)); -`build_property_model()` is called automatically on the returned tree. - -For loading **many** resources, or filtering by properties / parent / type, use -the [Query DSL](#querying-data) instead. - -#### Accessing and updating property values - -Each attribute group on a resource is a `PropertySchema`. Attributes are accessed by dot (slug) or bracket (slug or original name). Each attribute is an `AttributeValueSchema` with a `.value` and a `.unit`. Its `str()` renders `""`: - -```python -well = plate.children["A01"] - -# Read value and unit — dot (slug) or bracket (slug or original name) -well.properties.content.volume.value # 10.0 -well.properties.content.volume.unit # "uL" -str(well.properties.content.volume) # "10.0uL" - -well.properties.content["volume"].value # 10.0 (bracket by slug) -well.properties.content["Volume"].value # 10.0 (bracket by original name) - -# .get() — accepts slug or original name, returns None when missing -well.properties.content.get("volume").value # 10.0 - -# Set value (unit stays unchanged — mutates in-place) -well.properties.content.volume = 8.5 # dot setter -well.properties.content["volume"] = 8.5 # bracket setter — same behaviour - -# Set unit -well.properties.content.volume.unit = "uL" - -# Set a unit-free attribute -well.properties.content.smiles = "CCO" -well.properties.content["smiles"] = "CCO" # equivalent -``` - -The returned object is a Pydantic model, well suited for inspection and local changes, but database changes must go through the client. You can either: - -1. Call `create_resource` (shown above) to get a default instance, or -2. Use a builder to tweak values or add extra children before persisting: - -```python -with client.build_resource(name="Plate B", template_name="Library Plate") as resource_builder: - resource = resource_builder.get_model() - # Make changes to the resource and its default parameters - resource.children["A01"].properties.status.used = True - # Then update the builder with the newly edited object - resource_builder.set_model(resource) -``` - -**Note**: Values in the database can _only_ be changed via builders. Editing the Pydantic model changes your local copy, not the database. Builders are context managers that manage the transaction; if validation fails, the context rolls back safely. - -**Note**: `create_resource` generates the resource with default values and returns a Pydantic model. `build_resource` opens a builder so you can modify values before commit. - -**Note**: Builders support `on_existing="warn" | "raise" | "silent"` (default `"warn"`). Use `"silent"` to reuse existing objects without warning noise in idempotent pipelines. See the [Performance Guide](#on_existing-silent-for-known-existing-records) for the cost of `"silent"` on existing records. - -You can also re-use an existing builder: - -```python -with resource_builder: - # This is the same builder as the one created above - # Any changes here modify the resource called "Plate B" - ... -``` - -To update a known existing resource by its ID (avoids a name-based lookup): - -```python -with client.build_resource(resource_id=resource.id) as rb: - m = rb.get_model() - m.properties.status.used = True - rb.set_model(m) -``` - -#### Calling `build_property_model()` on queried resources - -Resources returned from `create_resource()` have their property access model built automatically. For resources obtained from **queries**, whether `build_property_model()` is called automatically depends on the load strategy used: - -| Load strategy | `build_property_model()` called? | -|---|---| -| `load="eager"` on a resource query | Yes — automatically | -| `include(["properties"])` on a resource query | **No** — call `resource.build_property_model()` manually before accessing `resource.properties.group.attr.value` | -| `include_resources()` on a process run query | Yes — automatically, including for child resources | - -```python -# If you used include(["properties"]) on a resource query: -for resource in results: - resource.build_property_model() # required before property access - print(resource.properties.dimensions.rows.value) -``` - -Every time the context manager is initialized, the builder pulls the latest data for the model from the database. In the example above, the builder updates itself from the database. - -## ProcessTemplates and ProcessRuns - -A `ProcessTemplate` captures a workflow that manipulates resources. - -- Each template contains an ordered series of steps. -- Each step has parameters (similar to resource properties). -- Resources are assigned into slots defined by the template. -- A `ProcessRun` is an instance of a `ProcessTemplate`. -- ProcessRuns form the core provenance trail. - -The figure illustrates the structure of a ProcessTemplate: - -

- Process Template Schema -

- -This template consists of 3 steps. Each step is connected to the next in the order of execution using solid arrows: - -- Imaging step: plate wells are imaged under a microscope. -- Echo Transfer step: the Echo 525 acoustic liquid handler moves liquid, typically well-to-well across plates. -- Harvest step: crystals are harvested from a plate and transferred into a pin that sits in a puck. - -On the left of the Process Template are the input resource slots; only resources of type `library_plate` and `crystal_plate` can be assigned there. The right side shows the output slot, which must be a `puck_collection`. - -Assigned resources play different roles per step. In `Echo Transfer`, the `library_plate` is the `source` and the `crystal_plate` is the `destination`. In `Harvesting`, the `crystal_plate` becomes the `source` and the `puck_collection` is the `destination`. The dotted arrows indicate the association of a `resource_slot` to each `role`. When a `ProcessRun` starts, Recap wires the assigned resources to the appropriate steps based on the template definition. - -Before implementing the ProcessTemplate shown in the figure, add the Resource templates the process needs: the crystal plate, a collection of pucks, a puck template, and a pin (sample holder) that sits inside a puck: - -```python -# Crystal plate template -with client.build_resource_template(name="Crystal Plate", - type_names=["container", "plate", "xtal_plate"]) as template_builder: - template_builder.add_properties({ - "dimensions": [ - {"name": "rows", "type": "int", "default": 3}, - {"name": "columns", "type": "int", "default": 4}, - ] - }) - - for row in "ABC": - for col in range(1, 4): - template_builder.add_child(f"{row}{col:02d}", ["container", "well"])\ - .add_properties({ - "well_map": [ - {"name": "well_pos_x", "type": "int", "default": 0}, - {"name": "well_pos_y", "type": "int", "default": 0}, - {"name": "echo", "type": "str", "default": ""}, - {"name": "shifter", "type": "str", "default": ""}, - ] - })\ - .close_child() - -# Puck collection template -with client.build_resource_template( - name="Puck Collection", type_names=["container", "puck_collection"] -) as pc: - pass - -# Puck template -with client.build_resource_template( - name="Puck", type_names=["container", "puck"] -) as pkb: - pkb.add_properties({ - "details": [ - {"name": "type", "type": "str", "default": "unipuck"}, - {"name": "capacity", "type": "int", "default": 16}, - ] - }) - puck_template = pkb.get_model() - -# Pin template -with client.build_resource_template( - name="Pin", type_names=["container", "pin"] -) as pin: - pin.add_properties({ - "mount": [ - {"name": "position", "type": "int", "default": 0}, - {"name": "sample_name", "type": "str", "default": ""}, - {"name": "departure", "type": "datetime", "default": None}, - ] - }) -``` - -Once the resource templates exist, you can create the process template. You can technically define it earlier, but the resource types it references must already exist. - -```python -from recap.utils.general import Direction -with client.build_process_template("PM Workflow", "1.0") as pt: - ( - pt.add_resource_slot("library_plate", "library_plate", Direction.input) - .add_resource_slot("xtal_plate", "xtal_plate", Direction.input) - .add_resource_slot("puck_collection", "puck_collection", Direction.output) - ) - ( - pt.add_step(name="Imaging") - .add_parameters({ - "drop": [ - {"name": "position", "type": "enum", "default": "u", - "metadata":{"choices": {"u": {"x": 0, "y": 1}, "d": {"x": 0, "y": -1}}}, - }] - }) - .bind_slot("plate", "xtal_plate") - .close_step() - ) - ( - pt.add_step(name="Echo Transfer") - .add_parameters({ - "echo": [ - {"name": "batch", "type": "int", "default": 1}, - {"name": "volume", "type": "float", "default": 25.0, "unit": "nL"}, +with RecapClient.from_sqlite("experiment.db") as client: + client.create_namespace("beamline") + client.create_namespace("beamline/amx", metadata={"beamline": "amx"}) + namespace = client.namespace("beamline/amx") + + with namespace.build_resource_template( + name="Sample Plate", + type_names=["container", "plate"], + ) as template: + template.add_properties({ + "dimensions": [ + {"name": "rows", "type": "int", "default": 8}, + {"name": "columns", "type": "int", "default": 12}, ] }) - .bind_slot("source", "library_plate") - .bind_slot("dest", "xtal_plate") - .close_step() - ) - ( - pt.add_step(name="Harvesting") - .add_parameters({ - "harvest": [ - {"name": "arrival", "type": "datetime"}, - {"name": "departure", "type": "datetime"}, - {"name": "lsdc_name", "type": "str"}, - {"name": "harvested", "type": "bool", "default": False}, - ] - }) - .bind_slot("source", "xtal_plate") - .bind_slot("dest", "puck_collection") - .close_step() - ) -``` - -**Note**: For a given database, it is only required to define a template _once_. Templates are reusable definitions of a resource or process. - -Before we initialize instances of these containers or create a process run, we must associate the current session with a `Campaign`. - -A **Campaign** stores the scientific context: - -- Proposal identifiers -- SAF/regulatory details -- Arbitrary metadata -- All `ProcessRun` objects belonging to the project - -``` -Campaign - └── ProcessRun - ├── Step 1 - ├── Step 2 - └── Step 3 -``` - -Creating a campaign: - -```python -campaign = client.create_campaign( - name="Experiment visit on 12/12/25", - proposal="399999", - saf="123", - metadata={"arbitrary_data": True} -) -``` - -## Instantiating a ProcessRun - -After you set or create a campaign, any ProcessRun or Resource is automatically associated with it. The snippet below creates a ProcessRun tied to that campaign. Recap will raise an exception if you forget to set a campaign first. - -```python -test_xtal_plate = client.create_resource(name="Test crystal plate", template_name="Crystal Plate", version="1.0") -test_library_plate = client.create_resource(name="Test library plate", template_name="Library Plate", version="1.0") -test_puck_collection = client.create_resource("Test puck collection", "Puck Collection") - -with client.build_process_run( - name="Run 001", - description="Fragment screening test run", - template_name="PM Workflow", - version="1.0" -) as prb: - prb.assign_resource("library_plate", test_library_plate) - prb.assign_resource("xtal_plate", test_xtal_plate) - prb.assign_resource("puck_collection", test_puck_collection) - process_run = prb.get_model() -``` - -The figure below shows the resources we created. Resources are assigned to the appropriate slots and wired to their steps. - -

- Process Template Schema -

- -To update parameters on a known existing process run without triggering a failed INSERT, use `process_run_id=`: - -```python -with client.build_process_run(process_run_id=existing_run.id) as prb: - p = prb.get_params("Harvesting") - p.harvest.harvested = True - prb.set_params(p) -``` - -See the [Performance Guide](#on_existing-silent-for-known-existing-records) for why this matters. - -## Adding child resources and steps during runtime - -Sometimes templates can't predict every child resource or step ahead of time. For example, a puck collection may have an arbitrary number of pucks. To add children at runtime, use the `add_child` method in the resource builder: - -```python -with client.build_resource(resource_id=test_puck_collection.id) as pcb: - pcb.add_child(name="Puck01", template_name="Puck", template_version="1.0") -``` - -Or if you have a reference to the template id: - -```python -with client.build_resource(resource_id=test_puck_collection.id) as pcb: - pcb.add_child(name="Puck01", template_id=puck_template.id) -``` - -Child steps can be added dynamically in cases where details are unknown ahead of time. For example, to capture an Echo Transfer step from 1 well of the library plate to the crystal plate we can do the following: - -```python -with client.build_process(process_id=process_run.id) as prb: - echo_transfer_step = process_run.steps["Echo Transfer"].generate_child() - echo_transfer_step.parameters.echo.batch = 2 - echo_transfer_step.parameters.echo.volume = 20 - echo_transfer_step.parameters.echo.volume.unit = "nL" - echo_transfer_step.resources["source"] = test_library_plate.children["A1"] - echo_transfer_step.resources["dest"] = test_xtal_plate.children["A1a"] - prb.add_child_step(echo_transfer_step) -``` - -# Querying Data - -RECAP exposes a small Query DSL on top of the configured backend (SQLAlchemy or another adapter) so that you can express provenance-oriented queries in a fluent, chainable style. Query objects are immutable; each chain returns a new query with your filters/preloads applied. - -The query builder lives on the client as `client.query_maker(on_unloaded=...)` and exposes type-specific entry points: - -- `campaigns()` -> `CampaignQuery` -- `process_templates(shape="full"|"ref", load="none"|"eager")` -> `ProcessTemplateQuery` -- `process_runs(shape="full"|"ref", load="none"|"eager")` -> `ProcessRunQuery` -- `resources(shape="full"|"ref", load="none"|"eager")` -> `ResourceQuery` -- `resource_templates(shape="full"|"ref", load="none"|"eager")` -> `ResourceTemplateQuery` - -Under the hood, these all use a common `BaseQuery` and a backend-provided `.query(model, spec)` implementation. The `QuerySpec` object carries filters, predicates, ordering, preloads, and pagination options down to the backend. Query objects are immutable: every operation like `filter` or `include` returns a new query instance. - -### Getting a QueryDSL Handle - -Assuming you have a configured client: - -```python -qm = client.query_maker(on_unloaded="warn") - -# Query entry points -campaigns = qm.campaigns() -runs = qm.process_runs() # full models + load="none" -resources = qm.resources() -templates = qm.resource_templates() -process_templates = qm.process_templates() -``` - -> **Campaign scoping**: when a campaign is set via `create_campaign()` or -> `set_campaign()`, process run queries are scoped to that campaign by default. -> Resource queries are also scoped, but via a JOIN through `ResourceAssignment → -> ProcessRun` — any resource not yet assigned to a process run in the active -> campaign will be **invisible**. Pass `unscoped=True` to query across all -> campaigns, or reach resources through process run queries using -> `include_resources()` to avoid this constraint. - -```python -# Cross-campaign query — bypasses any active campaign set on the client -qm_all = client.query_maker(unscoped=True) -``` - -`on_unloaded` controls what happens when you access relationship fields that were -not loaded by `include(...)` (or `load="eager"`): - -- `"warn"` (default): emit a warning with include hint. -- `"raise"`: raise an exception immediately. -- `"silent"`: keep old behavior (empty/default container). - -These use custom types: -- warning: `recap.exceptions.UnloadedFieldWarning` -- exception: `recap.exceptions.UnloadedFieldError` - -You can set a default at `query_maker(...)`, and optionally override per query: - -```python -qm = client.query_maker(on_unloaded="raise") -run = qm.process_runs(on_unloaded="warn").filter(name="Run-1").first() -``` - -For process runs, choose the payload shape/load strategy directly: - -```python -qm.process_runs(shape="ref") # lightweight reference models -qm.process_runs(shape="full", load="none") # full schema models without relationships -qm.process_runs(shape="full", load="eager") # full schema models with eager relationships -``` - -`shape="full"` selects existing schema models, while `shape="ref"` selects -reference models. `load="eager"` eagerly loads relationships. -`include(...)` requires `shape="full", load="none"` and raises a `ValueError` -with `load="eager"`. - -> **Migration:** `shape="schema"` and `load="full"` are deprecated aliases for -> `shape="full"` and `load="eager"`. They remain accepted temporarily and emit -> `DeprecationWarning`. - -The same rule applies to `resources`, `process_templates`, and `resource_templates`. - -> **Note — `load="eager"` on resource queries**: `load="eager"` eagerly loads the -> entire child-resource hierarchy. This is fetched in a single bulk query -> (recursive CTE) with a bounded, depth-independent number of SELECTs — it is no -> longer an N+1 pattern. It still materialises the whole subtree, so when you -> only need specific descendants prefer `include(["template", "properties"])` -> for direct resource queries and `descendants()` (or `under_parent()`) for -> targeted descendant queries. See the -> [Performance Guide](#loadeager-on-resource-queries-fetches-the-whole-subtree) -> for details. - -### Basic Filtering - -The simplest way to filter for equality is `filter(**kwargs)`, which translates -keyword arguments into backend-specific filter expressions. It remains a -convenient shorthand for queries such as `filter(name="Run-1")`. - -List all campaigns with a given proposal id: - -```python -campaigns = ( - client.query_maker() - .campaigns() - .filter(proposal="399999") - .all() -) - -for c in campaigns: - print(c.id, c.name) -``` - -Fetch a single campaign by name (or `None` if not found): - -```python -campaign = ( - client.query_maker() - .campaigns() - .filter(name="Beamline Proposal 4321") - .first() -) - -if campaign is None: - raise RuntimeError("No such campaign") -``` - -Counting results: - -```python -n_runs = ( - client.query_maker() - .process_runs() - .count() -) -print("Total runs:", n_runs) -``` - -### Filtering Resource Templates by Type - -`ResourceTemplateQuery` adds a convenience helper `filter_by_types` for semantic resource types: - -```python -xtal_plate_templates = ( - client.query_maker() - .resource_templates() - .filter_by_types(["xtal_plate"]) - .all() -) - -for tmpl in xtal_plate_templates: - print(tmpl.name, tmpl.types) -``` - -### Filtering Resources by Properties - -`ResourceQuery.filter_property` lets you compare against typed property values. The property group is optional; pass it when you need to disambiguate. Supported comparators: `eq`, `gt`, `gte`, `lt`, `lte`, `between`, and `in_`. - -```python -# Scalar comparisons -plates = ( - client.query_maker() - .resources() - .filter_property("rows", gt=100, group="dimensions") - .all() -) - -# Membership test with in_ -samples_of_interest = ( - client.query_maker() - .resources() - .filter_property("catalog_id", in_=["L001", "L002", "L003"], group="content") - .all() -) -``` - -Scope a property filter to all descendants of a parent resource with `under_parent`. - -> **`under_parent()` uses a recursive SQL CTE** and finds descendants at **any -> depth** — not just direct children. `under_parent(dewar)` on a -> `dewar → puck → sample` hierarchy returns both pucks and samples. Add a -> `filter(resource_template_id=...)` to narrow to a specific level. - -```python -# All descendants of parent_resource with height > 10 (any depth) -child_hits = ( - client.query_maker() - .resources() - .filter_property("height", gt=10) - .under_parent(parent_resource) - .all() -) - -# Only samples (leaf level) under a dewar -samples = ( - client.query_maker() - .resources() - .include(["template", "properties"]) - .filter(resource_template_id=sample_tmpl_id) - .under_parent(dewar_resource) - .all() -) -``` - -For the common case — "give me everything under this resource, hydrated" — use -the `descendants()` shortcut, which wraps -`under_parent(parent).include(["template", "properties"])`: - -```python -# Every resource beneath the dewar (any depth), template + properties loaded -all_below = client.query_maker().resources().descendants(dewar_resource).all() - -# Restrict to a single template (e.g. just samples), still one bulk query -samples = ( - client.query_maker() - .resources() - .descendants(dewar_resource, of_template=sample_tmpl_id) - .all() -) -``` - -### Filtering Process Runs by Step Parameters - -`ProcessRunQuery.filter_parameter` works like `filter_property` but targets step parameters. Supported comparators: `eq`, `gt`, `gte`, `lt`, `lte`, `between`, and `in_`. Narrow by step name and parameter group name to avoid ambiguity. - -```python -# Scalar comparison -runs = ( - client.query_maker() - .process_runs() - .filter_parameter("dwell", gt=10, group="Exposure", step="Collect") - .all() -) - -# Membership test with in_ -active_runs = ( - client.query_maker() - .process_runs() - .filter_parameter("state", in_=["queued", "running"], group="queue_meta", step="Request") - .all() -) -``` - -### Eager Loading Related Data with `include` - -Queries can preload related entities via the `include` helper. Each include path maps to a backend `selectinload` chain. - -`include` accepts either a single string or a list of dot-path strings: - -```python -runs = ( - client.query_maker() - .process_runs() - .include(["steps", "steps.parameters", "resources"]) - .all() -) -``` - -The type-specific queries also expose convenience methods: - -- `CampaignQuery.include_process_runs()` -- `ProcessRunQuery.include_steps(include_parameters: bool = False)` -- `ProcessRunQuery.include_resources()` -- `ProcessTemplateQuery.include_step_templates()` -- `ProcessTemplateQuery.include_resource_slots()` -- `ResourceQuery.include_template()` -- `ResourceTemplateQuery.include_children()` -- `ResourceTemplateQuery.include_attribute_groups()` -- `ResourceTemplateQuery.include_types()` - -> **`include_resources()` provides richer property hydration than -> `include(["properties"])` on direct resource queries.** It batch-loads -> `Property._values` AND `Property.template` for assigned resources, and -> automatically calls `build_property_model()` on all assigned resources and -> their children. Direct resource queries using `include(["properties"])` do not -> batch-load `Property.template` and require a manual `build_property_model()` -> call before accessing `resource.properties.group.attr.value`. - -Example: load campaigns and their process runs in one go: - -```python -campaigns = ( - client.query_maker() - .campaigns() - .include_process_runs() - .all() -) - -for c in campaigns: - print("Campaign:", c.name) - for run in c.process_runs: - print(" Run:", run.name) -``` - -#### Example: load runs with steps and parameters - -```python -runs = ( - client.query_maker() - .process_runs() - .include_steps(include_parameters=True) - .all() -) - -for run in runs: - print(f"Run: {run.name}") - # run.steps is a dict[str, StepSchema] keyed by step name - for step_name, step in run.steps.items(): - print(f"\tStep: {step_name}") - # step.parameters is a dict[str, ParameterSchema] keyed by group name - for group_name, param_group in step.parameters.items(): - print(f"\t\tGroup: {group_name}") - for param_name, param_value in param_group.items(): - print(f"\t\t\t{param_name} = {param_value.value}") -``` - -#### Example: load resources with their template - -```python -library_plates = ( - client.query_maker() - .resources() - .filter(types__names_in=["library_plate"]) - .include_template() - .all() -) - -for plate in library_plates: - print("Resource:", plate.name) - print(" Template:", plate.template.name) -``` - -### Accessing Assigned Resources - -When process runs are loaded with `include_resources()`, the assigned resources -are available via `run.assigned_resources` — a `dict[str, ResourceAssignmentSchema]` -keyed by the slot name defined in the process template. Each value is a -`ResourceAssignmentSchema` with `slot` and `resource` fields. - -```python -runs = ( - client.query_maker() - .process_runs() - .filter(process_template_id=my_template_id) - .include_steps(include_parameters=True) - .include_resources() - .all() -) - -for run in runs: - print(f"Run: {run.name}") - - # Iterate all assigned resources by slot name - for slot_name, assignment in run.assigned_resources.items(): - resource = assignment.resource - print(f" Slot '{slot_name}': {resource.name}") - # Properties are fully hydrated — build_property_model() was called automatically - print(f" rows = {resource.properties.dimensions.rows.value}") - - # Or access a specific slot directly - xtal_plate = run.assigned_resources["xtal_plate"].resource - library_plate = run.assigned_resources["library_plate"].resource -``` - -`include_resources()` also loads the child resources of each assigned resource -(e.g., wells inside a plate, samples inside a puck). Children are accessible via -`resource.children` — see [Accessing Resource Children](#accessing-resource-children). - -### Accessing Resource Children - -`resource.children` is a `dict[str, ResourceSchema]` **keyed by the child -resource's name**. Use bracket access for a named child, or `.values()` to -iterate all children: - -```python -# Named access -well = plate.children["A01"] -print(well.properties.content.volume.value) - -# Iterate all children -for child_name, child_resource in resource.children.items(): - # If the resource was loaded via include_resources() on a process run query, - # build_property_model() has already been called on children automatically. - # For resources loaded via direct resource queries, call it manually: - child_resource.build_property_model() - print(child_name, child_resource.properties.status.used.value) -``` - -### Provenance Queries - -#### "Which campaigns touched this sample?" - -```python -sample = ( - client.query_maker() - .resources() - .filter(name="Sample 42") - .first() -) - -if sample is None: - raise RuntimeError("Sample not found") - -runs = ( - client.query_maker() - .process_runs() - .filter(resources__id=sample.id) - .include_steps() - .all() -) - -campaign_ids = {run.campaign_id for run in runs} -campaigns = ( - client.query_maker() - .campaigns() - .filter(id__in=list(campaign_ids)) - .all() -) - -for c in campaigns: - print("Campaign:", c.name) -``` - -#### "Show me a full tree for a campaign" - -```python -campaign = ( - client.query_maker() - .campaigns() - .filter(name="Buffer Prep") - .include_process_runs() - .first() -) - -if campaign is None: - raise RuntimeError("No such campaign") - -runs = ( - client.query_maker() - .process_runs() - .filter(campaign_id=campaign.id) - .include_steps(include_parameters=True) - .include_resources() - .all() -) - -for run in runs: - print("Run:", run.name) - - # assigned_resources is a dict[str, ResourceAssignmentSchema] keyed by slot name - print(" Resources:") - for slot_name, assignment in run.assigned_resources.items(): - print(f" - {assignment.resource.name} (slot: {slot_name})") - - # steps is a dict[str, StepSchema] keyed by step name - print(" Steps:") - for step_name, step in run.steps.items(): - print(f" - {step_name}") - for group_name, param_group in step.parameters.items(): - for param_name, param_schema in param_group.items(): - print(f" {group_name}.{param_name} = {param_schema.value}") -``` - -### Field Predicates, Ordering, and Pagination - -All query types expose generic helpers: - -- `where(*predicates)` -- `order_by(*orderings)` -- `limit(value)` -- `offset(value)` - -Use `Field` to build backend-independent predicates and orderings. Structured -`Field` expressions work through both local SQLite and remote GraphQL clients: - -```python -from recap.dsl.query import Field - -runs = ( - client.query_maker() - .process_runs() - .where(Field("name").starts_with("Run-batch")) - .where(Field("campaign.proposal") == "2026-1") - .order_by(Field("create_date").desc()) - .all() -) -``` - -Supported predicates are: - -- comparisons: `==`, `!=`, `>`, `>=`, `<`, and `<=` -- membership: `.in_(values)` and `.not_in(values)` -- strings: `.contains(value)`, `.starts_with(value)`, and `.ends_with(value)` - -Pass `Field("name")` to `order_by()` for ascending order, or use `.asc()` and -`.desc()` explicitly. Multiple predicates passed to one `where()` call, and -predicates added by chained `where()` calls, are combined with `AND`. Python -`and`/`or` composition is unsupported; chain `where()` calls instead. Boolean -`OR`, `NOT`, and predicate grouping are not supported. - -Field paths use dot notation and may traverse ORM relationships to arbitrary -depth, for example `Field("campaign.proposal")` or -`Field("template.parent.name")`. String operations match their argument -literally, so `%` and `_` are not SQL wildcard characters. Regex and SQL -`LIKE` pattern syntax are not supported. - -Raw SQLAlchemy predicates and orderings are deprecated. They remain available -for local queries during migration, emit `DeprecationWarning`, and cannot be -sent to a remote backend. Migrate local expressions as follows: - -```python -from recap.db import ProcessRun -from recap.dsl.query import Field - -# Deprecated, local only -legacy = client.query_maker().process_runs().where(ProcessRun.name == "Run-1") - -# Preferred, local and remote -portable = client.query_maker().process_runs().where(Field("name") == "Run-1") -``` - -Example: fetch the 10 most recent runs: - -```python -from recap.dsl.query import Field - -recent_runs = ( - client.query_maker() - .process_runs() - .order_by(Field("create_date").desc()) - .limit(10) - .all() -) - -for run in recent_runs: - print(run.create_date, run.name) -``` - ---- - -# Performance Guide - -This section documents behaviours that are correct but have non-obvious -performance implications at scale. - -## Query recommendations - -A quick checklist for fast, predictable queries: - -1. **Default to `shape="ref"` or `load="none"`** when you only need - identity/scalar fields — this skips relationship hydration entirely. -2. **Use `include([...])`** to load exactly the relations you touch; avoid - `load="eager"` on deep resource trees. -3. **Fetch deep hierarchies with `descendants()`** (or - `under_parent()` + `filter(resource_template_id=...)`) — one bulk query, then - call `build_property_model()` per row. -4. **For known-existing records, use the ID overloads** - (`resource_id=` / `process_run_id=`) rather than `on_existing="silent"`, which - costs extra round-trips (see below). -5. **Group writes by campaign** and call `set_campaign()` once per group. -6. **`include(...)` requires `shape="full", load="none"`** — it raises - `ValueError` when combined with `load="eager"`. - -## `load="eager"` on resource queries fetches the whole subtree - -When `load="eager"` is used on a resource query (or `include(["children"])`), -Recap sets `include_children=True` and hydrates the entire child-resource -hierarchy. This is loaded in a single recursive-CTE bulk query — roots plus all -descendants — with their templates and properties eagerly fetched. The number of -SQL statements is **bounded and independent of tree depth or size**; it is no -longer an N+1 pattern. - -The remaining cost is that the *whole* subtree is materialised into schemas. -When you only need certain descendants (e.g. just the samples under a dewar, not -the intervening pucks), it is still cheaper to fetch them directly than to -hydrate the full tree. - -**Prefer** `include(["template", "properties"])` for direct resource queries, and -use `descendants()` (which wraps -`under_parent().include(["template", "properties"])`, optionally narrowed by -`of_template=`) to query descendants in a single targeted bulk query: - -```python -# Efficient: one query for all samples at any depth under the dewar -samples = qm.resources().descendants(dewar_resource, of_template=sample_tmpl_id).all() -for sample in samples: - sample.build_property_model() - print(sample.properties.identity.sample_name.value) -``` - -## `set_campaign()` has a DB round-trip on every call - -Every call to `client.set_campaign()` opens a transaction, runs -`SELECT Campaign WHERE id = ?`, and commits — even when called with the same -campaign as last time. In batch write loops that process many items across a -small number of campaigns, group items by campaign and call `set_campaign()` -once per group rather than once per item. - -```python -# Inefficient — set_campaign() called 496 times -for request in all_requests: - campaign = get_campaign_for(request) - client.set_campaign(campaign=campaign) # round-trip every iteration - with client.build_process_run(...) as prb: - ... - -# Efficient — set_campaign() called once per unique campaign -from itertools import groupby -for campaign, requests in groupby(all_requests, key=get_campaign_for): - client.set_campaign(campaign=campaign) # once per campaign - for request in requests: - with client.build_process_run(...) as prb: - ... -``` - -## `on_existing="silent"` for known-existing records - -When `build_process_run(name=..., on_existing="silent")` or -`build_resource(name=..., on_existing="silent")` is called for a record that -already exists, Recap attempts an INSERT, receives a UNIQUE constraint violation, -rolls back the transaction, re-opens a new transaction, and then queries for the -existing record. This is three database round-trips instead of one. - -If you already know a record exists (e.g. from a prior bulk query), use the -`process_run_id=` or `resource_id=` overload to load it directly: - -```python -# Slow for known-existing runs — triggers INSERT → ROLLBACK → SELECT -with client.build_process_run( - run_name, description, template_name, version, on_existing="silent" -) as prb: - ... - -# Fast — loads by ID with no failed INSERT -with client.build_process_run(process_run_id=existing_run.id) as prb: - ... - -# Same for resources -with client.build_resource(resource_id=existing_resource.id) as rb: - ... -``` - -Pre-fetch existing records with a bulk query before entering your write loop, then -use the ID-based overload for all existing items and the name-based overload only -for genuinely new ones. - ---- - -## Roadmap - -- REST API backend -- CLI -- Web UI for campaign/process management - -## GraphQL Server - -recap ships an optional read-only GraphQL server (`pyrecap[server]`). Start it with: - -```bash -recap-server --db /path/to/recap.db --port 8000 -# or with a config file: -recap-server --config recap-server.yaml -``` - -`RecapClient.from_url()` connects to a running server. For reads, the Python -client sends the complete `QuerySpec` through an internal GraphQL field. The -server reconstructs the query and delegates it unchanged to `LocalBackend`, so -load modes, includes, pagination, and unloaded-field policy match local queries. -The client then uses Pydantic validators to reconstruct nested models and -restores relation-load metadata, including warning or raising when an unloaded -field is accessed. - -Structured `Field` predicates and orderings serialize through `QuerySpec` and -work for local and remote reads. Raw SQLAlchemy expressions are deprecated and -local-only; remote clients reject them with `TypeError` before making an HTTP -request. See [Field Predicates, Ordering, and Pagination](#field-predicates-ordering-and-pagination) -for supported expressions and migration examples. - -Writes do not travel through GraphQL. `from_url()` fetches the database path -reported by the server and opens that SQLite file directly with `LocalBackend`. -The client and server must therefore have shared filesystem access, and the -server-reported path must resolve to the same database from both processes. - -```python -client = RecapClient.from_url("http://localhost:8000") -``` - -### Caveat: snake_case field names - -The recap GraphQL server disables Strawberry's default camelCase conversion (`auto_camel_case=False`). All field names in queries and responses use **snake_case** (`create_date`, `modified_date`, `process_runs`, etc.) rather than the GraphQL convention of camelCase (`createDate`, `modifiedDate`, `processRuns`). - -This is intentional for Phase 1 — it allows the Python client to deserialize GraphQL responses directly into Pydantic schemas without a translation layer. - -**Impact for external GraphQL clients** (GraphiQL, language clients, etc.): use snake_case field names in all queries. This behaviour may change in a future release when a dedicated response translation layer is added. + with namespace.build_resource( + name="Plate 001", + template_name="Sample Plate", + ) as plate_builder: + plate = plate_builder.resource +The same namespace-scoped client can define process templates, create process +runs, assign resources, record parameters, and query the resulting provenance +graph. +Local and remote use +For local workflows, RECAP manages a SQLite database: +client = RecapClient.from_sqlite("experiment.db") +For shared or service-based deployments, clients connect to an authenticated +RECAP server: +client = RecapClient.from_url( + "https://recap.example.org", + api_key="your-api-key", +) +Remote clients use authenticated REST for queries, reads, creates, updates, and +resource copies. Applications do not need direct access +to the server's database filesystem. + +Remote queries use ordinary JSON request and result envelopes. Local and remote +clients share the same QueryDSL, canonical entity identity, and load-aware model +behavior. Query results default to full models with relationships unloaded; +explicit `include(...)` or `load="eager"` controls hydration. Builders collect +drafts and submit one aggregate command at `save()`, locally or through REST. + +Query results can be passed to `query.export(format, destination)` through the +registered exporter extension point. RECAP does not prescribe a built-in export +format. +What RECAP does not do +RECAP records experimental data and provenance. It is not: +- An electronic lab notebook. +- A laboratory inventory management system. +- An instrument-control system. +- A scientific analysis or computation engine. +Applications can build those capabilities on top of RECAP's provenance model. +Learn more +- Getting started (/getting_started/00-orientation.html) +- Complete provenance workflow (/getting_started/04-complete-provenance-workflow.html) +- Quick start: create and store data locally (/how-to/quick-start-create-and-store-data-locally.html) +- Model a process workflow (/getting_started/03-process-workflow.html) +- How-to guides (/how-to/index.html) +- Reference (/reference/index.html) +- Explanations (/explanation/index.html) +- Tutorials (/tutorials/index.html) +Install +pip install pyrecap +Install server support with: +pip install "pyrecap[server]" +RECAP is released under the 3-clause BSD license. diff --git a/docs/assets/favicon.ico b/docs/assets/favicon.ico new file mode 100644 index 0000000..fb5ba12 Binary files /dev/null and b/docs/assets/favicon.ico differ diff --git a/docs/assets/process_chain.png b/docs/assets/process_chain.png new file mode 100644 index 0000000..c0b1d85 Binary files /dev/null and b/docs/assets/process_chain.png differ diff --git a/docs/assets/process_run.png b/docs/assets/process_run.png new file mode 100644 index 0000000..245dbb0 Binary files /dev/null and b/docs/assets/process_run.png differ diff --git a/docs/assets/process_template.png b/docs/assets/process_template.png new file mode 100644 index 0000000..238a1ee Binary files /dev/null and b/docs/assets/process_template.png differ diff --git a/docs/assets/recap_logo.png b/docs/assets/recap_logo.png new file mode 100644 index 0000000..7c4fd55 Binary files /dev/null and b/docs/assets/recap_logo.png differ diff --git a/docs/assets/resource_template.png b/docs/assets/resource_template.png new file mode 100644 index 0000000..55f6f94 Binary files /dev/null and b/docs/assets/resource_template.png differ diff --git a/docs/explanation/audit-and-errors.qmd b/docs/explanation/audit-and-errors.qmd new file mode 100644 index 0000000..c835e8b --- /dev/null +++ b/docs/explanation/audit-and-errors.qmd @@ -0,0 +1,22 @@ +--- +title: Audit and Error Semantics +--- + +# Audit and Error Semantics + +Every command carries request identity through the mutation path. Command-service +audit records capture request ID, actor ID, mutation, target, outcome, and a safe +reason code. + +Audit output excludes API keys, authorization headers, property values, and +process parameter values. These exclusions keep operational records useful +without turning them into credential or experiment-value logs. + +Server errors use stable error categories and request IDs. Clients can return a +request ID with an error report without exposing internal exception details or +authorization grant contents. + +The command layer emits success records inside the mutation transaction and can +write non-authorization failure records after rollback. The current REST server +does not connect commands to a durable audit sink, so REST audit output is not +persisted. diff --git a/docs/explanation/authentication-authorization.qmd b/docs/explanation/authentication-authorization.qmd new file mode 100644 index 0000000..cc02cec --- /dev/null +++ b/docs/explanation/authentication-authorization.qmd @@ -0,0 +1,73 @@ +--- +title: Authentication and Authorization +--- + +# Authentication and Authorization + +Authentication identifies a request. Authorization decides whether that actor +may perform an operation in a namespace. + +## Request actor + +Authentication produces an immutable `RequestActor` containing: + +- Stable actor identity. +- Provider identities such as `(provider, subject)` pairs. +- Credential scopes. +- Optional namespace restrictions. +- A credential fingerprint for safe audit correlation. + +The actor is created once per request and passed through REST request context. +Query and command requests use ordinary JSON over authenticated REST. Local +execution uses an unrestricted single-user actor and policy; remote execution +authenticates each request and applies server policy. Both use namespace-scoped +query semantics. + +## Scopes + +Scopes are separate read and write permissions for namespace, resource template, +resource, process template, and process run families. Write permission does not +implicitly grant read permission. + +## Namespace grants + +Multi-user authorization is compiled into an immutable snapshot. Grants bind +identities and roles to namespace paths and scopes. Policy evaluation: + +```{mermaid} +%%| fig-cap: "RECAP namespace authorization evaluation" +flowchart TD + Request[Incoming request] --> Canonicalize[Canonicalize namespace] + Canonicalize --> Restriction{Actor namespace restriction allows target?} + Restriction -->|No| DenyRestriction[Deny request] + Restriction -->|Yes| Identity[Match provider identities] + Identity --> Grants[Collect grants from namespace and ancestors] + Grants --> Scopes[Intersect grants with credential scopes] + Scopes --> Target{Target relationship valid?} + Target -->|No| DenyTarget[Deny request] + Target -->|Yes| Allow[Authorize operation] +``` + +Evaluation first canonicalizes namespace, applies actor restrictions, matches +provider identities, collects namespace and ancestor grants, intersects those +grants with credential scopes, and validates the target relationship. Write +scope does not imply read scope. + +Ancestor grants can make templates and resources visible in descendants. Sibling +namespaces remain isolated. Process-run access requires exact namespace context. + +## Cross-namespace operations + +Namespace creation checks write permission on the parent namespace. Resource +copy requires both source read permission and destination write permission. A +valid source permission alone cannot authorize a destination write. + +## REST query filtering + +Authorization visibility is applied before user filters, ordering, pagination, +and counts. This prevents unauthorized records from influencing result pages or +counts returned by REST query/read endpoints. + +Remote query requests are JSON envelopes sent to `POST /api/v1/query` or +`POST /api/v1/query/count`. Responses contain only authorized models or counts; +authorization never relies on client-side filtering. diff --git a/docs/explanation/core-concepts.qmd b/docs/explanation/core-concepts.qmd new file mode 100644 index 0000000..ce94947 --- /dev/null +++ b/docs/explanation/core-concepts.qmd @@ -0,0 +1,27 @@ +--- +title: Core Concepts +--- + +# Core Concepts + +RECAP separates reusable definitions from concrete experimental records. + +![A process run connects resources through ordered steps](../assets/process_run.png) + +- **Namespace**: hierarchical ownership and authorization context. +- **ResourceTemplate**: blueprint for a resource and its properties or child + resources. +- **Resource**: trackable physical, digital, or logical entity with identity, + metadata, and lineage. +- **ProcessTemplate**: versioned workflow definition containing steps, + parameters, roles, and resource slots. +- **ProcessRun**: concrete execution record connected to a process template and + assigned resources. + +Resources and process runs form a provenance graph. A process run records which +resources filled its slots and which parameters governed its steps. Downstream +queries can therefore trace outputs back through the resources and processes +that produced them. + +Namespaces replace the former Campaign ownership model. Namespaces also make +visibility explicit for local queries and authenticated remote requests. diff --git a/docs/explanation/data-organization.qmd b/docs/explanation/data-organization.qmd new file mode 100644 index 0000000..218bf01 --- /dev/null +++ b/docs/explanation/data-organization.qmd @@ -0,0 +1,112 @@ +--- +title: How RECAP Organizes Data +--- + +# How RECAP Organizes Data + +RECAP separates definitions from instances and connects instances through a +provenance graph. This organization lets one workflow definition be reused +across many experiments while retaining the exact resources and parameters +used by each run. + +![Resource and process provenance graph](../assets/process_chain.png) + +The graph view is useful when a result must be traced back through the process +runs and resources that produced it. + +## Namespaces + +Every process run, template, and resource belongs to a Namespace. Namespaces +form a hierarchy such as: + +```text +beamline +└── beamline/amx + └── beamline/amx/proposal/312345 +``` + +Namespace context scopes queries and writes. Namespaces also provide the +boundary used by remote authorization. A child namespace can inherit visibility +from ancestors without making sibling namespaces visible. + +## Resources and resource templates + +A Resource is any trackable physical, digital, or logical entity: a sample, a +plate, a detector file, an instrument, or an intermediate dataset. A +ResourceTemplate defines the structure that instances receive. + +```text +ResourceTemplate: Sample Plate +├── properties: dimensions +└── children: wells + ├── A01 + ├── A02 + └── ... +``` + +Child resources have their own identity, template, properties, and lifecycle +within the parent graph. The public `children` value is a dictionary keyed by +child name, so use `plate.children["A01"]` for named access and +`plate.children.values()` for iteration. + +![Resource template organization](../assets/resource_template.png) + +## Property groups and attributes + +Resource and process templates organize values into named groups. Each +attribute declares a value type, optional unit, default, and validation +metadata. + +```text +dimensions +├── rows: int = 8 +└── columns: int = 12 + +content +├── catalog_id: str +└── volume: float [uL] +``` + +Property values are exposed through typed schema models. A value has both a +value and optional unit; metadata can constrain numeric ranges or enum choices. + +## Process templates and runs + +A ProcessTemplate is a reusable workflow definition. It contains ordered steps, +step parameters, resource slots, and role bindings. A ProcessRun is one concrete +execution of that template. + +```text +ProcessTemplate: Measure Sample v1.0 +├── input slot: sample +├── output slot: result +└── steps + ├── Collect + └── Analyze + +ProcessRun: Run 001 +├── sample -> Sample Plate 001 +├── result -> Analysis File 001 +└── parameters: values recorded for this run +``` + +Slots separate a workflow's interface from concrete resources. Steps bind slots +to roles such as `source`, `destination`, or `operator`, so the same template +can run with different resource instances. + +## Provenance graph + +Process runs connect resources into a directed provenance graph. A resource can +be an input to one run, an output of another, or reused across multiple runs +when namespace visibility and lifecycle rules permit it. + +The provenance image above provides the detailed visual graph: for example, +`Sample` can flow through a `Prepare` run to a `Prepared Sample`, then through a +`Measure` run to a `Result File`. A resource may also be reused by another +process run when namespace visibility and lifecycle rules permit it. + +This graph supports questions such as which sample preparation produced a +result file, which parameters governed a run, and which resources were assigned +to a workflow step. + +![Process template organization](../assets/process_template.png) diff --git a/docs/explanation/index.qmd b/docs/explanation/index.qmd new file mode 100644 index 0000000..739098a --- /dev/null +++ b/docs/explanation/index.qmd @@ -0,0 +1,14 @@ +--- +title: Explanation +--- + +# Explanation + +Explanation pages describe how RECAP works and why its design matters. They +cover namespaces, provenance, lifecycle and freezing, local/remote transport, +authentication, authorization, and command processing. + +For exact signatures, accepted values, return models, and failure behavior, see +the [Reference](/reference/index.html) section. Explanation describes why +RECAP behaves as it does; Reference is lookup material rather than workflow +instruction. diff --git a/docs/explanation/query-model.qmd b/docs/explanation/query-model.qmd new file mode 100644 index 0000000..14d6580 --- /dev/null +++ b/docs/explanation/query-model.qmd @@ -0,0 +1,44 @@ +--- +title: Query Model and Loading +--- + +# Query Model and Loading + +RECAP queries are immutable descriptions of filters, predicates, ordering, +pagination, and relationship loading. The same query model is interpreted by +the local backend and transported through REST query/read endpoints for remote +clients. + +## Canonical models and loading + +- `shape="full"` is the supported model shape and returns canonical schema + models. +- `shape="ref"` is deprecated compatibility syntax. It normalizes to + `shape="full"`; specify `load="none"` explicitly for unloaded relationships. +- `load="none"` avoids relationship hydration. +- `load="eager"` loads configured relationships eagerly. +- `include(...)` selects explicit relationship paths for full, non-eager + queries. + +The client identity map merges models by stable entity family and UUID. A +lightweight result and a later hydrated result therefore refer to the same +canonical object, while fuller relationship data upgrades existing state. + +Namespace context travels beside the serialized `QuerySpec`. It is not embedded +inside query predicates, which keeps predicates portable across backends. + +## Visibility and pagination + +Namespace visibility is applied before user filters, ordering, pagination, and +counts. This ordering prevents an authorized query from counting or paginating +records that the actor cannot see. + +Structured `Field` predicates work across local and remote backends. Raw +SQLAlchemy predicates remain local-only and are deprecated. + +Hydration uses bounded SQL plans. Resource and process-run trees do not issue +N+1 statements as depth or breadth grows; use targeted includes when complete +subtrees are not needed. + +`query.export(format, destination)` sends materialized models to a registered +exporter. No export format is built into the query model. diff --git a/docs/explanation/resource-loading.qmd b/docs/explanation/resource-loading.qmd new file mode 100644 index 0000000..3ea9698 --- /dev/null +++ b/docs/explanation/resource-loading.qmd @@ -0,0 +1,59 @@ +--- +title: Resource Models and Loading +--- + +# Resource Models and Loading + +RECAP exposes canonical, load-aware resource models. Callers choose relationship +hydration without choosing a separate wire or model shape. + +## Model identity and loading + +Full models contain stable identity and schema fields. `load="none"` leaves +relationships unloaded; targeted includes and `load="eager"` hydrate them. +The old `shape="ref"` spelling is deprecated and normalizes to `shape="full"`. +Specify `load="none"` explicitly for unloaded relationships. + +```python +plates = ( + namespace.query_maker() + .resources(load="none") + .filter(name="Sample Plate 001") + .include(["template", "properties"]) + .all() +) +``` + +Use `load="eager"` when the complete relationship tree is required. Resource +and process-run eager loading uses a bounded, depth-independent number of SQL +statements, but can materialize more data than needed. Targeted descendant +queries are preferable when only one level or template is relevant. + +## Property model access + +Resources created through builders expose property access models directly. A +resource loaded with `include(["properties"])` may require +`build_property_model()` before accessing nested values. Process-run queries +using `include_resources()` hydrate assigned resources and their children for +property access. + +```python +for resource in plates: + resource.build_property_model() + print(resource.properties.dimensions.rows.value) +``` + +## Child resources + +```python +plate = namespace.query_maker().resources(load="eager").first() +well = plate.children["A01"] + +for name, child in plate.children.items(): + print(name, child.id) +``` + +Child hierarchies can be defined in a template or extended at runtime through a +resource builder. Child identity remains scoped by its parent while the root +resource retains its own UUID. Repeated references to the same root or child are +canonicalized within client lifetime. diff --git a/docs/explanation/rest-query-architecture.qmd b/docs/explanation/rest-query-architecture.qmd new file mode 100644 index 0000000..37abb22 --- /dev/null +++ b/docs/explanation/rest-query-architecture.qmd @@ -0,0 +1,101 @@ +--- +title: REST Query and Command Architecture +--- + +# REST Query and Command Architecture + +Remote RECAP clients use REST query and command endpoints. Query transport is +ordinary JSON RPC over HTTP, not a schema-specific query language. + +```{mermaid} +%%| fig-cap: "Remote RECAP client read and write transports" +%%| fig-cap-location: bottom +flowchart LR + Client[RecapClient.from_url] + Client -->|queries and counts| RESTQuery[REST query endpoints] + Client -->|create, update, copy, lifecycle| REST[REST command endpoints] + RESTQuery --> Routes[FastAPI routes] + REST --> Routes[FastAPI routes] + Routes --> Commands[CommandService] + Commands --> Auth[Authorization] + Commands --> Tx[One database transaction] + Commands --> Revision[Revision and idempotency checks] + Commands --> Audit[Mutation audit] +``` + +REST query endpoints carry namespace-scoped query specifications and reconstruct +the corresponding local query behavior. REST command routes carry aggregate +command payloads to `CommandService`, which owns authorization, transactions, +revisions, idempotency, and audit outcomes. + +Query requests use JSON envelopes such as `entity`, `projection`, +`namespace_path`, and serialized `spec`; results return the entity, projection, +and serialized items (or a count for the count endpoint). Local and remote +clients therefore share QueryDSL semantics without sharing database access. + +For readers who cannot view the diagram, reads use `POST /api/v1/query` and +counts use `POST /api/v1/query/count`. Writes enter FastAPI routes, then pass +through command authorization, one transaction, revision and idempotency checks, +and audit recording. + +This split means remote clients do not require access to the server's database +filesystem. The client API remains the same for local and remote builders, but +remote writes cross an authenticated HTTP boundary. + +## REST route families + +Create routes place the entity collection before the namespace path: + +```text +POST /api/v1/process-templates/{namespace_path} +POST /api/v1/resource-templates/{namespace_path} +POST /api/v1/resources/{namespace_path} +POST /api/v1/process-runs/{namespace_path} +``` + +Update routes identify immutable owning aggregates by UUID: + +```text +PATCH /api/v1/process-templates/{template_id} +PATCH /api/v1/resource-templates/{template_id} +PATCH /api/v1/resources/{resource_id} +PATCH /api/v1/process-runs/{process_run_id} +``` + +Namespace commands use a path for creation and UUID for updates: + +```text +PUT /api/v1/namespaces/{namespace_path} +PATCH /api/v1/namespaces/{namespace_id} +``` + +Resource and process-run copies are derived-aggregate commands: + +```text +POST /api/v1/resources/{source_resource_id}/copies +POST /api/v1/process-runs/{source_process_run_id}/copies +``` + +The destination namespace is required in each copy request body. Lifecycle +transitions use: + +```text +POST /api/v1/lifecycle/{object_type}/{object_id} +``` + +Builders do not issue incremental remote writes. They accumulate immutable draft +payloads and submit one aggregate command at `save()`. This keeps local and REST +builder behavior aligned and makes validation failures side-effect free. + +Query models are canonicalized by stable entity identity at the client boundary. +The same entity received through separate queries or relationship paths resolves +to one in-memory model; later fuller loads upgrade that canonical model. Use +`load="none"`, targeted `include(...)`, or `load="eager"` to control hydration. + +Resource-tree and process-run loading use bounded, depth-independent SQL +statement budgets rather than one query per child. Targeted loading remains +preferable when a complete tree is unnecessary. + +`query.export(format, destination)` delegates to a registered exporter. The +registry is an extension point, not a built-in claim about any serialization +format. diff --git a/docs/getting_started/00-orientation.qmd b/docs/getting_started/00-orientation.qmd new file mode 100644 index 0000000..4e9e985 --- /dev/null +++ b/docs/getting_started/00-orientation.qmd @@ -0,0 +1,38 @@ +--- +title: Getting Started +--- + +# Getting Started + +RECAP captures experimental provenance through resources, process templates, +and process runs. Start with the local workflow, then use the remote workflow +when a RECAP server owns the database. + +## Find the right documentation + +RECAP documentation is organized around four complementary ways to use the +documentation: + +- **Getting Started** provides guided lessons for learning RECAP by doing. Begin + with [Complete Provenance Workflow](04-complete-provenance-workflow.qmd), then + use the shorter [Quick Start: Create and Store Data Locally](../how-to/quick-start-create-and-store-data-locally.qmd) + when you need a task recipe. +- **How-To Guides** provide focused procedures for users who need to complete a + specific task. Use [How-To Guides](../how-to/index.qmd) for templates, + servers, queries, authentication, and provenance operations. +- **Explanation** describes how RECAP works and why its architecture matters. + Read [Explanation](../explanation/index.qmd) when you need concepts before + choosing an implementation approach. +- **Tutorials** show complete domain workflows from setup through working code. + Browse [Tutorials](../tutorials/index.qmd) when you want a guided path. +- **Reference** lists generated public APIs, schemas, query families, and + builder methods. Browse [API Reference](/reference/index.html) for exact + signatures and model fields. + +## Choose a workflow + +- [Quick start: create and store data locally](../how-to/quick-start-create-and-store-data-locally.qmd) + with SQLite. +- [Use a remote RECAP server](../how-to/setup-recap-server.qmd) with REST query/read and + command transports. Requests use ordinary JSON; local and remote QueryDSL, + canonical identity, and load-aware models remain aligned. diff --git a/docs/getting_started/03-process-workflow.qmd b/docs/getting_started/03-process-workflow.qmd new file mode 100644 index 0000000..90c1fc6 --- /dev/null +++ b/docs/getting_started/03-process-workflow.qmd @@ -0,0 +1,72 @@ +--- +title: Model a Process Workflow +--- + +# Model a Process Workflow + +This tutorial shows how a process template becomes a provenance record. A +template defines slots, steps, role bindings, and parameters; a run supplies +concrete resources and values. + +## Define the workflow + +```python +from recap.utils.general import Direction + +with namespace.build_process_template("Measure Plate", "1.0") as template: + template.add_resource_slot("input_plate", "plate", Direction.input) + template.add_resource_slot("result_file", "file", Direction.output) + ( + template.add_step("Measure") + .add_parameters({ + "acquisition": [ + {"name": "exposure", "type": "float", "default": 0.1, "unit": "s"}, + {"name": "mode", "type": "str", "default": "standard"}, + ] + }) + .bind_slot("source", "input_plate") + .bind_slot("destination", "result_file") + .close_step() + ) +``` + +Slots are the stable interface between a workflow and its resources. Role +bindings let the step logic refer to `source` and `destination` without naming +one particular plate or file. + +## Create a run + +```python +with namespace.build_process_run( + name="Measure Plate 001", + description="First measurement", + template_name="Measure Plate", + version="1.0", +) as run: + run.assign_resource("input_plate", input_plate) + run.assign_resource("result_file", result_file) + + params = run.get_params("Measure") + params.acquisition.exposure = 0.25 + run.set_params(params) +``` + +The run records assignments and parameter values alongside the ordered steps. +Use `model_dump()` to inspect a parameter model and +`model_json_schema()` to inspect its generated validation schema. + +## Inspect assignments + +When loaded with resources, assigned values are available by slot name: + +```python +run = ( + namespace.query_maker() + .process_runs() + .include_steps(include_parameters=True) + .include_resources() + .first() +) + +input_plate = run.assigned_resources["input_plate"].resource +``` diff --git a/docs/getting_started/04-complete-provenance-workflow.qmd b/docs/getting_started/04-complete-provenance-workflow.qmd new file mode 100644 index 0000000..723ed52 --- /dev/null +++ b/docs/getting_started/04-complete-provenance-workflow.qmd @@ -0,0 +1,170 @@ +--- +title: Complete Provenance Workflow +--- + +# Complete Provenance Workflow + +This tutorial follows one experiment from an empty local database to a queried +provenance record. You will define reusable templates first, then create one +resource and one process run that uses it. The Quick Start is a shorter recipe; +this tutorial pauses after each stage so you can connect the definitions to the +records they produce. One client stays open across all code blocks, so dependent +variables remain available and active namespace context remains valid. Run the +blocks in order; final block closes client. + +## 1. Start a local client and namespace + +Create a new SQLite database and a namespace for this experiment: + +```python +from recap.client import RecapClient + +client = RecapClient.from_sqlite("provenance-tutorial.db") +client.create_namespace("tutorial") +namespace_context = client.create_namespace( + "tutorial/sample-preparation", + metadata={"experiment": "sample preparation"}, +) +namespace = client.namespace("tutorial/sample-preparation") +``` + +The client manages the local database, while active namespace scopes templates, +resources, and process runs. After this step, you should have a database file +named `provenance-tutorial.db` and a namespace at +`tutorial/sample-preparation`. Continue when the namespace creation completes +without an exception. + +## 2. Define a resource template + +Define the shape of a sample. The property group gives every sample a typed +identifier with an empty default: + +```python +with namespace.build_resource_template( + name="Tutorial Sample", + type_names=["sample"], +) as template: + template.add_properties({ + "identity": [ + {"name": "sample_id", "type": "str", "default": ""}, + ] + }) +``` + +The context manager validates and persists the template. You should now have a +`Tutorial Sample` template in the namespace. The template is a reusable +definition; it is not yet an experimental sample record. + +## 3. Create a resource + +Create one sample from the template and set its property value: + +```python +with client.build_resource( + name="Sample A", + template_name="Tutorial Sample", + namespace_path=namespace.path, +) as builder: + builder.resource.properties["identity"].values.sample_id.value = "A-001" + builder.finalize() +``` + +The builder persists `Sample A` when the context exits. Retrieve the saved +resource model before assigning it to a process run: + +```python +sample = ( + namespace.query_maker() + .resources() + .filter(name="Sample A") + .first() +) +assert sample is not None +``` + +You should now have a resource with its own ID, the `sample` type, and +`sample_id` equal to `A-001`. This is the concrete resource that the process run +will use. + +## 4. Define a process template + +Define a preparation workflow with one input slot and one step parameter: + +```python +from recap.utils.general import Direction + +with namespace.build_process_template("Tutorial Preparation", "1.0") as template: + template.add_resource_slot("sample", "sample", Direction.input) + ( + template.add_step("Prepare") + .add_parameters({ + "conditions": [ + {"name": "temperature", "type": "float", "default": 20.0, "unit": "degC"}, + {"name": "duration", "type": "float", "default": 10.0, "unit": "min"}, + ] + }) + .bind_slot("source", "sample") + .close_step() + ) +``` + +The process template is the repeatable workflow definition. You should now see +`Tutorial Preparation` version `1.0` with input slot `sample`, step `Prepare`, +and two declared parameters. No process execution exists yet. + +## 5. Create a process run and record parameters + +Instantiate the process template, assign `Sample A`, and replace the defaults +with values for this execution: + +```python +with namespace.build_process_run( + name="Prepare Sample A", + description="Tutorial preparation run", + template_name="Tutorial Preparation", + version="1.0", +) as run: + run.assign_resource("sample", sample) + + params = run.get_params("Prepare") + params.conditions.temperature = 22.5 + params.conditions.duration = 12.0 + run.set_params(params) + run.finalize() +``` + +When the context exits, the run records its template, resource assignment, step, +and parameter values. You should now have one process run named +`Prepare Sample A`; its `sample` slot points to `Sample A`, and its recorded +temperature and duration are `22.5` and `12.0`. + +## 6. Query the resulting provenance + +Load the run with its steps, parameters, and assigned resources: + +```python +result = ( + namespace.query_maker() + .process_runs() + .filter(name="Prepare Sample A") + .include_steps(include_parameters=True) + .include_resources() + .first() +) + +assert result is not None +assert result.assigned_resources["sample"].resource.name == "Sample A" +assert result.steps["Prepare"].parameters.conditions.temperature.value == 22.5 +assert result.steps["Prepare"].parameters.conditions.duration.value == 12.0 + +print(result.name) +print(result.assigned_resources["sample"].resource.name) +print(result.steps["Prepare"].parameters.conditions.model_dump()) +client.close() +``` + +The query should return one hydrated process run. Its assigned resource points +back to `Sample A`, and its `Prepare` step contains the values recorded for this +execution. This is the provenance graph assembled from the template definitions +and concrete records: a process run uses a named resource and preserves the +parameters that governed that run. diff --git a/docs/how-to/add-runtime-children.qmd b/docs/how-to/add-runtime-children.qmd new file mode 100644 index 0000000..6aa481b --- /dev/null +++ b/docs/how-to/add-runtime-children.qmd @@ -0,0 +1,28 @@ +--- +title: Add Runtime Children +--- + +# Add Runtime Children + +Templates describe predictable structure. Resource builders can add child +resources when an experiment discovers structure at runtime. + +## Add a child resource + +Use template name and version: + +```python +with namespace.build_resource(resource_id=collection.id) as builder: + builder.add_child( + name="Puck01", + template_name="Puck", + template_version="1.0", + ) +``` + +Child resources are persisted as part of the root aggregate and remain +available through the root's `children` dictionary. + +Process templates define their ordered steps. Process-run builders record +assignments and parameter values for those declared steps; they do not support +adding child process steps at runtime. diff --git a/docs/how-to/build-process-runs.qmd b/docs/how-to/build-process-runs.qmd new file mode 100644 index 0000000..077b0fb --- /dev/null +++ b/docs/how-to/build-process-runs.qmd @@ -0,0 +1,93 @@ +--- +title: Build Process Runs +--- + +# Build Process Runs + +Process runs instantiate a process template, assign resources to its slots, and +record step parameters for one execution. + +## Define slots and steps + +```python +from recap.utils.general import Direction + +with namespace.build_process_template("Measure Sample", "1.0") as template: + template.add_resource_slot("sample", "sample", Direction.input) + template.add_resource_slot("result", "file", Direction.output) + ( + template.add_step("Measure") + .add_parameters({ + "acquisition": [ + {"name": "exposure", "type": "float", "default": 1.0}, + {"name": "mode", "type": "str", "default": "standard"}, + ] + }) + .bind_slot("source", "sample") + .bind_slot("destination", "result") + .close_step() + ) +``` + +Slots declare compatible resource types. Role bindings let a step refer to a +slot by purpose instead of by a concrete resource name. + +## Create and assign a run + +```python +with namespace.build_process_run( + name="Run 001", + description="Sample measurement", + template_name="Measure Sample", + version="1.0", +) as run: + run.assign_resource("sample", sample_resource) + run.assign_resource("result", result_resource) +``` + +The resulting ProcessRun records the template, assignments, ordered steps, and +parameter values. Use `process_run_id=` when updating a known run to avoid a +name-based lookup. + +## Record dynamic resources + +When an experiment discovers resource structure at runtime, add child resources +through a resource builder. Process-run builders record assignments and +parameters for steps declared by their process template; they cannot add steps. + +## Set generated process parameters + +Generated parameter models provide typed access to values declared by a process +step. Read model, mutate it, then submit it back to run: + +```python +with namespace.build_process_run(process_run_id=run.id) as builder: + params = builder.get_params("Measure") + params.acquisition.exposure = 2.5 + params.acquisition.mode = "fast" + builder.set_params(params) +``` + +`set_params()` flushes updated values as part of builder transaction. + +## Finalize a completed run + +Finalize is separate lifecycle action. Call it after assignments, steps, and +parameters are complete: + +```python +with namespace.build_process_run(process_run_id=run.id) as builder: + builder.finalize() +``` + +Finalization marks run active. + +## Archive a run + +Archive completed runs separately when they should remain queryable but no longer +be treated as active: + +```python +with namespace.build_process_run(process_run_id=run.id) as builder: + builder.archive() +``` diff --git a/docs/how-to/choose-query-shape-and-loading.qmd b/docs/how-to/choose-query-shape-and-loading.qmd new file mode 100644 index 0000000..9e77e9c --- /dev/null +++ b/docs/how-to/choose-query-shape-and-loading.qmd @@ -0,0 +1,88 @@ +--- +title: Choose Query Shape and Loading +--- + +# Choose Query Shape and Loading + +Choose relationship loading, then add targeted paths when needed. Query results +are canonical full models; loading controls relationship hydration. + +```{mermaid} +%%| fig-cap: "Choosing RECAP query shape and relationship loading" +flowchart TD + Start[What data does the task need?] + Start --> Identity{Identity and scalar fields only?} + Identity -->|Yes| Full[Full models with no relationship loading] + Identity -->|No| Full[Full models] + Full --> Complete{Need complete relationship tree?} + Complete -->|Yes| Eager[Complete tree: eager loading] + Complete -->|No| Targeted[Targeted loading with explicit includes] + Targeted --> Bounded[Use explicit relationship paths or descendants] +``` + +Use `load="none"` when identity and scalar fields are enough. Use eager loading +for a complete relationship tree or non-eager explicit includes for targeted +relationships. `shape="ref"` remains deprecated compatibility syntax and must +not be used in new code. + +## Choose a result shape + +```python +minimal = namespace.query_maker().resources(load="none").all() +resources = namespace.query_maker().resources(load="eager").all() +``` + +Both queries return canonical full models. First query returns identity and +scalar fields; second hydrates configured relationships. + +## Choose loading mode + +`load="none"` avoids relationship hydration. `load="eager"` loads the +configured relationship tree. + +```python +minimal = namespace.query_maker().resources(load="none").all() +complete = namespace.query_maker().resources(load="eager").all() +``` + +Use eager loading only when the complete tree is required. For a bounded +subtree, prefer explicit inclusion or `descendants()`. + +## Include targeted relationships + +`include(...)` selects explicit relationship paths on full, non-eager queries. +Convenience methods expand to those paths: + +```python +resources = ( + namespace.query_maker() + .resources(load="none") + .include(["template", "properties", "children"]) + .all() +) + +runs = ( + namespace.query_maker() + .process_runs(load="none") + .include_steps(include_parameters=True) + .include_resources() + .all() +) +``` + +`include_template()` is the resource shortcut. Process runs provide +`include_steps()` and `include_resources()`. + +## Deprecated combinations + +New code should not use `shape="ref"`. It is normalized to full models; use +`load="none"` when relationships must remain unloaded. `include(...)` still requires +non-eager loading: + +```python +namespace.query_maker().resources(load="eager").include("children") +``` + +Use `load="none"` with `include(...)`, or use `load="eager"` without explicit +includes. Hydration plans have bounded SQL statement counts and avoid N+1 +queries for resource trees. diff --git a/docs/how-to/edit-resource-properties.qmd b/docs/how-to/edit-resource-properties.qmd new file mode 100644 index 0000000..e30ff28 --- /dev/null +++ b/docs/how-to/edit-resource-properties.qmd @@ -0,0 +1,49 @@ +--- +title: Edit Resource Properties +--- + +# Edit Resource Properties + +Resource schemas provide typed property groups and values for inspection and +local editing. Editing a schema does not persist changes by itself; use a +resource builder to submit the edited model. + +## Read values + +```python +plate = namespace.query_maker().resources(load="eager").first() +plate.build_property_model() + +rows = plate.properties.dimensions.rows.value +unit = plate.properties.dimensions.rows.unit +``` + +Attributes support dot access and bracket access. Brackets accept either the +normalized slug or the original attribute name, and `.get(name)` returns a +missing attribute as `None`. + +```python +group = plate.properties["dimensions"] +rows = group["rows"].value +rows = group.get("rows").value +``` + +## Modify and persist values + +```python +with namespace.build_resource(resource_id=plate.id) as builder: + model = builder.get_model() + model.properties.dimensions.rows = 16 + model.properties.dimensions.rows.unit = "count" + builder.set_model(model) +``` + +The builder validates values against the template and persists the aggregate on +successful context-manager exit. An exception rolls the change back. + +## Units and metadata + +Setting an attribute value preserves its existing unit. Set the unit explicitly +when the update changes its meaning. Template metadata can constrain numeric +ranges or enumerate allowed values; invalid values fail validation before the +mutation is committed. diff --git a/docs/how-to/handle-errors-and-request-ids.qmd b/docs/how-to/handle-errors-and-request-ids.qmd new file mode 100644 index 0000000..97185a6 --- /dev/null +++ b/docs/how-to/handle-errors-and-request-ids.qmd @@ -0,0 +1,51 @@ +--- +title: Handle Errors and Request IDs +--- + +# Handle Errors and Request IDs + +Mutation failures are structured so callers can classify failures without +parsing internal exception text. + +## Classify failures + +- **Validation**: submitted properties, parameters, or references violate the + template or command contract. +- **Authorization**: caller lacks permission for namespace or target. +- **Conflict**: revision is stale, idempotency key is reused for another + command, or `on_existing="raise"` detects duplicate. +- **Connection**: client cannot reach server or receives rejected HTTP response. + +Local builders raise domain exceptions such as `ExistingResourceError`. +Remote clients expose `RecapConnectionError` for transport failures and +`RecapHTTPError` for rejected responses. HTTP errors include status code and, +when supplied by server, `request_id`. + +## Use request IDs in diagnostics + +Every server response carries `X-Request-ID`; structured error bodies also carry +that identifier: + +```text +X-Request-ID: 6f4c2f08-... +``` + +Return opaque value to operators when reporting failure. Do not expose API keys, +authorization headers, internal tracebacks, or sensitive property and parameter +values in user-facing errors. + +## Rely on atomic mutation behavior + +Failed mutations roll back database changes without retaining partial state. +The current REST server does not persist failure audits; use its request ID to +correlate a rejected response with server diagnostics. + +```python +from recap.exceptions import ExistingResourceError + +try: + with namespace.build_resource(resource_id=plate.id) as builder: + builder.set_props({"dimensions": {"rows": -1}}) +except ExistingResourceError as exc: + print(f"Local mutation failed: {exc}") +``` diff --git a/docs/how-to/index.qmd b/docs/how-to/index.qmd new file mode 100644 index 0000000..7c10392 --- /dev/null +++ b/docs/how-to/index.qmd @@ -0,0 +1,13 @@ +--- +title: How-To Guides +--- + +# How-To Guides + +How-to guides solve focused RECAP tasks. They assume readers know the relevant +concepts and prioritize an actionable procedure over broad explanation. + +Start with [Quick Start: Create and Store Data Locally](quick-start-create-and-store-data-locally.qmd) +if you want to create data without running a server. For server-backed work, use +[Set Up a RECAP Server](setup-recap-server.qmd), then follow the same workflow +with a URL client. diff --git a/docs/how-to/manage-resource-lifecycle.qmd b/docs/how-to/manage-resource-lifecycle.qmd new file mode 100644 index 0000000..f2e87c6 --- /dev/null +++ b/docs/how-to/manage-resource-lifecycle.qmd @@ -0,0 +1,52 @@ +--- +title: Manage Resource Lifecycle +--- + +# Manage Resource Lifecycle + +Resources move through three states: + +- **Mutable**: properties can be edited and saved. +- **Active**: resource is finalized for use and cannot be patched in place. +- **Archived**: resource is retained for history and cannot be patched in place. + +## Activate a resource + +```python +with namespace.build_resource(resource_id=plate.id) as builder: + with builder: + builder.finalize() +``` + +Activation and preceding edits commit when builder exits normally. + +## Archive a resource + +```python +with namespace.build_resource(resource_id=plate.id) as builder: + builder.archive() +``` + +Archiving retains resource and provenance while removing it from mutable +lifecycle. + +## Edit frozen resources with copy-on-write + +Active and archived resources cannot be patched in place. Create mutable copy, +then edit copy: + +```python +from recap.schemas.resource import ResourceCopyOptions + +copied = namespace.copy_resource( + source_resource_id=plate.id, + options=ResourceCopyOptions(name="Sample Plate 001 Revision"), +) + +with namespace.build_resource(resource_id=copied.id) as builder: + props = builder.get_props() + props.dimensions.rows = 16 + builder.set_props(props.model_dump(by_alias=True)) +``` + +Copy-on-write preserves frozen source and records lineage from copy to source. diff --git a/docs/how-to/nested-resource-templates.qmd b/docs/how-to/nested-resource-templates.qmd new file mode 100644 index 0000000..80babbf --- /dev/null +++ b/docs/how-to/nested-resource-templates.qmd @@ -0,0 +1,61 @@ +--- +title: Nest Resource Templates +--- + +# Nest Resource Templates + +Use nested resource templates when every instance should expose the same +hierarchy. Each `add_child` call returns a child builder. Add that child's +properties or children, then call `close_child()` to return to its parent. + +```python +with namespace.build_resource_template( + name="Experiment", + type_names=["experiment"], +) as experiment: + sample = experiment.add_child("Sample", ["sample"]) + sample.add_properties({ + "identity": [ + {"name": "sample_id", "type": "str", "default": ""}, + ], + }) + + vial = sample.add_child("Vial", ["vial"]) + vial.add_properties({ + "storage": [ + {"name": "position", "type": "str", "default": ""}, + ], + }) + sample = vial.close_child() + experiment = sample.close_child() +``` + +The example defines two nesting levels below `Experiment`: `Sample` and `Vial`. +Close builders from deepest child back to root so each builder's parent is +explicit. A `with` block also saves the root template when it exits. + +## Distinguish template and runtime children + +**Template children:** predictable structure defined once by a template. + +**Runtime children:** experiment-specific structure added to an instance. + +Use template children for required hierarchy such as experiment, sample, and +vial. Use runtime children when count or structure is discovered during an +experiment and should not change the template for every future instance. + +## Copy an existing resource into a group + +Use runtime children to group an existing resource without changing its source +or its template. Pass either its `ResourceSchema` or UUID to `add_child`: + +```python +with namespace.build_resource(resource_id=group.id) as group_builder: + copied_sample = group_builder.add_child(sample) +``` + +The operation copies the selected resource and its full descendant subtree +beneath the group. The copied root has `copied_from_id == sample.id`; +descendants do not individually record lineage. A copied root keeps its source +name, and a group cannot receive another child with the same name. Rename the +source before grouping when that name already exists under the group. diff --git a/docs/how-to/performance.qmd b/docs/how-to/performance.qmd new file mode 100644 index 0000000..13c337e --- /dev/null +++ b/docs/how-to/performance.qmd @@ -0,0 +1,46 @@ +--- +title: Choose Efficient Loading and Writes +--- + +# Choose Efficient Loading and Writes + +RECAP provides explicit controls for relationship hydration and known-record +updates. Use them to avoid loading more of the provenance graph than a task +requires. + +## Keep query payloads small + +Use references for identity-only results, explicit relationship paths for +targeted hydration, and pagination for large result sets. See [Choose query +shape and loading](choose-query-shape-and-loading.qmd), [Query Resources](query-resources.qmd), +and [Query Process Runs](query-process-runs.qmd). + +Use `descendants()` or `under_parent()` for targeted resource subtree queries +instead of eager-loading an entire large hierarchy. See [Trace Provenance](provenance-queries.qmd) +for traversal patterns. + +## Test suites + +Run default tests with `pixi run test`. This excludes SQL statement-budget +regressions. Run those regressions with `pixi run test-performance`; run all +tests with `pixi run test-all` before release or when changing query loading. + +## Update known records by ID + +Builders accept `resource_id=` and `process_run_id=` for existing records. These +paths avoid probing by name and avoid an insert-conflict-rollback-select cycle. +See [Use revisions and idempotency](use-revisions-and-idempotency.qmd) for +known-ID update procedures. + +```python +with namespace.build_resource(resource_id=resource_id) as builder: + resource = builder.get_model() + resource.properties.status.used = True + builder.set_model(resource) +``` + +## Group work by namespace + +Namespace context controls visibility and authorization. Reuse one namespace +client for a batch of related operations rather than repeatedly switching +contexts. diff --git a/docs/how-to/process-template.qmd b/docs/how-to/process-template.qmd new file mode 100644 index 0000000..5abb707 --- /dev/null +++ b/docs/how-to/process-template.qmd @@ -0,0 +1,92 @@ +--- +title: Create a Process Template +--- + +# Create a Process Template + +Use a process template to define a workflow that consumes or produces +resources. Templates contain resource slots, ordered steps, role bindings, and +parameters. + +```python +from recap.utils.general import Direction + +with namespace.build_process_template("Collect Data", "1.0") as builder: + builder.add_resource_slot("sample", "sample", Direction.input) + builder.add_resource_slot( + "calibration", "calibration", Direction.input, required=False + ) + builder.add_resource_slot( + "output", "file", Direction.output, create_resource_type=True + ) + ( + builder.add_step("Collect") + .add_parameters({ + "acquisition": [ + {"name": "exposure", "type": "float", "default": 1.0} + ] + }) + .bind_slot("sample", "sample") + .bind_slot("output", "output") + .close_step() + ) +``` + +## Add required and optional slots + +Slots identify resource types used by a process. Slots are required by default; +set `required=False` when a run may omit that resource, such as optional +calibration data. + +```python +builder.add_resource_slot("sample", "sample", Direction.input) +builder.add_resource_slot( + "calibration", "calibration", Direction.input, required=False +) +``` + +## Declare input and output directions + +Use `Direction.input` for resources consumed by a process and +`Direction.output` for resources it produces. Direction is part of the slot +definition and keeps process roles explicit. + +```python +builder.add_resource_slot("source", "raw-data", Direction.input) +builder.add_resource_slot("result", "processed-data", Direction.output) +``` + +## Create resource types from slots + +Set `create_resource_type=True` when slot's named resource type should be +created as part of template construction. Leave it false when type must already +exist or is managed separately. + +```python +builder.add_resource_slot( + "report", "analysis-report", Direction.output, + create_resource_type=True, +) +``` + +## Define step parameters + +Add typed parameter groups to a step. Parameters support defaults, units, and +metadata constraints just like resource properties: + +```python +step = builder.add_step("Collect") +step.add_parameters({ + "acquisition": [ + {"name": "exposure", "type": "float", "default": 1.0, "unit": "s"}, + {"name": "mode", "type": "str", "default": "standard"}, + ], +}) +step.bind_slot("sample", "sample") +step.bind_slot("output", "output") +step.close_step() +``` + +Process templates are versioned. Once a template becomes referenced, lifecycle +rules prevent unsafe mutation; create a new version when the workflow shape +must change. diff --git a/docs/how-to/provenance-queries.qmd b/docs/how-to/provenance-queries.qmd new file mode 100644 index 0000000..f67c947 --- /dev/null +++ b/docs/how-to/provenance-queries.qmd @@ -0,0 +1,97 @@ +--- +title: Trace Provenance +--- + +# Trace Provenance + +Process runs and resources form a graph. Query both sides of that graph to +answer where a resource came from and which workflows used it. + +## Find runs that used a resource + +```python +runs = ( + namespace.query_maker() + .process_runs() + .filter(resources__id=resource.id) + .include_steps() + .all() +) +``` + +The reverse traversal starts from a process run and loads assigned resources. +This is useful for finding outputs and inputs of one workflow execution. + +## Find copied-resource lineage + +Copied resources retain provenance links to their source. Query the copied +resource and include its provenance relationship when inspecting lineage. + +```python +copies = ( + namespace.query_maker() + .resources() + .filter(copied_from_id=source.id) + .include_template() + .all() +) +``` + +The `copied_from_id` field identifies source resource. Query that ID directly +when source details are needed; resource schemas expose source ID, not a +hydrated `copied_from` relationship. + +## Load a complete run tree + +```python +run = ( + namespace.query_maker() + .process_runs() + .filter(name="Run 001") + .include_steps(include_parameters=True) + .include_resources() + .first() +) + +for slot_name, assignment in run.assigned_resources.items(): + print(slot_name, assignment.resource.name) + +for step_name, step in run.steps.items(): + print(step_name, step.parameters) +``` + +`include_resources()` also loads child resources of assigned resources, which +makes the complete provenance tree available from one run model. + +## Trace a resource subtree + +```python +samples = ( + namespace.query_maker() + .resources() + .descendants(dewar, of_template=sample_template.id) + .all() +) +``` + +Use `descendants()` for all-depth traversal. It is equivalent to a targeted +`under_parent(...).include(["template", "properties"])` query and loads those +two relationships in one subtree operation. + +## Traverse resource-to-run provenance + +Start with a resource ID and filter process runs through assigned resources. +Add steps and parameters when the consuming workflow details are needed. + +```python +consumers = ( + namespace.query_maker() + .process_runs() + .filter(resources__id=sample.id) + .include_steps(include_parameters=True) + .all() +) +``` + +For a focused subtree, combine `descendants()` with a template filter before +using the resulting resource IDs in a run query. diff --git a/docs/how-to/query-process-runs.qmd b/docs/how-to/query-process-runs.qmd new file mode 100644 index 0000000..d8a650e --- /dev/null +++ b/docs/how-to/query-process-runs.qmd @@ -0,0 +1,78 @@ +--- +title: Query Process Runs +--- + +# Query Process Runs + +Process-run queries support process-template filters, typed parameter filters, +relationship inclusion, visibility, ordering, and pagination. + +## Filter process templates + +Use `filter()` for query fields and `Field` predicates for portable structured +comparisons. + +```python +from recap.dsl.query import Field + +runs = ( + namespace.query_maker() + .process_runs() + .where(Field("process_template.name") == "Collect") + .where(Field("name").starts_with("Run-")) + .all() +) +``` + +## Filter parameters + +`filter_parameter()` requires exactly one comparator. Use `eq`, range +comparators, `between`, or membership with `in_`; pass `step` when parameter +names repeat across steps. + +```python +query = namespace.query_maker() + +exact = query.process_runs().filter_parameter( + "detector", eq="pilatus", step="Collect" +).all() + +in_range = query.process_runs().filter_parameter( + "exposure", between=(0.1, 1.0), step="Collect" +).all() + +selected = query.process_runs().filter_parameter( + "energy", in_=[8.0, 12.0, 16.0], value_type="float" +).all() +``` + +## Include run relationships + +```python +runs = ( + query.process_runs() + .include_steps(include_parameters=True) + .include_resources() + .all() +) +``` + +`include_steps()` loads steps; `include_steps(include_parameters=True)` also +loads step parameters; `include_resources()` loads assigned resources and +their child resources. + +## Include archived runs and paginate + +```python +runs = ( + query.process_runs() + .include_archived() + .order_by(Field("create_date").desc()) + .limit(25) + .offset(50) + .all() +) +``` + +Archived runs are excluded by default. `first()` is equivalent to applying +`limit(1)` and returning the first result. diff --git a/docs/how-to/query-resources.qmd b/docs/how-to/query-resources.qmd new file mode 100644 index 0000000..e37f85b --- /dev/null +++ b/docs/how-to/query-resources.qmd @@ -0,0 +1,89 @@ +--- +title: Query Resources +--- + +# Query Resources + +Create namespace-scoped queries and compose immutable filters. Each operation +returns a new query. + +```python +from recap.dsl.query import Field + +query = namespace.query_maker() +resources = ( + query.resources() + .where(Field("name").starts_with("Sample")) + .where(Field("state") == "active") + .order_by(Field("create_date").desc()) + .limit(10) + .offset(20) + .all() +) +``` + +## Filter typed properties + +Use exactly one comparator for each typed-property filter. Supported +comparators are `eq`, `gt`, `gte`, `lt`, `lte`, `between`, and `in_`. + +```python +large_plates = ( + query.resources() + .filter_property("rows", gte=100, group="dimensions") + .filter_property("material", in_=["Si", "Ge"], group="sample") + .all() +) +``` + +## Include templates and children + +Full resource queries can explicitly load relationships. `include_template()` +loads each resource's template; `include(["children"])` loads child resources. + +```python +plates = query.resources().include_template().include("children").all() +``` + +Use `load="eager"` when the complete configured relationship tree is needed. +See [Choose query shape and loading](choose-query-shape-and-loading.qmd) for +the difference between eager and targeted loading. + +## Include archived resources + +Archived resources are excluded by default. Add `include_archived()` when a +retired record should be visible. + +```python +all_versions = query.resources().include_archived().all() +``` + +## Query descendants + +```python +samples = query.resources().descendants(dewar).all() +sample_descendants = ( + query.resources() + .descendants(dewar, of_template=sample_template.id) + .all() +) +``` + +`descendants()` searches every level below parent and loads `template` and +`properties`. Use `under_parent(...)` when combining ancestry with additional +filters without the descendant convenience preloads. + +## Filter namespace metadata + +Metadata filters apply to namespace queries, not resource queries. Local +metadata belongs to the namespace itself; effective metadata includes inherited +values. + +```python +local = query.namespaces().filter_local_metadata(owner="beamline-a").all() +effective = ( + query.namespaces() + .filter_effective_metadata(environment="production") + .all() +) +``` diff --git a/docs/how-to/query-templates-and-namespaces.qmd b/docs/how-to/query-templates-and-namespaces.qmd new file mode 100644 index 0000000..557a555 --- /dev/null +++ b/docs/how-to/query-templates-and-namespaces.qmd @@ -0,0 +1,64 @@ +--- +title: Query Templates and Namespaces +--- + +# Query Templates and Namespaces + +Query templates and namespaces through separate query families. All families +support `all()`, `first()`, `count()`, ordering, and pagination. + +## Query namespaces + +```python +namespaces = ( + namespace.query_maker() + .namespaces() + .filter(name="beamline") + .filter_effective_metadata(environment="production") + .all() +) +``` + +Use `filter_local_metadata()` for values defined directly on a namespace and +`filter_effective_metadata()` for inherited values. + +## Query resource templates + +Filter labels and type names, include child templates, and opt into archived +templates when needed. + +```python +templates = ( + namespace.query_maker() + .resource_templates() + .filter_label("sample") + .filter_by_types(["sample", "container"]) + .include_children() + .include_archived() + .all() +) +``` + +Other relationship helpers are `include_attribute_groups()` and +`include_types()`. Use `include([...])` for explicit relationship paths. + +## Query process templates + +```python +process_templates = ( + namespace.query_maker() + .process_templates() + .filter_label("measurement") + .include_step_templates() + .include_resource_slots() + .include_archived() + .all() +) +``` + +## Use canonical models for identity-only results + +Template queries return canonical full models. Use `load="none"` for identity +and scalar fields, then add targeted includes when relationships are needed. +`shape="ref"` is deprecated compatibility syntax and normalizes to +`shape="full"`; use `load="none"` for unloaded relationships. See [Choose query shape and loading](choose-query-shape-and-loading.qmd). diff --git a/docs/how-to/quick-start-create-and-store-data-locally.qmd b/docs/how-to/quick-start-create-and-store-data-locally.qmd new file mode 100644 index 0000000..77d6f2c --- /dev/null +++ b/docs/how-to/quick-start-create-and-store-data-locally.qmd @@ -0,0 +1,75 @@ +--- +title: "Quick Start: Create and Store Data Locally" +--- + +# Quick Start: Create and Store Data Locally + +This procedure creates a namespace, a resource template, a resource, and a +process template in a local SQLite database. Builders validate nested models +and persist an aggregate when their context manager exits successfully. + +## Create a client and namespace + +```python +from recap.client import RecapClient + +with RecapClient.from_sqlite("experiment.db") as client: + client.create_namespace("beamline") + client.create_namespace("beamline/amx", metadata={"beamline": "amx"}) + namespace = client.namespace("beamline/amx") +``` + +`from_sqlite()` creates or upgrades the database. A namespace path scopes +queries and writes; it also allows the same template or resource name to exist +in separate namespaces. + +## Define a resource template + +```python +with namespace.build_resource_template( + name="Sample Plate", + type_names=["container", "plate"], +) as template: + template.add_properties({ + "dimensions": [ + {"name": "rows", "type": "int", "default": 8}, + {"name": "columns", "type": "int", "default": 12}, + ] + }) +``` + +Resource templates define the shape of resources, including property groups, +typed attributes, defaults, and child templates. + +## Create a resource + +```python +with namespace.build_resource( + name="Sample Plate 001", + template_name="Sample Plate", +) as builder: + pass +resource = builder.resource +``` + +Resources receive identity, namespace ownership, metadata, and lifecycle state. +The returned schema is a local model; subsequent database changes still go +through a client or builder. + +## Query the result + +```python +plate = ( + namespace.query_maker() + .resources() + .filter(name="Sample Plate 001") + .first() +) + +assert plate is not None +``` + +Continue with [Query resources](query-resources.qmd) for filtering, ordering, +pagination, and relationship loading. To run the same workflow through a +server, [set up a RECAP server](setup-recap-server.qmd) and replace +`RecapClient.from_sqlite()` with `RecapClient.from_url()`. diff --git a/docs/how-to/resource-template.qmd b/docs/how-to/resource-template.qmd new file mode 100644 index 0000000..9c2116a --- /dev/null +++ b/docs/how-to/resource-template.qmd @@ -0,0 +1,95 @@ +--- +title: Create a Resource Template +--- + +# Create a Resource Template + +Use a resource template to define the shape of trackable entities. Templates +can include typed properties, defaults, property groups, and child templates. + +```python +with namespace.build_resource_template( + name="Detector File", + type_names=["file", "detector-data"], +) as builder: + builder.add_properties({ + "file": [ + {"name": "path", "type": "str", "default": ""}, + {"name": "sha256", "type": "str", "default": ""}, + ] + }) +``` + +## Define typed property groups + +Group related properties so resource values have a stable, readable shape. The +mapping form is useful when defining several groups at once: + +```python +builder.add_properties({ + "file": [ + {"name": "path", "type": "str", "default": ""}, + {"name": "size", "type": "int", "default": 0, "unit": "byte"}, + ], + "provenance": [ + {"name": "instrument", "type": "str", "default": ""}, + ], +}) +``` + +For incremental definitions, open a group and close it when finished: + +```python +properties = builder.prop_group("checksum") +properties.add_attribute("sha256", "str", "", "") +properties.close_group() +``` + +## Set defaults, units, and metadata + +Defaults fill omitted values. Units document how numeric values are interpreted; +metadata carries validation hints and other property information: + +```python +builder.add_properties({ + "measurement": [ + { + "name": "temperature", + "type": "float", + "default": 20.0, + "unit": "degC", + "metadata": {"min": -273.15, "max": 500.0}, + }, + ], +}) +``` + +Property metadata validates resource values against constraints such as numeric +bounds or enum choices. It does not replace the declared property type. + +## Constrain values + +Use `min` and `max` metadata for numeric properties, or `choices` for an enum: + +```python +builder.add_properties({ + "state": [ + { + "name": "mode", + "type": "enum", + "default": "idle", + "metadata": {"choices": ["idle", "running", "failed"]}, + }, + ], +}) +``` + +Choose constraints that describe every valid resource instance. Invalid values +are rejected when resource properties are validated. + +## Choose template child structure + +Use `add_child` for hierarchy known when defining the template. Child templates +are owned by the parent template and create predictable structure; runtime +children are added later to one resource instance. See [Nest resource +templates](nested-resource-templates.qmd) for multi-level examples. diff --git a/docs/how-to/setup-recap-server.qmd b/docs/how-to/setup-recap-server.qmd new file mode 100644 index 0000000..dd285bb --- /dev/null +++ b/docs/how-to/setup-recap-server.qmd @@ -0,0 +1,209 @@ +--- +title: Set Up a RECAP Server +--- + +# Set Up a RECAP Server + +RECAP can run behind a local or remote server. In both cases, the server owns +the database and clients connect with `RecapClient.from_url()`. Authenticated +REST query endpoints serve reads and counts; REST command endpoints handle +creates, updates, and copies. + +This guide documents the currently runnable single-user API-key deployment. +Multi-user authorization configuration is described separately as an +implementation gap because its server startup wiring is not complete. + +## Install the server + +Install the server extra on the host that owns the RECAP database: + +```bash +pip install "pyrecap[server]" +``` + +The server extra includes Uvicorn, FastAPI integration, PostgreSQL support, and +server configuration dependencies. + +## Configure a single-user server + +Create a configuration file on the server. Keep the API key in a secret +manager or provision the file with restricted permissions; do not commit the +key to source control. + +```yaml +server: + db_path: /data/recap/recap.db + host: 127.0.0.1 + port: 8000 + authentication_mode: single-user + api_key: replace-with-secret +``` + +Exactly one of `db_path` and `database_uri` is required. Use `database_uri` +for a non-file database such as PostgreSQL. Single-user mode requires a +non-empty `api_key`. + +For a local server, start directly against SQLite: + +```bash +pip install "pyrecap[server]" +recap-server --db /path/to/recap.db --port 8000 +``` + +The direct `--db` form uses server configuration defaults. Configure its API +key through the supported server configuration or environment settings rather +than placing credentials in command arguments. + +For a configured deployment, start with: + +```bash +recap-server --config /etc/recap/recap-server.yaml +``` + +## Run a remote server + +For a remote deployment, install the server extra on the host that owns the +database and run `recap-server` under your chosen hosting platform. A typical +provider-neutral boundary is: + +```text +Client -> HTTPS endpoint or reverse proxy -> recap-server -> database +``` + +The deployment platform is intentionally outside RECAP's scope. Configure its +process manager, network access, TLS termination, backups, and secrets using +your organization's standards. + +```text +[Deployment-specific: add reverse-proxy and TLS configuration.] +[Deployment-specific: add process-manager configuration and restart policy.] +[Deployment-specific: add database backup and restore procedure.] +[Deployment-specific: add secret provisioning and rotation procedure.] +``` + +## Connect a remote client + +Connect to the reachable base URL from Python: + +```python +from recap.client import RecapClient + +client = RecapClient.from_url( + "http://localhost:8000", + api_key="your-api-key", +) +namespace = client.namespace("beamline/amx") +``` + +Once connected, follow [Quick Start: Create and Store Data Locally](quick-start-create-and-store-data-locally.qmd). +Use `RecapClient.from_url()` in place of `RecapClient.from_sqlite()`; the +namespace, builders, queries, and provenance workflow are the same. + +Remote clients use authenticated `POST /api/v1/query` for reads and +`POST /api/v1/query/count` for counts. Creates, updates, and resource copies use +REST command endpoints. Applications do not need direct access to the server's +database filesystem. +Query payloads and results are ordinary JSON envelopes; local and remote +QueryDSL behavior remains aligned. + +## Authentication and authorization + +The current runnable server authentication mode uses one configured API key. +The client sends this exact header on REST requests: + +```text +Authorization: Apikey your-api-key +``` + +Missing, malformed, or invalid credentials return `401` before query or +command execution. Credentials are compared using constant-time comparison +and are redacted from client representations and connection errors. + +The single-user actor currently receives all defined scopes and has no namespace +restriction. The runnable server therefore permits this actor to access every +namespace. Namespace-specific grants, ancestor visibility, and cross-namespace +authorization rules require multi-user server startup wiring, which is not yet +available. + +Inspect effective permissions from a client: + +```python +permissions = client.namespace("beamline/amx").permissions() +print(permissions.effective_scopes) +print(permissions.matched_namespace_paths) +``` + +See [Authentication and Authorization](../explanation/authentication-authorization.qmd) +for policy evaluation details and +[REST Query and Command Architecture](../explanation/rest-query-architecture.qmd) +for transport behavior. + +### Multi-user authorization status + +The authorization compiler can convert a YAML source file into an immutable +SQLite entitlement snapshot: + +```bash +recap-authz-compile authorization.yml entitlement-snapshot.db +``` + +The source format binds provider identities to groups, roles, scopes, and +namespace paths: + +```yaml +source_revision: revision-42 + +roles: + reader: + scopes: + - namespace:read + - resource:read + +groups: + scientists: + identities: + - provider: oidc + subject: user@example.org + +namespaces: + beamline/amx: + groups: + - name: scientists + role: reader +``` + +```text +[Code gap: ServerConfig accepts multi-user snapshot settings, but +recap-server currently does not pass authentication_mode, +entitlement_snapshot_path, or entitlement_snapshot_max_age_seconds into +create_app(). Do not use multi-user mode in production until server wiring and +end-to-end startup tests exist.] +``` + +Snapshot loading is fail-closed when the snapshot is missing, stale, corrupt, +has an unsupported format, or fails its integrity check. + +## Verify and troubleshoot + +Send an authenticated REST query after starting the server: + +```bash +curl https://recap.example.org/api/v1/query \ + -H 'Authorization: Apikey your-api-key' \ + -H 'Content-Type: application/json' \ + --data '{"entity":"namespace","projection":"full","namespace_path":"beamline/amx","spec":{}}' +``` + +Use responses as follows: + +- `200`: request authenticated and query completed. +- `401`: credential is missing, malformed, or invalid. +- `403`: request is rejected by an authorization policy in a future multi-user + deployment. +- `404`: requested resource or namespace does not exist; a future multi-user + policy may also conceal it. +- `503`: authorization snapshot is unavailable in a future multi-user deployment. + +Use the `X-Request-ID` response header when reporting server-side failures. + +For client credential details, see [Authenticate a Remote Client](use-remote-authentication.qmd). diff --git a/docs/how-to/update-and-retire-templates.qmd b/docs/how-to/update-and-retire-templates.qmd new file mode 100644 index 0000000..4f070a5 --- /dev/null +++ b/docs/how-to/update-and-retire-templates.qmd @@ -0,0 +1,79 @@ +--- +title: Update and Retire Templates +--- + +# Update and Retire Templates + +Templates are identified by namespace, name, and version. Load an existing +template by ID when an update or lifecycle operation must target one exact +definition. + +## Load a template by ID + +Pass `resource_template_id` or `process_template_id` to the corresponding +builder: + +```python +with namespace.build_resource_template( + resource_template_id=resource_template.id, +) as builder: + model = builder.get_model(update=True) +``` + +```python +with namespace.build_process_template( + process_template_id=process_template.id, +) as builder: + model = builder.get_model(update=True) +``` + +`update=True` reloads current backend state before reading the model. Save only +changes that are safe for that existing version. + +## Create a new version + +Do not mutate structure referenced by existing resources or process runs. Create +a new template with the same name and a new version, then define the changed +structure there: + +```python +with namespace.build_resource_template( + name="Detector File", + type_names=["file", "detector-data"], + version="2.0", +) as builder: + builder.add_properties({ + "file": [ + {"name": "path", "type": "str", "default": ""}, + {"name": "sha256", "type": "str", "default": ""}, + {"name": "format", "type": "str", "default": "unknown"}, + ], + }) +``` + +The same rule applies to process templates: bump `version` when changing slots, +directions, steps, or parameters. Template ID, creation and modification +timestamps, and version are protected identity fields and cannot be changed +through the model. + +## Activate a template + +After checking a definition, mark it active: + +```python +with builder: + builder.finalize() +``` + +Activation changes lifecycle status; it does not change template structure. + +## Archive a template + +Archive versions that should no longer be selected for new work: + +```python +builder.archive() +``` + +Archiving preserves historical references and does not delete resources or +process runs that already use the template. diff --git a/docs/how-to/use-remote-authentication.qmd b/docs/how-to/use-remote-authentication.qmd new file mode 100644 index 0000000..1ec5336 --- /dev/null +++ b/docs/how-to/use-remote-authentication.qmd @@ -0,0 +1,57 @@ +--- +title: Authenticate a Remote Client +--- + +# Authenticate a Remote Client + +Create a remote client with the API key configured for the current +single-user server: + +```python +from recap.client import RecapClient + +client = RecapClient.from_url( + "https://recap.example.org", + api_key="your-api-key", +) +``` + +The client sends exactly one `Authorization` header: + +```text +Authorization: Apikey your-api-key +``` + +Keep the key in a secret manager or environment variable rather than source +control. RECAP redacts credentials from client representations and connection +errors. + +This page documents the currently runnable single-user authentication flow. +Multi-user authorization snapshots can be compiled with +`recap-authz-compile`, but `recap-server` does not yet wire the multi-user +configuration into application startup. See [Set Up a RECAP Server](setup-recap-server.qmd) +for the deployment procedure and current implementation gap. + +## Namespace permissions + +Inspect the current actor's effective permissions for a namespace: + +```python +permissions = client.namespace("beamline/amx").permissions() +print(permissions.effective_scopes) +print(permissions.matched_namespace_paths) +``` + +Permissions are computed for the authenticated actor. In the current +single-user server, this reports unrestricted effective access. The client +cannot ask for another actor's permissions; namespace-specific evaluation +requires unavailable multi-user server startup wiring. + +## Authentication failures + +Missing or malformed credentials fail before query or command execution. A +valid credential with insufficient scope is authenticated but denied by the +authorization policy. + +See [Authentication and Authorization](../explanation/authentication-authorization.qmd) +for namespace grants, scope evaluation, and cross-namespace rules. diff --git a/docs/how-to/use-revisions-and-idempotency.qmd b/docs/how-to/use-revisions-and-idempotency.qmd new file mode 100644 index 0000000..11ebc5f --- /dev/null +++ b/docs/how-to/use-revisions-and-idempotency.qmd @@ -0,0 +1,45 @@ +--- +title: Use Revisions and Idempotency +--- + +# Use Revisions and Idempotency + +REST mutations use optimistic concurrency and replay protection. + +## Revision-checked updates + +Successful responses include an `ETag` containing the current revision: + +```text +ETag: "3" +``` + +Send that value back when updating: + +```text +If-Match: "3" +``` + +If another writer has changed the aggregate, the expected revision is stale and +the update conflicts instead of overwriting the newer state. + +## Idempotent creates and copies + +Send an idempotency key with creates, updates, and copies: + +```text +Idempotency-Key: sample-plate-create-001 +``` + +The command fingerprint includes HTTP method, route, namespace, source ID, and +request body. Repeating the same actor/key/command can replay the original +result. Reusing a key with a different command conflicts. + +Authorization is checked again before a replay is returned, so an old successful +result cannot bypass a later permission change. + +## Atomic mutation behavior + +Each command uses one request-scoped database transaction. Domain mutation, +success audit, and idempotency result commit together. Failed mutations roll +back; failure audit uses a separate short transaction. diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 9928e6f..0000000 --- a/docs/index.md +++ /dev/null @@ -1,9 +0,0 @@ -# Recap (Reproducible Experiment Capture and Provenance) - -![recap logo](./img/recap_logo.png) - -A scientific framework for Reproducible Experiment Capture, tracking, and metadata management - -* Free software: 3-clause BSD license - -Quickstart guide [here](./quickstart.md) diff --git a/docs/querying_data.md b/docs/querying_data.md deleted file mode 100644 index 4601c21..0000000 --- a/docs/querying_data.md +++ /dev/null @@ -1,419 +0,0 @@ -# Querying Data - -RECAP exposes a small Query DSL on top of the configured backend (SQLAlchemy or another adapter) so that you can express provenance-oriented queries in a fluent, chainable style. Query objects are immutable; each chain returns a new query with your filters/preloads applied. - -The query builder lives on the client as `client.query_maker(on_unloaded=...)` and exposes type-specific entry points: - -- `campaigns()` -> `CampaignQuery` -- `process_templates(shape="full"|"ref", load="none"|"eager")` -> `ProcessTemplateQuery` -- `process_runs(shape="full"|"ref", load="none"|"eager")` -> `ProcessRunQuery` -- `resources(shape="full"|"ref", load="none"|"eager")` -> `ResourceQuery` -- `resource_templates(shape="full"|"ref", load="none"|"eager")` -> `ResourceTemplateQuery` - -Under the hood, these all use a common `BaseQuery` and a backend-provided `.query(model, spec)` implementation. The `QuerySpec` object carries filters, predicates, ordering, preloads, and pagination options down to the backend. Query objects are immutable: every operation like `filter` or `include` returns a new query instance. - -### Getting a QueryDSL Handle - -Assuming you have a configured client: - -```python -qm = client.query_maker(on_unloaded="warn") - -# Query entry points -campaigns = qm.campaigns() -runs = qm.process_runs() # full models + load="none" -resources = qm.resources() -templates = qm.resource_templates() -process_templates = qm.process_templates() -``` - -> Note: when using `RecapClient`, if a campaign is set via `create_campaign()` or -> `set_campaign()`, resource and process run queries are scoped to that campaign -> by default. You can override per-call by passing `campaign=` to `resources()` / -> `process_runs()`, or leave it unset to query across campaigns. - -`on_unloaded` controls what happens when you access relationship fields that were -not loaded by `include(...)` (or `load="eager"`): - -- `"warn"` (default): emit a warning with include hint. -- `"raise"`: raise an exception immediately. -- `"silent"`: keep old behavior (empty/default container). - -These use custom types: -- warning: `recap.exceptions.UnloadedFieldWarning` -- exception: `recap.exceptions.UnloadedFieldError` - -You can set a default at `query_maker(...)`, and optionally override per query: - -```python -qm = client.query_maker(on_unloaded="raise") -run = qm.process_runs(on_unloaded="warn").filter(name="Run-1").first() -``` - -For process runs, choose the payload shape/load strategy directly: - -```python -qm.process_runs(shape="ref") # lightweight reference models -qm.process_runs(shape="full", load="none") # full schema models without relationships -qm.process_runs(shape="full", load="eager") # full schema models with eager relationships -``` - -`shape="full"` selects existing schema models, while `shape="ref"` selects -reference models. `load="eager"` eagerly loads relationships. - -`include(...)` requires `shape="full", load="none"`. - -> **Migration:** `shape="schema"` and `load="full"` are deprecated aliases for -> `shape="full"` and `load="eager"`. They remain accepted temporarily and emit -> `DeprecationWarning`. - -The same rule applies to `resources`, `process_templates`, and `resource_templates`. - -### Basic Filtering - -The simplest way to filter for equality is `filter(**kwargs)`, which translates -keyword arguments into backend-specific filter expressions. It remains a -convenient shorthand for queries such as `filter(name="Run-1")`. - -List all campaigns with a given proposal id: - -```python -campaigns = ( - client.query_maker() - .campaigns() - .filter(proposal="399999") - .all() -) - -for c in campaigns: - print(c.id, c.name) -``` - -Fetch a single campaign by name (or `None` if not found): - -```python -campaign = ( - client.query_maker() - .campaigns() - .filter(name="Beamline Proposal 4321") - .first() -) - -if campaign is None: - raise RuntimeError("No such campaign") -``` - -Counting results: - -```python -n_runs = ( - client.query_maker() - .process_runs() - .count() -) -print("Total runs:", n_runs) -``` - -### Filtering Resource Templates by Type - -`ResourceTemplateQuery` adds a convenience helper `filter_by_types` for semantic resource types: - -```python -xtal_plate_templates = ( - client.query_maker() - .resource_templates() - .filter_by_types(["xtal_plate"]) - .all() -) - -for tmpl in xtal_plate_templates: - print(tmpl.name, tmpl.types) -``` - -This corresponds directly to the examples in the workflow section where we create templates tagged with types like `["container", "xtal_plate", "plate"]` or `["library_plate"]`. - -### Filtering Resources by Properties - -`ResourceQuery.filter_property` lets you compare against typed property values (int/float/bool/str/datetime inferred from your input). The property group is optional; pass it when you need to disambiguate: - -```python -plates = ( - client.query_maker() - .resources() - .filter_property("rows", gt=100, group="dimensions") - .all() -) -``` - -Scope a property filter to the descendants of a parent resource with `under_parent`: - -```python -child_hits = ( - client.query_maker() - .resources() - .filter_property("height", gt=10) - .under_parent(parent_resource) - .all() -) -``` - -### Filtering Process Runs by Step Parameters - -`ProcessRunQuery.filter_parameter` works like `filter_property` but targets step parameters. You can optionally narrow by step name and parameter group name; otherwise the match applies to any step/group: - -```python -runs = ( - client.query_maker() - .process_runs() - .filter_parameter("dwell", gt=10, group="Exposure", step="Collect") - .all() -) -``` - -### Eager Loading Related Data with `include` - -Queries can preload related entities via the `include` helper. Each include path maps to a backend loader path (for SQLAlchemy this is typically `selectinload`). - -`include` accepts either a single string or a list of dot-path strings: - -```python -runs = ( - client.query_maker() - .process_runs() - .include(["steps", "steps.parameters", "resources"]) - .all() -) -``` - -The type-specific queries also expose convenience methods: - -- `CampaignQuery.include_process_runs()` -- `ProcessRunQuery.include_steps(include_parameters: bool = False)` -- `ProcessRunQuery.include_resources()` -- `ProcessTemplateQuery.include_step_templates()` -- `ProcessTemplateQuery.include_resource_slots()` -- `ResourceQuery.include_template()` -- `ResourceTemplateQuery.include_children()` -- `ResourceTemplateQuery.include_attribute_groups()` -- `ResourceTemplateQuery.include_types()` - -Example: load campaigns and their process runs in one go: - -```python -campaigns = ( - client.query_maker() - .campaigns() - .include_process_runs() - .all() -) - -for c in campaigns: - print("Campaign:", c.name) - for run in c.process_runs: - print(" Run:", run.name) -``` - -#### Example: load runs with steps and parameter groups - -```python -runs = ( - client.query_maker() - .process_runs() - .include_steps(include_parameters=True) - .all() -) - -# Fetch process templates with their steps and resource slots -pt = ( - client.query_maker() - .process_templates() - .filter(name="Workflow-1") - .include_step_templates() - .include_resource_slots() - .first() -) - -# Fetch resource templates with children, attr groups, and types -rt = ( - client.query_maker() - .resource_templates() - .filter(name="Plate") - .include_children() - .include_attribute_groups() - .include_types() - .first() -) - -for run in runs: - print(f"Run: {run.name}") - for step_num, step in enumerate(run.steps): - print(f"\tStep {step_num}: {step.name}") - for pg_num, (param_group_name, param_group) in enumerate(step.parameters.items()): - print(f"\t\tGroup {pg_num}: {param_group_name}") - for param_name, param_value in param_group.items(): - print(f"\t\t\t{param_name} : {param_value}") -``` - -#### Example: load resources with their template - -```python -library_plates = ( - client.query_maker() - .resources() - .filter(types__names_in=["library_plate"]) - .include_template() - .all() -) - -for plate in library_plates: - print("Resource:", plate.name) - print(" Template:", plate.template.name) -``` - -### Provenance Queries - -#### "Which campaigns touched this sample?" - -```python -sample = ( - client.query_maker() - .resources() - .filter(name="Sample 42") - .first() -) - -if sample is None: - raise RuntimeError("Sample not found") - -runs = ( - client.query_maker() - .process_runs() - .filter(resources__id=sample.id) - .include_steps() - .all() -) - -campaign_ids = {run.campaign_id for run in runs} -campaigns = ( - client.query_maker() - .campaigns() - .filter(id__in=list(campaign_ids)) - .all() -) - -for c in campaigns: - print("Campaign:", c.name) -``` - -#### "Show me a full tree for a campaign" - -```python -campaign = ( - client.query_maker() - .campaigns() - .filter(name="Buffer Prep") - .include_process_runs() - .first() -) - -if campaign is None: - raise RuntimeError("No such campaign") - -runs = ( - client.query_maker() - .process_runs() - .filter(campaign_id=campaign.id) - .include_steps(include_parameters=True) - .include_resources() - .all() -) - -for run in runs: - print("Run:", run.name) - print(" Resources:") - for assignment in run.resources: - print(" -", assignment.resource.name, f"({assignment.role})") - - print(" Steps:") - for step in run.steps: - print(" -", step.name) - for group in step.parameters: - for attr in group.values: - print(f" {group.group_name}.{attr.name} = {attr.value}") -``` - -### Field Predicates, Ordering, and Pagination - -All query types expose generic helpers: - -- `where(*predicates)` -- `order_by(*orderings)` -- `limit(value)` -- `offset(value)` - -Use `Field` to build backend-independent predicates and orderings. Structured -`Field` expressions work through both local SQLite and remote GraphQL clients: - -```python -from recap.dsl.query import Field - -runs = ( - client.query_maker() - .process_runs() - .where(Field("name").starts_with("Run-batch")) - .where(Field("campaign.proposal") == "2026-1") - .order_by(Field("create_date").desc()) - .all() -) -``` - -Supported predicates are: - -- comparisons: `==`, `!=`, `>`, `>=`, `<`, and `<=` -- membership: `.in_(values)` and `.not_in(values)` -- strings: `.contains(value)`, `.starts_with(value)`, and `.ends_with(value)` - -Pass `Field("name")` to `order_by()` for ascending order, or use `.asc()` and -`.desc()` explicitly. Multiple predicates passed to one `where()` call, and -predicates added by chained `where()` calls, are combined with `AND`. Python -`and`/`or` composition is unsupported; chain `where()` calls instead. Boolean -`OR`, `NOT`, and predicate grouping are not supported. - -Field paths use dot notation and may traverse ORM relationships to arbitrary -depth, for example `Field("campaign.proposal")` or -`Field("template.parent.name")`. String operations match their argument -literally, so `%` and `_` are not SQL wildcard characters. Regex and SQL -`LIKE` pattern syntax are not supported. - -Raw SQLAlchemy predicates and orderings are deprecated. They remain available -for local queries during migration, emit `DeprecationWarning`, and cannot be -sent to a remote backend. Remote clients reject them before making an HTTP -request. Migrate local expressions as follows: - -```python -from recap.db import ProcessRun -from recap.dsl.query import Field - -# Deprecated, local only -legacy = client.query_maker().process_runs().where(ProcessRun.name == "Run-1") - -# Preferred, local and remote -portable = client.query_maker().process_runs().where(Field("name") == "Run-1") -``` - -Example: fetch the 10 most recent runs: - -```python -from recap.dsl.query import Field - -recent_runs = ( - client.query_maker() - .process_runs() - .order_by(Field("create_date").desc()) - .limit(10) - .all() -) - -for run in recent_runs: - print(run.create_date, run.name) -``` diff --git a/docs/quickstart.md b/docs/quickstart.md deleted file mode 100644 index ba22f8a..0000000 --- a/docs/quickstart.md +++ /dev/null @@ -1,317 +0,0 @@ - -# RECAP API Quickstart — End‑to‑End Example - -**Audience:** Experimental scientists and engineers who want to define, run, and track repeatable lab or data‑processing workflows using RECAP. - -**Goal:** Start with zero context and walk through creating: - -1. a **Process Template** (the recipe), -2. **Resource Templates** (the things your process uses or produces), then -3. a **Process Run** (an actual instance with parameters filled in). - -This document explains each step and annotates a complete, runnable example. - ---- - -## Core Concepts (what maps to your lab work) - -* **ProcessTemplate**: A reusable recipe for an experiment or operation (e.g., "Liquid transfer, then heat"). -* **StepTemplate**: One step in that recipe (e.g., `Transfer`, `Heat plate`). Steps can define **parameter groups** (like `volume_transfer` or `heat_to`) containing attributes such as `volume` or `temperature`. -* **Resource Types & Resource Templates**: Describe allowable inputs/outputs (e.g., a *96‑well plate* with rows/columns and per‑well attributes). Resources are typed (e.g., `container`, `plate`, `operator`) to control compatibility. -* **Resource Slots**: Named inputs/outputs required by a ProcessTemplate (e.g., `Input plate 1` of type `container`). In a run, you **assign** actual resources to these slots. -* **ProcessRun**: A concrete execution of a ProcessTemplate. You assign resources to slots and fill in step parameters. - -### Deep dive: Resource Slots — why they exist - -Resource slots define the **interface** between a process template and the real world. They’re required for: - -* **Decoupling recipe from inventory (late binding).** Templates stay reusable because they don’t hard‑code specific plates/files/instruments. At run time, you *assign* whatever concrete resource matches the slot’s type (e.g., any `container` with a compatible template). -* **Type‑safe compatibility.** Each slot declares allowed **resource type tags** (e.g., `container`, `operator`, `file`). Assignment is validated so a `pipette` can’t be plugged into a `plate` slot. This prevents subtle lab/runtime errors. -* **Role binding inside steps.** Steps refer to resources by **role** (`source`, `dest`, `operator`) rather than instance names. A slot is bound to a role (via `.bind(...)`), so your step logic is stable while concrete resources vary per run. -* **Provenance & audit.** The run records exactly *which* resource filled each slot (e.g., which 96‑well plate and which operator). This makes results reproducible and traceable. -* **Portability and versioning.** Slots act like a stable **contract** for the template. You can upgrade internals while preserving slot names/types so downstream tooling and campaigns don’t break. -* **Workflow composition.** Slots make **I/O explicit**. Output slots can produce resources that feed into downstream processes in a **Campaign**, giving you a clear DAG of resources and processes. -* **Automation & scheduling.** Robots/LIMS can reserve and stage resources to satisfy slots before execution (e.g., ensure the specified `operator` and correct `container` are available). - -**Common patterns** - -* **Input vs Output:** Use `Direction.input` for required inputs; mirror with `Direction.output` for produced artifacts. -* **Single vs multiple:** A template can model one‑to‑one or one‑to‑many by defining distinct slots (e.g., `Input plate 1`, `Input plate 2`) or a collection pattern if supported. -* **Human‑readable names:** Make slot names descriptive (`Destination plate`, `Transfer operator`). These appear in UIs and logs. - -**Anti‑patterns** - -* Binding steps directly to concrete resource *names* instead of slots. This couples a template to a specific inventory item and kills reuse. - -**Mini example (with an output)** - -```python -with client.process_template("QC Measure", "1.0.0") as ed: - ( - ed.add_resource_slot("Sample plate", "container", Direction.input) - .add_resource_slot("Detector", "instrument", Direction.input) - .add_resource_slot("QC report", "file", Direction.output) - .add_step("Measure").bind("Sample plate", "target").bind("Detector", "reader") - .param_group("acquisition").add_attribute("exposure", "float", "s", 0.5) - .close_group().close_step() - ) -``` - -> **Mental model:** **Template** = blueprint, **Run** = a specific experiment. **Resource Template** = template for a physical/virtual thing, **Resource** = the actual thing. - ---- - -## Prerequisites - -* Python 3.10+ -* SQLAlchemy 2.0+ -* Pydantic 2.0+ -* A configured database session (`db_session`) and a working RECAP install. - ---- - -## End‑to‑End Example (annotated) - -### 1) Create a Process Template - -We’ll define a simple two‑step process: - -1. Transfer liquid from one plate to another using an operator. -2. Heat the destination plate. - -```python -from itertools import product - -from recap.client.base_client import RecapClient -from recap.utils.general import Direction - -client = RecapClient(session=db_session) - -# Create or update a Process Template named "Test" version "0.0.1". -with client.process_template("Test", "0.0.1") as ed: - # Define required input resource slots for the process (typed and named). - ( - ed.add_resource_slot( - "Input plate 1", # a human‑readable slot name - "container", # required resource type - Direction.input, # input vs. output - create_resource_type=True # auto‑create the resource type tag if missing - ) - .add_resource_slot("Input plate 2", "container", Direction.input) - .add_resource_slot( - "Liquid transfer operator", "operator", Direction.input, - create_resource_type=True - ) - - # Step 1: Transfer - .add_step("Transfer") - .bind("Input plate 1", "source") # role binding inside the step - .bind("Input plate 2", "dest") - .bind("Liquid transfer operator", "operator") - .param_group("volume transfer") # group logical parameters - .add_attribute( - attr_name="volume", value_type="float", unit="uL", default=0.0 - ) - .add_attribute( - attr_name="rate", value_type="float", unit="uL/sec", default=0.0 - ) - .close_group() - .close_step() - - # Step 2: Heat - .add_step("Heat plate") - .bind("Input plate 2", "target") - .param_group("heat to") - .add_attribute("temperature", "float", "degC", "0.0") - .close_group() - .close_step() - ) -``` - -**What happened here?** - -* You created a process blueprint `Test:0.0.1` with three **input slots** and two **steps**. -* Each step binds the slots to roles that the step expects (e.g., `source`, `dest`, `operator`). -* Each step defines a **parameter group** with typed attributes and optional defaults. - -> Choose stable **template names** and **versions**. Changing versions lets you evolve protocols while preserving historical runs. - ---- - -### 2) Create Resource Templates - -We’ll make two resource templates: a **96‑well plate** (with per‑well metadata) and a simpler **sample holder**. - -```python -# 96‑well plate template: global properties + per‑well children -with client.resource_template("96 well plate", ["container", "plate"]) as rt: - rt.prop_group("dimensions") \ - .add_attribute("rows", "float", "", 8) \ - .add_attribute("columns", "float", "", 12) - - well_cols = "ABCDEFGH" - well_rows = [i for i in range(1, 13)] - well_names = [f"{wn[0]}{wn[1]}" for wn in product(well_cols, well_rows)] - - for well_name in well_names: - ( - rt.add_child(well_name, ["container", "well"]) # define a child resource - .prop_group(group_name="well_data") - .add_attribute("sample_name", "str", "", "") - .add_attribute("buffer_name", "str", "", "") - .add_attribute("volume", "int", "uL", "0") - .add_attribute("mixing", "str", "", "") - .add_attribute("stock", "bool", "", "False") - .add_attribute("notes", "str", "", "") - .close_group() - .close_child() - ) - -# Sample holder template: 2×9 with simpler per‑well metadata -with client.resource_template("sample holder", ["container", "plate"]) as rt: - rt.prop_group("dimensions") \ - .add_attribute("rows", "int", "", 2) \ - .add_attribute("columns", "int", "", 9) - - for well_num in range(1, 19): - ( - rt.add_child(str(well_num), ["container", "well"]) \ - .prop_group("sample_holder_well_data") \ - .add_attribute("sample_name", "str", "", "") \ - .add_attribute("buffer_name", "str", "", "") \ - .add_attribute("volume", "float", "uL", "0") \ - .close_group() \ - .close_child() - ) -``` - -**What happened here?** - -* You registered two **resource templates** with types (`container`, `plate`, `well`). -* The 96‑well plate defines **children** for each well and groups per‑well metadata under `well_data`. -* These templates control what attributes exist when you later create actual resources in a run. - -> Use **tags** (like `container`, `well`, `operator`) consistently. Your process steps will bind to these resource types, preventing invalid assignments. - ---- - -### 3) Instantiate a Process Run and Fill Parameters - -Now create an actual run from the `Test:0.0.1` template, instantiate resources, assign them to the process slots, and set step parameters. - -```python -with client.process_run(name="test_run", template_name="Test", version="0.0.1") as run: - # Create actual resources from templates (these become assignable to slots) - run.create_resource("96 well plate", "96 well plate") - run.create_resource("Test destination plate", "sample holder") - - # Assign resources to the process’s declared input slots - run.assign_resource("Input plate 1", resource_name="96 well plate") \ - .assign_resource("Input plate 2", resource_name="Test destination plate") - - # Read, edit, and persist step parameters - transfer_params = run.get_params("Transfer") # returns a typed object - transfer_params.volume_transfer.volume = 50 - transfer_params.volume_transfer.rate = 1 - print(transfer_params) # inspect before saving - run.set_params(transfer_params) # write back to the run - - heat_params = run.get_params("Heat plate") - heat_params.heat_to.temperature = 100 - run.set_params(heat_params) -``` - -### About parameters: typed Pydantic models & validation - -`get_params(step_name)` returns a **Pydantic model** that mirrors the template’s parameter groups and attributes. You can inspect its schema and fill fields with proper Python types. Calling `set_params(model)` will **validate** and persist the data for that step. - -```python -from pydantic import ValidationError - -# Inspect the model returned by get_params -params = run.get_params("Transfer") -print(params.model_dump()) # current values (defaults + any edits) -print(params.model_json_schema()) # full JSON schema (types, required, etc.) - -# Fill with correct types -params.volume_transfer.volume = 50.0 # float -params.volume_transfer.rate = 1.0 # float - -# Persist with validation -try: - run.set_params(params) -except ValidationError as e: - # If types/constraints don't match the template, you'll see a detailed error - print("Parameter validation failed:\n", e) -``` - -**Key points** - -* The parameter object is **strongly typed** (via Pydantic) per your template. -* `set_params(...)` runs **validation**; if values don’t conform (e.g., wrong type/unit/required missing), a `ValidationError` is raised. -* Use `.model_dump()` for current state and `.model_json_schema()` to programmatically discover structure and constraints. - -**What happened here?** - -* `process_run(...)` created a **ProcessRun** linked to your template. -* `create_resource(name, template)` materialized actual resources from your **resource templates**. -* `assign_resource(slot, resource_name=...)` bound those resources to the template’s input **slots**. -* `get_params(step)` returned a typed parameter model for that step; you edited values and wrote them back with `set_params(...)`. -* Exiting the `with` block commits the run and all related objects to the database. - -> If you see validation errors, check that your **value types** (`int`, `float`, `str`, `bool`) and **units** match what the template expects, and that required assignments (slots) are complete. - ---- - -## How This Relates to RECAP’s Data Flow - -``` -YAML / JSON (optional inputs) - ↓ -Pydantic Schemas (validation) - ↓ -RECAP DSL Builders (your code above) - ↓ -SQLAlchemy ORM Models - ↓ -Database (auditable templates & runs) -``` - -* You can feed RECAP with YAML/JSON to parameterize templates and runs; Pydantic validates shapes and types. -* The builder DSL (what you used) gives a readable, chainable Python interface for scientists. - ---- - -## Troubleshooting & FAQs - -**Q: My resource won’t assign to a slot. Why?** -A: Check the slot’s required **type** (e.g., `container`) and your resource’s tags. They must be compatible. - -**Q: `get_params` returns a model, but `set_params` fails.** -A: Ensure you didn’t remove required groups/attributes, and that values conform to the declared `value_type`. - -**Q: How do I version changes safely?** -A: Bump the **template version** (e.g., `0.0.2`) when you change step structure, slots, or parameter schemas. Old runs keep their original version. - -**Q: Where is commit handled?** -A: The context managers (`with ...`) manage lifecycle and commit on successful exit. Exceptions inside the block will roll back. - ---- - -## Best Practices - -* **Name things for humans.** Slots like `Input plate 1` are clearer than `ip1`. -* **Group parameters logically.** Keep related attributes together (`volume_transfer`, `heat_to`). -* **Prefer templates first.** Define resource and process templates before runs to maximize reuse. -* **Tag consistently.** Use a stable set of resource type tags (`container`, `well`, `operator`, etc.). -* **Use defaults thoughtfully.** Defaults enable quick prototyping; record actual values in runs for provenance. - ---- - -## Next Steps - -* Add outputs by declaring **output** slots in your ProcessTemplate (mirrors `Direction.input`). -* Chain processes into a **Campaign** to capture multi‑step experimental programs. -* Drive runs from **YAML/JSON** to reduce boilerplate in Python notebooks. - -*You’re ready to build richer templates and automate more of your lab workflow with RECAP.* diff --git a/docs/reference/server-configuration.qmd b/docs/reference/server-configuration.qmd new file mode 100644 index 0000000..36acd17 --- /dev/null +++ b/docs/reference/server-configuration.qmd @@ -0,0 +1,62 @@ +--- +title: Server Configuration +--- + +# Server Configuration + +`ServerConfig` reads direct keyword arguments and environment variables with +the `RECAP_` prefix when constructed directly. `ServerConfig.from_yaml(path)` +reads values below top-level `server:`. `recap-server` applies explicit +`--host`, `--port`, and `--log-level` flags after loading its configuration. + +## Fields + +| Field | Default | Meaning | +| --- | --- | --- | +| `db_path` | `None` | SQLite database path | +| `database_uri` | `None` | Non-file database URI, stored as secret | +| `host` | `127.0.0.1` | Bind host | +| `port` | `8000` | Bind port | +| `log_level` | `info` | Logging level | +| `authentication_mode` | `single-user` | `single-user` or `multi-user` | +| `api_key` | `None` | Single-user API key, stored as secret | +| `entitlement_snapshot_path` | `None` | Multi-user entitlement snapshot | +| `entitlement_snapshot_max_age_seconds` | `300` | Positive snapshot age limit | +| `audit_log_path` | `recap-audit.jsonl` | Audit JSONL output path | + +`database_url` returns `sqlite:///` for file configuration or the +secret database URI. + +## Validation + +Exactly one of `db_path` and `database_uri` is required. `single-user` requires +non-empty `api_key`; `multi-user` requires `entitlement_snapshot_path`. +`entitlement_snapshot_max_age_seconds` must be positive. Path fields accept +strings and are converted to `Path`. Invalid configuration raises Pydantic +validation errors or `ValueError` from the model validator. + +## YAML + +```yaml +server: + db_path: /data/recap.db + host: 0.0.0.0 + port: 8000 + authentication_mode: single-user + api_key: secret +``` + +Environment variables include `RECAP_DB_PATH`, `RECAP_HOST`, `RECAP_PORT`, +`RECAP_AUTHENTICATION_MODE`, and `RECAP_API_KEY` when `ServerConfig` is +constructed directly. `ServerConfig.from_yaml()` supplies YAML values as +constructor arguments, so YAML overrides environment variables; explicit CLI +flags override loaded configuration. + +`audit_log_path` is currently a configuration field, but the server application +does not yet connect it to a durable audit sink. + +`authentication_mode: multi-user` and +`entitlement_snapshot_path` are validated by `ServerConfig`, but the current +`recap-server` startup path does not pass those settings into the application. +Multi-user deployment therefore remains unavailable until that wiring and +end-to-end startup coverage are implemented. diff --git a/docs/resource_examples.md b/docs/resource_examples.md deleted file mode 100644 index d0c71a1..0000000 --- a/docs/resource_examples.md +++ /dev/null @@ -1,109 +0,0 @@ -## Examples of resource templates - -Besides plates and containers, users can also create resource templates that represent files, robots and even people. - -### NeXus Hdf5 files - -Capture beamline data products with file- and detector-level metadata, plus a nested entry describing the stored dataset. - -```python -with client.build_resource_template(name="NeXus File", - type_names=["file", "hdf5", "nexus"]) as template_builder: - template_builder.add_properties({ - "file_metadata": [ - {"name": "path", "type": "str", "default": ""}, - {"name": "sha256", "type": "str", "default": ""}, - {"name": "created", "type": "datetime", "default": None}, - ], - "scan": [ - {"name": "instrument", "type": "str", "default": ""}, - {"name": "beamline", "type": "str", "default": ""}, - {"name": "exposure_time", "type": "float", "default": 0.1, "unit": "s"}, - ], - }) - - (template_builder.add_child("entry1", ["group", "nexus_entry"]) - .add_properties({ - "detector": [ - {"name": "distance_mm", "type": "float", "default": 0.0, "unit": "mm"}, - {"name": "pixel_size_um", "type": "float", "default": 75.0, "unit": "um"}, - {"name": "wavelength", "type": "float", "default": 1.0, "unit": "angstrom"}, - ], - "data": [ - {"name": "shape", "type": "array", "default": [0, 0]}, - {"name": "compression", "type": "enum", "default": "lz4", - "metadata": {"choices": ["lz4", "gzip", "bslz4"]}}, - ], - }) - .close_child()) -``` - -### UR5 Robot - -Model the arm, its controller settings and a pluggable end-effector. - -```python -with client.build_resource_template(name="UR5 Robot", - type_names=["instrument", "robot", "ur5"]) as template_builder: - template_builder.add_properties({ - "specs": [ - {"name": "reach_mm", "type": "float", "default": 850.0, "unit": "mm"}, - {"name": "payload_kg", "type": "float", "default": 5.0, "unit": "kg"}, - {"name": "tcp_frame", "type": "array", "default": [0, 0, 0, 0, 0, 0]}, - ], - "controller": [ - {"name": "ip_address", "type": "str", "default": "192.168.0.10"}, - {"name": "firmware", "type": "str", "default": ""}, - {"name": "safety_mode", "type": "enum", "default": "normal", - "metadata": {"choices": ["normal", "reduced", "protective_stop"]}}, - ], - }) - - (template_builder.add_child("end_effector", ["instrument", "tool", "gripper"]) - .add_properties({ - "gripper": [ - {"name": "model", "type": "str", "default": ""}, - {"name": "max_width_mm", "type": "float", "default": 85.0, "unit": "mm"}, - {"name": "calibrated", "type": "bool", "default": False}, - ], - "maintenance": [ - {"name": "last_service", "type": "datetime", "default": None}, - {"name": "cycles_since_service", "type": "int", "default": 0}, - ], - }) - .close_child()) -``` - -### Scientist - -Track personnel involved in experiments with training, contact info and linked notebooks. - -```python -with client.build_resource_template(name="Scientist", - type_names=["person", "operator"]) as template_builder: - template_builder.add_properties({ - "identity": [ - {"name": "full_name", "type": "str", "default": ""}, - {"name": "orcid", "type": "str", "default": ""}, - {"name": "affiliation", "type": "str", "default": ""}, - ], - "contact": [ - {"name": "email", "type": "str", "default": ""}, - {"name": "phone", "type": "str", "default": ""}, - ], - "training": [ - {"name": "beamline_certified", "type": "bool", "default": False}, - {"name": "radiation_training_expires", "type": "datetime", "default": None}, - ], - }) - - (template_builder.add_child("notebook", ["record", "lab_notebook"]) - .add_properties({ - "tracking": [ - {"name": "provider", "type": "enum", "default": "eln", - "metadata": {"choices": ["eln", "paper", "lims"]}}, - {"name": "identifier", "type": "str", "default": ""}, - ], - }) - .close_child()) -``` diff --git a/docs/rest-api-architecture.md b/docs/rest-api-architecture.md new file mode 100644 index 0000000..fa8fa04 --- /dev/null +++ b/docs/rest-api-architecture.md @@ -0,0 +1,405 @@ +# REST API Branch Architecture + +## Scope + +This document describes changes made on branch `rest-api` after it diverged from +`main`. + +- Base: `0b9e85f8d640d0e414f89161529e874509cf925b` +- Branch tip: `8e33371` (`Merge rest API implementation`) +- Branch range: `0b9e85f8..8e33371` + +Behavior inherited from `main` is intentionally omitted. Code references point +to the branch-tip implementation. + +## Architectural Summary + +The branch changes RECAP from a locally scoped, Campaign-oriented persistence +model into a namespace-scoped service with authenticated reads and writes. + +The resulting split is: + +- **REST query endpoints** handle remote reads and counts. +- **REST command endpoints** handle remote create, update, and copy commands. +- **CommandService** owns authorization, transaction boundaries, revisions, + idempotency, and mutation outcomes. +- **Namespace policy** determines what an authenticated actor may see or change. +- **Builders** retain the existing fluent API but accumulate drafts and submit + one aggregate command at save time. +- **Query models** are canonical full models; relationship loading is controlled + independently with `load="none"`, targeted includes, or `load="eager"`. + +The main request flows are: + +```text +Read: + API key -> RequestActor -> QueryRequest -> NamespacePolicy + -> authorized REST query -> LocalBackend query/count + +Write: + API key -> REST route -> CommandContext -> CommandService + -> authorization -> idempotency -> one transaction + -> revision/audit -> REST response with ETag +``` + +Query RPC uses ordinary JSON envelopes. `POST /api/v1/query` accepts an entity, +projection, namespace path, and serialized `QuerySpec`; it returns matching +serialized models. `POST /api/v1/query/count` returns a count envelope. Local +and remote clients use the same QueryDSL semantics and authorization ordering. + +## 1. Namespace Domain + +### Namespace replaces Campaign ownership + +The first phase introduces hierarchical namespaces and moves provenance +aggregates under explicit namespace ownership. Namespace paths are canonical, +have parent/ancestor relationships, and carry lifecycle status and revisions. + +Relevant locations: + +- `recap/lifecycle.py:1-21` defines `LifecycleStatus` and allowed transitions. +- `recap/utils/namespace.py:1-38` canonicalizes paths and calculates parents, + ancestors, and ancestry relationships. +- `recap/db/namespace.py:19-92` defines the namespace ORM model and repository. +- `recap/schemas/namespace.py:1-30` defines public namespace schemas and + namespace query context. +- `recap/db/migrations/versions/71c5ce51c034_add_namespaces.py` backfills + namespaces from Campaign data. +- `recap/db/migrations/versions/8f3c2a1b7d90_remove_campaigns.py` removes the + Campaign schema after backfill validation. + +The ownership model becomes explicit: + +- Process templates: `(namespace_id, name, version)`. +- Resource templates: `(namespace_id, name, version)` for roots. +- Process runs: `(namespace_id, name)`. +- Top-level resources: UUID identity; names are not globally unique. + +The ownership and identity changes are implemented in: + +- `recap/db/process.py` +- `recap/db/resource.py` +- `recap/schemas/process.py` +- `recap/schemas/resource.py` +- `recap/adapter/local.py` + +### Namespace query context + +Namespace context travels beside serialized query state rather than inside +`QuerySpec`. This preserves query portability while making visibility explicit +at each backend boundary. + +- `recap/dsl/query.py` carries namespace context on query objects. +- `recap/adapter/transport.py` carries `namespace_path` beside `QuerySpec`. +- `recap/adapter/local.py` applies namespace visibility before user filters, + ordering, pagination, and counts. +- `recap/adapter/rest.py` serializes the namespace context for remote reads. +- `recap/server/rest.py` receives explicit namespace paths for REST query + operations. + +Process runs require exact namespace context. Templates and resources can be +visible through ancestor namespaces subject to authorization. Archived entities +are excluded unless explicitly requested. + +### Lifecycle, freezing, and copy-on-write + +The branch adds aggregate lifecycle and immutability rules: + +- `MUTABLE -> ACTIVE`, `MUTABLE -> ARCHIVED`, and `ACTIVE -> ARCHIVED` are + allowed; transitions are forward-only. +- Templates and resources freeze after first stable reference. +- Nested aggregate mutations are checked through the owning root. +- Frozen resources are copied into another namespace instead of edited in + place. + +Implementation locations: + +- `recap/db/process.py` and `recap/db/resource.py` store lifecycle and revision + state. +- `recap/adapter/local.py` enforces lifecycle operations and deep-copy behavior. +- `recap/db/step.py` and `recap/db/attribute.py` participate in aggregate freeze + validation. +- `recap/dsl/resource_builder.py` exposes copy-on-write behavior. +- `recap/tests/test_resource_freeze.py` and + `recap/tests/test_resource_copy.py` cover freeze and copy semantics. + +Copy creates fresh IDs for the resource graph, preserves source identity through +`copied_from_id` on the root, applies requested property changes, activates the +source when required, and commits source activation plus destination insertion +atomically. + +## 2. Authentication + +Authentication produces an immutable request actor. The actor contains a stable +actor ID, provider identities, credential scopes, optional namespace +restrictions, and a credential fingerprint. + +- `recap/authentication/models.py:1-34` defines `ProviderIdentity`, `ActorKind`, + and frozen `RequestActor` models. +- `recap/authentication/protocols.py:1-15` defines authentication protocols. +- `recap/authentication/api_key.py:13-35` validates API keys and produces the + request actor. +- `recap/server/security.py:22-36` parses the `Authorization: Apikey ...` + header and maps authentication failures to server responses. +- `recap/server/config.py` validates authentication mode and credential config. + +The branch defines separate scopes for namespace, template, resource, and +process-run read/write operations in `recap/authorization/scopes.py`. Write +scope does not implicitly grant read scope. + +Secrets are kept out of representations, errors, and audit records. The +client-side redaction implementation is in `recap/adapter/rest.py:37-50`. + +## 3. Authorization + +Authorization is snapshot-based in multi-user mode. A source configuration maps +provider identities to groups, groups to roles, and roles to scopes at namespace +paths. Compilation happens outside request handling. + +- `recap/authorization/source.py` defines source configuration models. +- `recap/authorization/compiler.py` compiles YAML authorization input into a + validated SQLite snapshot. +- `recap/authorization/snapshot.py` loads immutable snapshot generations and + fails closed for missing, corrupt, or stale snapshots. +- `recap/authorization/policy.py:32-205` evaluates effective permissions and + target relationships. +- `recap/authorization/query.py` provides authorized query representations. + +`SnapshotNamespacePolicy` evaluates: + +1. Canonical namespace path. +2. Actor namespace restrictions. +3. Actor identity matches. +4. Ancestor grants. +5. Intersection with credential scopes. +6. Target relationship rules for the requested entity and operation. + +Relationship rules differ by aggregate: + +- Process-run access requires target and context namespaces to match. +- Template/resource reads may use ancestor visibility. +- Template/resource writes target the context namespace. +- Namespace creation authorizes against the parent namespace. +- Resource copy requires source resource read plus destination resource write. + +`UnrestrictedNamespacePolicy` provides the single-user local/test policy through +the same interface. + +## 4. Request Context, Errors, and Audit + +Request state is immutable and assembled once per request. + +- `recap/server/rest.py` authenticates each query request, acquires one + authorization snapshot generation, and injects policy-aware querying. + +The server also adds stable request IDs and sanitized error handling: + +- `recap/server/errors.py` defines request error mapping and request-ID access. +- `recap/server/error_handlers.py` maps command and authorization failures to + safe HTTP errors. +- `recap/server/audit.py:15-57` defines immutable `AuditRecord` and `AuditSink`. +- `recap/db/audit.py:14-56` persists mutation audit records. +- `recap/commands/audit.py:9-22` emits durable failure records after rollback. + +Audit records capture request, actor, mutation, outcome, and safe reason data. +They do not contain credentials, property values, or process parameter values. + +## 5. Command Architecture + +The command layer is the write-side application boundary. REST routes and local +builder paths both converge on command models and `CommandService` methods. + +### Command models and request scope + +- `recap/commands/models.py:1-89` defines strict immutable command DTOs. +- `recap/commands/models.py` defines `CommandContext` with actor, request ID, + policy, audit sink, authorization generation, and idempotency key. +- `recap/commands/service.py:110-181` dispatches command DTOs to aggregate + handlers. +- `recap/db/engine.py` and `recap/server/dependencies.py` provide a + request-scoped session/backend. + +Each command handler owns one database transaction. The normal sequence is: + +```text +authorize -> calculate fingerprint -> claim/replay idempotency +-> load and validate aggregate -> mutate -> increment revision +-> persist success audit/result -> commit +``` + +Failure rolls back the mutation and emits failure audit through a separate short +transaction. + +### Idempotency and optimistic concurrency + +- `recap/commands/idempotency.py:1-26` creates deterministic fingerprints from + method, route, namespace, source ID, and body. +- `recap/db/idempotency.py` stores unique actor/key claims and replay responses. +- `recap/commands/service.py:193-270` shows the create-resource transaction, + including authorization, fingerprinting, idempotency replay, mutation, and + audit. +- Revision checks use `If-Match` values and return entity revisions as ETags. + +Replays re-check current authorization before returning the saved result. A +changed route, namespace, source ID, or body conflicts with the existing key. + +## 6. REST API + +The REST server is a thin HTTP adapter over `CommandService`. + +- `recap/server/rest.py:39-64` defines the `/api/v1` router and builds + `CommandContext`. +- `recap/server/rest.py:72-79` parses `If-Match` revisions. +- `recap/server/rest.py:81-319` defines namespace, template, resource, copy, and + process-run mutation routes. +- `recap/server/rest_models.py` defines strict request payloads. + +Mutation route families: + +```text +PUT /api/v1/namespaces/{namespace_path} +PATCH /api/v1/namespaces/{namespace_id} + +POST /api/v1/process-templates/{namespace_path} +PATCH /api/v1/process-templates/{template_id} + +POST /api/v1/resource-templates/{namespace_path} +PATCH /api/v1/resource-templates/{template_id} + +POST /api/v1/resources/{namespace_path} +PATCH /api/v1/resources/{resource_id} +POST /api/v1/resources/{source_resource_id}/copies + +POST /api/v1/process-runs/{namespace_path} +PATCH /api/v1/process-runs/{process_run_id} +POST /api/v1/process-runs/{source_process_run_id}/copies + +POST /api/v1/lifecycle/{object_type}/{object_id} +``` + +Creates and copies require `Idempotency-Key`. Entity updates and lifecycle +transitions require both `Idempotency-Key` and `If-Match`. Successful responses +include `ETag` with the new revision. + +Copy requests send destination namespace in the body rather than the URL: + +```json +{ + "destination_namespace": "beamline/amx", + "name": "AMXRobot", + "changes": {"properties": {"tool": "new-gripper"}} +} +``` + +## 7. REST Client and Remote Cutover + +`RESTAdapter` is the authenticated query and command transport: + +- `recap/adapter/rest.py:53-106` owns HTTP, authentication headers, idempotency + headers, ETags, request IDs, typed HTTP errors, and connection errors. +- `recap/adapter/rest.py:108-184` maps query, namespace, create, update, and + copy operations to canonical routes. +- `recap/adapter/rest.py:186-279` maps command DTOs to REST calls and validates + response schemas. + +`RecapClient.namespace()` returns a client view bound to one canonical namespace path: + +- `recap/client/base_client.py` composes REST queries with REST commands. +- `recap/client/permissions.py` exposes typed effective permissions. + +Remote clients no longer need access to the server's SQLite path. Remote reads, +counts, and mutations use authenticated REST endpoints. + +The client identity map canonicalizes models by stable entity family and UUID. +Later fuller loads upgrade existing canonical objects rather than creating a +second instance for the same entity. + +## 8. Builder and Aggregate Submission Model + +Builders preserve the existing fluent construction style without issuing +incremental remote writes. + +- `recap/dsl/drafts.py` defines nested draft payloads. +- `recap/dsl/process_builder.py` accumulates process-template and process-run + drafts and submits aggregate commands. +- `recap/dsl/resource_builder.py` accumulates resource-template and resource + drafts and submits aggregate commands. +- `recap/client/base_client.py` selects the local or REST command executor. + +The important invariant is that a clean builder context flushes its aggregate +draft once. Requested lifecycle transitions are submitted after that aggregate +command. A builder body that raises produces no persistence side effect. The same +command shapes work locally and remotely, which is tested by +`recap/tests/test_remote_builder_parity.py`. + +Resource and process-run loading use bounded, depth-independent SQL statement +budgets to avoid N+1 growth. `query.export(format, destination)` delegates to a +registered exporter; no export serialization format is part of core behavior. + +## 9. Commit History by Milestone + +The branch was implemented in these architectural phases: + +### Namespace domain and visibility + +- `fea45fa` `feat: add namespace lifecycle primitives` +- `a6a3520` `feat: add namespace domain model` +- `9c1a86e` `feat: migrate campaign data to namespaces` +- `d9a1071` `feat: scope provenance entities by namespace` +- `7d8d72a` `feat: query within namespace context` +- `9c701c4` `feat: freeze referenced provenance aggregates` +- `acb7b2c` `feat: copy frozen resources by namespace` +- `2b91a12` `feat: replace campaigns with namespaces` + +### Authentication and authorization + +- `38ad077` `feat: define authentication and scope contracts` +- `a75a757` `feat: authenticate requests with API key` +- `0235dd3` `feat: compile namespace authorization grants` +- `6c7bb3d` `feat: load authorization snapshot generations` +- `0457c00` `feat: authorize namespace operations` +- `f86ab70` `feat: add secure request context and audit` +- `da892a5` `feat: enforce namespace query policy` +- `ae75422` `feat: authenticate namespace REST client` + +### Command and mutation infrastructure + +- `41ab642` `feat: add request-scoped command spine` +- `a5087d8` `feat: add idempotent audited commands` +- `b133c4c` `feat: expose namespace write commands` +- `3ff2713` `feat: write process templates atomically` +- `45d627d` `feat: write resource templates atomically` +- `e2265ca` `feat: add resource copy-on-write API` +- `aba3322` `feat: write process runs through commands` +- `117e25e` `refactor: submit builders as aggregate commands` + +### REST transport and cutover + +- `9617590` `feat: add namespace REST client` +- `52d768c` `feat: route remote writes through REST` +- `8e33371` `Merge rest API implementation` + +Fix and style commits in the same range preserve behavior introduced by these +milestones, including atomicity, metadata, delegation, and lint fixes: +`05754d7`, `4db1e95`, `11414b5`, `96c0b8e`, `16aac38`, and `a7af6da`. + +## 10. Verification Surface + +Branch-added or branch-modified tests cover the architecture at each boundary: + +- Namespace migration, ownership, lifecycle, queries, and copy: + `test_namespace_migration.py`, `test_namespace_ownership.py`, + `test_lifecycle_operations.py`, `test_resource_copy.py`. +- Authentication and authorization: `test_authentication_models.py`, + `test_request_authentication.py`, `test_request_security.py`, + `test_authorization_compiler.py`, `test_authorization_snapshot.py`, + `test_namespace_policy.py`, `test_rest_query.py`. +- Commands and consistency: `test_command_models.py`, `test_command_errors.py`, + `test_idempotency.py`, `test_revisions.py`, `test_mutation_audit.py`, + `test_request_backend_scope.py`. +- REST and client behavior: `test_rest_adapter.py`, `test_rest_namespaces.py`, + `test_rest_process_templates.py`, `test_rest_resource_templates.py`, + `test_rest_resources.py`, `test_rest_process_runs.py`, + `test_namespace_client.py`, `test_remote_builder_parity.py`. + +Run `pixi run -e dev test-all` to execute the current full test suite. diff --git a/docs/tutorials/complete-local-provenance.qmd b/docs/tutorials/complete-local-provenance.qmd new file mode 100644 index 0000000..daf1c6f --- /dev/null +++ b/docs/tutorials/complete-local-provenance.qmd @@ -0,0 +1,120 @@ +--- +title: Complete Local Provenance +--- + +# Complete Local Provenance + +This example records a sample-preparation run and queries its resource hierarchy +and parameters in one local SQLite database. See [quick start](../how-to/quick-start-create-and-store-data-locally.qmd), +[build process runs](../how-to/build-process-runs.qmd), and [trace provenance](../how-to/provenance-queries.qmd) +for focused procedures. + +```python +from recap.client import RecapClient +from recap.utils.general import Direction + + +with RecapClient.from_sqlite("sample-preparation.db") as client: + client.create_namespace("beamline") + client.create_namespace( + "beamline/sample-preparation", + metadata={"experiment": "sample preparation"}, + ) + namespace = client.namespace("beamline/sample-preparation") + + with namespace.build_resource_template( + name="Sample Carrier", + type_names=["container"], + ) as template: + template.add_properties({ + "identity": [ + {"name": "carrier_id", "type": "str", "default": ""}, + ] + }) + + with namespace.build_resource_template( + name="Prepared Sample", + type_names=["sample"], + ) as template: + template.add_properties({ + "identity": [ + {"name": "sample_id", "type": "str", "default": ""}, + ] + }) + + with namespace.build_resource( + name="Carrier 001", + template_name="Sample Carrier", + ) as builder: + builder.set_props({"identity": {"carrier_id": "C-001"}}) + + carrier = ( + namespace.query_maker() + .resources() + .filter(name="Carrier 001") + .first() + ) + assert carrier is not None + + with namespace.build_resource( + name="Sample A", + template_name="Prepared Sample", + parent=carrier, + ) as builder: + builder.set_props({"identity": {"sample_id": "S-001"}}) + + sample = ( + namespace.query_maker() + .resources() + .filter(name="Sample A") + .first() + ) + assert sample is not None + + with namespace.build_process_template("Prepare Sample", "1.0") as template: + template.add_resource_slot("sample", "sample", Direction.input) + ( + template.add_step("Prepare") + .add_parameters({ + "conditions": [ + {"name": "temperature", "type": "float", "default": 20.0, "unit": "degC"}, + {"name": "duration", "type": "float", "default": 10.0, "unit": "min"}, + ] + }) + .bind_slot("source", "sample") + .close_step() + ) + + with namespace.build_process_run( + name="Prepare Sample A", + description="Prepare sample for measurement", + template_name="Prepare Sample", + version="1.0", + ) as run: + run.assign_resource("sample", sample) + params = run.get_params("Prepare") + params.conditions.temperature = 22.5 + params.conditions.duration = 12.0 + run.set_params(params) + run.finalize() + + result = ( + namespace.query_maker() + .process_runs() + .filter(name="Prepare Sample A") + .include_steps(include_parameters=True) + .include_resources() + .first() + ) + assert result is not None + assert result.assigned_resources["sample"].resource.name == "Sample A" + assert result.steps["Prepare"].parameters.conditions.temperature.value == 22.5 + assert result.steps["Prepare"].parameters.conditions.duration.value == 12.0 + + print(result.name) + print(result.assigned_resources["sample"].resource.name) + print(result.steps["Prepare"].parameters.conditions.model_dump()) +``` + +The query loads assigned resources, step parameters, and the parent-child +resource graph from the completed run. diff --git a/docs/tutorials/index.qmd b/docs/tutorials/index.qmd new file mode 100644 index 0000000..ecfcaa5 --- /dev/null +++ b/docs/tutorials/index.qmd @@ -0,0 +1,23 @@ +--- +title: Tutorials +--- + +# Tutorials + +Tutorials provide goal-oriented, step-by-step paths through complete RECAP +workflows. Start with one tutorial and follow it from setup through working +code, then use the [How-To guides](../how-to/index.qmd), +[Explanation](../explanation/index.qmd), and [Reference](/reference/index.html) +for focused procedures, concepts, and exact API details. + +## Domain Models + +- [Resource Examples](resource-examples.qmd): reusable file and instrument resource shapes. See [resource templates](../how-to/resource-template.qmd) and [builder API](/reference/ResourceTemplateBuilder.html). + +## Provenance Workflows + +- [Complete Local Provenance](complete-local-provenance.qmd): sample hierarchy, process run, parameters, and local provenance query. See [quick start](../how-to/quick-start-create-and-store-data-locally.qmd), [build process runs](../how-to/build-process-runs.qmd), and [trace provenance](../how-to/provenance-queries.qmd). + +## Remote Integration + +- [Remote Client](../how-to/setup-recap-server.qmd): connect through `RecapClient.from_url()` using authenticated REST JSON query/read and command transports. See [remote authentication](../how-to/use-remote-authentication.qmd), [client API](/reference/RecapClient.html), and [query API](/reference/QueryDSL.html). diff --git a/docs/tutorials/resource-examples.qmd b/docs/tutorials/resource-examples.qmd new file mode 100644 index 0000000..9d54298 --- /dev/null +++ b/docs/tutorials/resource-examples.qmd @@ -0,0 +1,46 @@ +--- +title: Resource Examples +--- + +# Resource Examples + +Resources can represent physical objects, digital files, instruments, or +people. The template defines the stable shape; each resource stores identity +and values for one instance. + +## NeXus file + +```python +with namespace.build_resource_template( + name="NeXus File", + type_names=["file", "hdf5", "nexus"], +) as template: + template.add_properties({ + "file_metadata": [ + {"name": "path", "type": "str", "default": ""}, + {"name": "sha256", "type": "str", "default": ""}, + ], + "scan": [ + {"name": "beamline", "type": "str", "default": ""}, + {"name": "exposure_time", "type": "float", "default": 0.1, "unit": "s"}, + ], + }) +``` + +## Robot + +```python +with namespace.build_resource_template( + name="UR5 Robot", + type_names=["instrument", "robot", "ur5"], +) as template: + template.add_properties({ + "specs": [ + {"name": "reach_mm", "type": "float", "default": 850.0, "unit": "mm"}, + {"name": "payload_kg", "type": "float", "default": 5.0, "unit": "kg"}, + ] + }) +``` + +Use stable type labels so process-template slots can require compatible +resources without coupling workflows to one concrete resource name. diff --git a/great-docs.yml b/great-docs.yml new file mode 100644 index 0000000..d767708 --- /dev/null +++ b/great-docs.yml @@ -0,0 +1,267 @@ +# Great Docs Configuration +# See https://posit-dev.github.io/great-docs/user-guide/configuration.html + +# Module Name (optional) +# ---------------------- +# Set this if your importable module name differs from the project name. +# Example: project 'py-yaml12' with module name 'yaml12' +module: recap + +# Site Identity +# ------------- +display_name: Recap +repo: https://github.com/nsls2/recap +logo: docs/assets/recap_logo.png +favicon: docs/assets/favicon.ico + +# Docstring Parser +# ---------------- +# The docstring format used in your package (numpy, google, or sphinx) +parser: numpy + +# Dynamic Introspection +# --------------------- +# Use runtime introspection for more accurate documentation (default: true) +# Set to false if your package has cyclic alias issues (e.g., PyO3/Rust bindings) +dynamic: true + +# API Discovery Settings +# ---------------------- +# Exclude items from auto-documentation +# exclude: +# - InternalClass +# - helper_function + +# Logo & Favicon +# --------------- +# Point to a single logo file (replaces the text title in the navbar): +# logo: assets/logo.svg +# +# For light/dark variants: +# logo: +# light: assets/logo-light.svg +# dark: assets/logo-dark.svg +# +# To show the text title alongside the logo, add: show_title: true + +# Author Information +# ------------------ +# Author metadata for display in the landing page sidebar +# You can add additional fields: github, orcid, affiliation, homepage +authors: + - name: Venkateswaran Shekar + role: Author + affiliation: Brookhaven National Lab + email: vshekar1@bnl.gov + # github: + # orcid: + # homepage: + +# Diataxis Content Sections +# ------------------------- + +sections: + - title: Getting Started + dir: docs/getting_started + - title: How-To Guides + dir: docs/how-to + sidebar_groups: + - section: Quick Start + contents: + - quick-start-create-and-store-data-locally.qmd + - setup-recap-server.qmd + - use-remote-authentication.qmd + - section: Create Templates + contents: + - resource-template.qmd + - process-template.qmd + - nested-resource-templates.qmd + - update-and-retire-templates.qmd + - section: Create and Edit Instances + contents: + - build-process-runs.qmd + - add-runtime-children.qmd + - edit-resource-properties.qmd + - manage-resource-lifecycle.qmd + - handle-errors-and-request-ids.qmd + - use-revisions-and-idempotency.qmd + - section: Query Experiments + contents: + - query-resources.qmd + - query-process-runs.qmd + - query-templates-and-namespaces.qmd + - choose-query-shape-and-loading.qmd + - provenance-queries.qmd + - performance.qmd + - title: Explanation + dir: docs/explanation + - title: Tutorials + dir: docs/tutorials + +custom_pages: + - dir: docs/assets + output: docs/assets + + +navbar_order: + - Getting Started + - How-To Guides + - Explanation + - Tutorials + - Reference + - Changelog + +# Funding / Copyright Holder +# -------------------------- +# Credit the organization that funds or holds copyright for this package. +# Displays in sidebar and footer. Homepage and ROR provide links. +# funding: +# name: "Posit Software, PBC" +# roles: +# - Copyright holder +# - funder +# homepage: https://posit.co +# ror: https://ror.org/03wc8by49 + +# API Reference Structure +# ----------------------- +# Customize the sections below to organize your API documentation. +# - Reorder items within a section to change their display order +# - Move items between sections or create new sections +# - Use 'members: false' to exclude methods from documentation +# - Add 'desc:' to sections for descriptions + +reference: + - title: Client + desc: Local and remote clients, namespace context, and permissions. + contents: + - name: RecapClient + members: true + - name: ActorPermissions + members: true + - name: EffectivePermissions + members: true + - name: PermissionDecision + members: true + - DenialCode + - title: Queries + desc: Immutable query composition and field predicates. + contents: + - name: QueryDSL + members: true + - name: Field + members: true + - name: FieldPredicate + members: true + - name: FieldOrdering + members: true + - name: BaseQuery + members: true + - name: NamespaceQuery + members: true + - name: ResourceQuery + members: true + - name: ResourceTemplateQuery + members: true + - name: ProcessTemplateQuery + members: true + - name: ProcessRunQuery + members: true + - title: Builders + desc: Context-managed resource and process builders. + contents: + - name: ResourceTemplateBuilder + members: true + - name: ResourceBuilder + members: true + - name: ProcessTemplateBuilder + members: true + - name: StepTemplateBuilder + members: true + - name: ProcessRunBuilder + members: true + - name: AttributeGroupBuilder + members: true + - title: Models and Lifecycle + desc: Public schemas, lifecycle state, directions, and copy options. + contents: + - LifecycleStatus + - Direction + - validate_transition + - name: NamespaceRef + members: true + - name: NamespaceContext + members: true + - name: NamespaceSchema + members: true + - name: ResourceTypeSchema + members: true + - name: ResourceCopyChanges + members: true + - name: ResourceCopyOptions + members: true + - name: ResourceTemplateRef + members: true + - name: ResourceTemplateSchema + members: true + - name: ResourceRef + members: true + - name: ResourceSchema + members: true + - name: ResourceAssignmentSchema + members: true + - name: ProcessTemplateRef + members: true + - name: ProcessTemplateSchema + members: true + - name: ProcessRunRef + members: true + - name: ProcessRunSchema + members: true + - name: StepTemplateRef + members: true + - name: StepTemplateSchema + members: true + - name: StepSchema + members: true + - name: ParameterSchema + members: true + - name: AttributeTemplateSchema + members: true + - name: AttributeGroupRef + members: true + - name: AttributeGroupTemplateSchema + members: true + - name: AttributeValueSchema + members: true + - name: AttributeTemplateValidator + members: true +# Site URL +# -------- +# Canonical address of the deployed documentation site. +# Required for subdirectory deployments, skills page install commands, +# .well-known/ discovery, and sitemaps. +# site_url: "https://your-org.github.io/your-package/" + +# Site Settings +# ------------- +# site: +# theme: flatly # Quarto theme (default: flatly) +# toc: true # Show table of contents (default: true) +# toc-depth: 2 # TOC heading depth (default: 2) +# toc-title: On this page # TOC title (default: "On this page") + +# Jupyter Kernel +# -------------- +# Jupyter kernel to use for executing code cells in .qmd files. +# This is set at the project level so it applies to all pages, including +# auto-generated API reference pages. Can be overridden in individual .qmd +# file frontmatter if needed for special cases. +jupyter: python3 + +# CLI Documentation +# ----------------- +# cli: +# enabled: true # Enable CLI documentation +# module: my_package.cli # Module containing Click commands +# name: cli # Name of the Click command object diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index c7bc07a..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,3 +0,0 @@ -site_name: Recap -repo_url: https://github.com/nsls2/recap -theme: readthedocs diff --git a/pixi.lock b/pixi.lock index 3abae45..ff525c9 100644 --- a/pixi.lock +++ b/pixi.lock @@ -25,55 +25,56 @@ environments: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py313h07c4f96_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/backports.zstd-1.2.0-py313h18e8e13_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.1.0-py313h7033f15_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py311h49ec1c0_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/backports.zstd-1.6.0-py311h6b1f9c4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py311h66f275b_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py313hf01b4d8_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cmarkgfm-2024.11.20-py313h07c4f96_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-46.0.2-py313hafb0bba_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py311h03d9500_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cmarkgfm-2024.11.20-py311h49ec1c0_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-50.0.0-py311h2005dd1_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h3c4dab8_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.17-py313h5d5ffb9_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.3.0-py313h7033f15_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/jsonpointer-3.0.0-py313h78bf25f_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.21-py311hc665b79_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.5.4-py311hc665b79_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-h1423503_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.6-h2dba641_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.1.0-h767d61c_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.1.0-h69a702a_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.0-h1fed272_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.3-h0d30a3d_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.1.0-h767d61c_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.20-h4ab18f5_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.1.0-h8f9b012_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.1.0-h4852527_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.2-py313h8060acc_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py311h3778330_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/nh3-0.3.2-py310h1570de5_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.3-h26f9b46_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.46-h1321c63_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.1.3-py313h54dd161_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.7-h2b335a9_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.2-py313h8060acc_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hfb55c3c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-0.30.0-py313h843e2db_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/secretstorage-3.4.1-py313h78bf25f_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlalchemy-2.0.44-py313h07c4f96_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py311haee01d2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.11.15-h7508c33_1_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py311h3778330_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py311hca8cfc1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-2026.6.3-py311h1baac5b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/secretstorage-3.5.0-py311h38be061_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlalchemy-2.0.51-py311haee01d2_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.2-py313h07c4f96_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.7-py311h49ec1c0_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.9.17-h76e24b7_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h387f397_9.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py313h54dd161_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py311haee01d2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.17.2-pyhd8ed1ab_0.conda @@ -97,7 +98,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.0-pyh707e725_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.11.15-py311hd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda @@ -132,6 +133,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jeepney-0.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/json5-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.25.1-he01879c_0.conda @@ -176,10 +178,10 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.11.15-hd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/readme_renderer-44.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -224,6 +226,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl @@ -236,7 +239,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda @@ -262,7 +264,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.0-pyh707e725_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.11.15-py311hd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda @@ -296,6 +298,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/json5-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.25.1-he01879c_0.conda @@ -340,10 +343,10 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.11.15-hd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/readme_renderer-44.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -382,51 +385,49 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py313h6535dbc_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/backports.zstd-1.2.0-py313hf42fe89_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.1.0-py313hb4b7877_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py311h9408147_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/backports.zstd-1.6.0-py311h36d4fbb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py311hdc60ec4_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py313h89bd988_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cmarkgfm-2024.11.20-py313hcdf3177_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.17-py313hc37fe24_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/greenlet-3.3.0-py313h0e822ff_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/jsonpointer-3.0.0-py313h8f79df9_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py311h833bfeb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cmarkgfm-2024.11.20-py311h3696347_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.21-py311h8948835_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/greenlet-3.5.4-py311h8948835_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.21.3-h237132a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.2-hf598326_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.1-hec049ff_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.6-h1da3d7d_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.20-h99b78c6_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.50.4-h4237e3c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.2-py313ha9b7d5b_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py311hc290fe0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nh3-0.3.2-py310h06fc29a_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.5.3-h5503f6c_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.1.3-py313h9734d34_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.0-py313h1bd1373_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.0-py313h3eb5a1b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.7-h5c937ed_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.2-py313ha9b7d5b_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312hd65ceae_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-0.30.0-py313h2c089d5_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sqlalchemy-2.0.44-py313h6535dbc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py311he363849_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.1-py311h733dca1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.1-py311h971847e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.11.15-h0c9c016_1_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.3-py311hc290fe0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py311h8463d66_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-2026.6.3-py311haff49d3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sqlalchemy-2.0.51-py311he363849_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.2-py313h6535dbc_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.7-py311hc949640_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.9.17-h1bde295_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h888dc83_9.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py313h9734d34_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py311h5bb9006_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-h6491c7d_2.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl @@ -466,7 +467,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.0-pyh7428d3b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.11.15-py311hd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda @@ -500,6 +501,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/json5-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.25.1-he01879c_0.conda @@ -544,10 +546,10 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.11.15-hd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/readme_renderer-44.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda @@ -587,37 +589,35 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py313h5ea7bf4_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/backports.zstd-1.2.0-py313h2a31948_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.1.0-py313hfe59770_4.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py311h3485c13_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/backports.zstd-1.6.0-py311h71c1bcc_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py311hc5da9e4_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py313h5ea7bf4_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cmarkgfm-2024.11.20-py313h5ea7bf4_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.17-py313h927ade5_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.3.0-py313hfe59770_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/jsonpointer-3.0.0-py313hfa70ccb_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py311h3485c13_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/cmarkgfm-2024.11.20-py311h3485c13_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py311h5dfdfe8_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.5.4-py311h5dfdfe8_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.21.3-hdf4eb48_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.7.1-hac47afa_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.4.6-h537db12_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.1-h2466b09_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-h2466b09_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.20-hc70643c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.50.4-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.2-py313hb4c8b1a_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py311h3f79411_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/nh3-0.3.2-py310hdba2031_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.5.3-h725018a_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.1.3-py313h5fd188c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.13.7-hdf00ec1_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py313h40c08fc_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-ctypes-0.2.3-py313hfa70ccb_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-2.0.15-py313h5813708_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.2-py313hb4c8b1a_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312hbb5da91_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-0.30.0-py313hfbe8231_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.44-py313h5ea7bf4_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py311hf893f09_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.11.15-h0159041_1_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py311hefeebc8_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-ctypes-0.2.3-py311h1ea47a8_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-3.0.5-py311hda3d55a_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py311h3f79411_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py311h7a22eb7_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.6.3-py311h7337c20_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.51-py311hf893f09_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h2c6b04d_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.2-py313h5ea7bf4_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py311h3485c13_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/uv-0.9.17-h3bd95fe_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.3-h41ae7f8_31.conda @@ -626,7 +626,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h5bddc39_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zstandard-0.25.0-py313h5fd188c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zstandard-0.25.0-py311hf893f09_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-hbeecb71_2.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl @@ -641,7 +641,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl @@ -649,6 +648,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl default: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -658,48 +658,48 @@ environments: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py313h07c4f96_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.1.0-py313h7033f15_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py311h49ec1c0_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py311h66f275b_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py313hf01b4d8_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-46.0.2-py313hafb0bba_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.17-py313h5d5ffb9_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.3.0-py313h7033f15_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/jsonpointer-3.0.0-py313h78bf25f_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py311h03d9500_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-50.0.0-py311h2005dd1_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.21-py311hc665b79_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.5.4-py311hc665b79_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-h1423503_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.6-h2dba641_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.1.0-h767d61c_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.1.0-h69a702a_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.1.0-h767d61c_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.20-h4ab18f5_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.1.0-h8f9b012_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.1.0-h4852527_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.2-py313h8060acc_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.3-h26f9b46_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.1.3-py313h54dd161_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.7-h2b335a9_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.2-py313h8060acc_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hfb55c3c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-0.30.0-py313h843e2db_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlalchemy-2.0.44-py313h07c4f96_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py311h3778330_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py311haee01d2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.11.15-h7508c33_1_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py311h3778330_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py311hca8cfc1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-2026.6.3-py311h1baac5b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlalchemy-2.0.51-py311haee01d2_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.2-py313h07c4f96_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.7-py311h49ec1c0_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h387f397_9.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py313h54dd161_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py311haee01d2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.17.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda @@ -719,7 +719,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.0-pyh707e725_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.0-pyhd8ed1ab_0.conda @@ -741,6 +740,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/json5-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.25.1-he01879c_0.conda @@ -781,10 +781,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda @@ -820,6 +819,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl @@ -832,10 +832,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl osx-arm64: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.17.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/appnope-0.1.4-pyhd8ed1ab_1.conda @@ -856,7 +854,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.0-pyh707e725_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.0-pyhd8ed1ab_0.conda @@ -878,6 +875,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/json5-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.25.1-he01879c_0.conda @@ -918,10 +916,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda @@ -951,47 +948,45 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py313h6535dbc_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.1.0-py313hb4b7877_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py311h9408147_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py311hdc60ec4_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py313h89bd988_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.17-py313hc37fe24_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/greenlet-3.3.0-py313h0e822ff_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/jsonpointer-3.0.0-py313h8f79df9_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py311h833bfeb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.21-py311h8948835_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/greenlet-3.5.4-py311h8948835_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.21.3-h237132a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.2-hf598326_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.1-hec049ff_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.6-h1da3d7d_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.20-h99b78c6_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.50.4-h4237e3c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.2-py313ha9b7d5b_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.5.3-h5503f6c_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.1.3-py313h9734d34_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.0-py313h1bd1373_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.0-py313h3eb5a1b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.7-h5c937ed_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.2-py313ha9b7d5b_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312hd65ceae_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-0.30.0-py313h2c089d5_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sqlalchemy-2.0.44-py313h6535dbc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py311hc290fe0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py311he363849_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.1-py311h733dca1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.1-py311h971847e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.11.15-h0c9c016_1_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.3-py311hc290fe0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py311h8463d66_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-2026.6.3-py311haff49d3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sqlalchemy-2.0.51-py311he363849_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.2-py313h6535dbc_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.7-py311hc949640_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h888dc83_9.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py313h9734d34_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py311h5bb9006_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-h6491c7d_2.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl @@ -1009,7 +1004,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl win-64: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.17.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda @@ -1029,7 +1023,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.0-pyh7428d3b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.0-pyhd8ed1ab_0.conda @@ -1051,6 +1044,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/json5-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.25.1-he01879c_0.conda @@ -1089,10 +1083,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda @@ -1123,33 +1116,31 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py313h5ea7bf4_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.1.0-py313hfe59770_4.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py311h3485c13_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py311hc5da9e4_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py313h5ea7bf4_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.17-py313h927ade5_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.3.0-py313hfe59770_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/jsonpointer-3.0.0-py313hfa70ccb_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py311h3485c13_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py311h5dfdfe8_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.5.4-py311h5dfdfe8_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.21.3-hdf4eb48_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.7.1-hac47afa_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.4.6-h537db12_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.1-h2466b09_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-h2466b09_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.20-hc70643c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.50.4-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.2-py313hb4c8b1a_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.5.3-h725018a_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.1.3-py313h5fd188c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.13.7-hdf00ec1_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py313h40c08fc_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-2.0.15-py313h5813708_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.2-py313hb4c8b1a_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312hbb5da91_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-0.30.0-py313hfbe8231_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.44-py313h5ea7bf4_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py311h3f79411_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py311hf893f09_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.11.15-h0159041_1_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py311hefeebc8_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-3.0.5-py311hda3d55a_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py311h3f79411_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py311h7a22eb7_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.6.3-py311h7337c20_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.51-py311hf893f09_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h2c6b04d_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.2-py313h5ea7bf4_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py311h3485c13_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.3-h41ae7f8_31.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_31.conda @@ -1157,7 +1148,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h5bddc39_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zstandard-0.25.0-py313h5fd188c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zstandard-0.25.0-py311hf893f09_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-hbeecb71_2.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl @@ -1172,7 +1163,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl @@ -1180,6 +1170,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl dev: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -1189,48 +1180,49 @@ environments: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py313h07c4f96_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.1.0-py313h7033f15_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py311h49ec1c0_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py311h66f275b_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py313hf01b4d8_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-46.0.2-py313hafb0bba_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.17-py313h5d5ffb9_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.3.0-py313h7033f15_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/jsonpointer-3.0.0-py313h78bf25f_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py311h03d9500_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-50.0.0-py311h2005dd1_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.21-py311hc665b79_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.5.4-py311hc665b79_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-h1423503_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.6-h2dba641_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.1.0-h767d61c_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.1.0-h69a702a_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.1.0-h767d61c_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.20-h4ab18f5_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.1.0-h8f9b012_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.1.0-h4852527_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.2-py313h8060acc_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.3-h26f9b46_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.1.3-py313h54dd161_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.7-h2b335a9_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.2-py313h8060acc_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hfb55c3c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-0.30.0-py313h843e2db_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlalchemy-2.0.44-py313h07c4f96_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py311h3778330_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py311haee01d2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.11.15-h7508c33_1_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py311h3778330_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py311hca8cfc1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-2026.6.3-py311h1baac5b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlalchemy-2.0.51-py311haee01d2_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.2-py313h07c4f96_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.7-py311h49ec1c0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py311hdf67eae_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h387f397_9.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py313h54dd161_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py311haee01d2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.17.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda @@ -1246,15 +1238,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.8.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.0-pyh707e725_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda @@ -1262,6 +1256,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.10-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.0.0-pyhd8ed1ab_1.conda @@ -1272,6 +1267,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/json5-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.25.1-he01879c_0.conda @@ -1294,6 +1290,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.16.6-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda @@ -1302,6 +1299,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.1-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.23.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda @@ -1312,10 +1310,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda @@ -1340,42 +1337,42 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.35.4-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.2.14-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhd8ed1ab_0.conda - pypi: ./ - - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/37/83/3e29a6eb0487621980bdba69376fc4f44103748a75b0c2958e742ce52a45/strawberry_graphql-0.323.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7a/4a/78b52ec2edcbd9b123638f4e0421fd8699ebe653ebf63ac5f82abd2665bc/cross_web-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/c1/fb2c66d92b5d0167c57042b784456ee3f8531a997726c88cf6f012a22da6/json_merge_patch-0.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl osx-arm64: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.17.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/appnope-0.1.4-pyhd8ed1ab_1.conda @@ -1392,15 +1389,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.8.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.0-pyh707e725_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda @@ -1408,6 +1407,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.10-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.0.0-pyhd8ed1ab_1.conda @@ -1418,6 +1418,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/json5-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.25.1-he01879c_0.conda @@ -1440,6 +1441,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.16.6-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda @@ -1448,6 +1450,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.1-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.23.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda @@ -1458,10 +1461,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda @@ -1486,67 +1488,64 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.35.4-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.2.14-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py313h6535dbc_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.1.0-py313hb4b7877_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py311h9408147_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py311hdc60ec4_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py313h89bd988_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.17-py313hc37fe24_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/greenlet-3.3.0-py313h0e822ff_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/jsonpointer-3.0.0-py313h8f79df9_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py311h833bfeb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.21-py311h8948835_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/greenlet-3.5.4-py311h8948835_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.21.3-h237132a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.2-hf598326_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.1-hec049ff_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.6-h1da3d7d_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.20-h99b78c6_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.50.4-h4237e3c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.2-py313ha9b7d5b_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.5.3-h5503f6c_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.1.3-py313h9734d34_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.0-py313h1bd1373_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.0-py313h3eb5a1b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.7-h5c937ed_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.2-py313ha9b7d5b_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312hd65ceae_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-0.30.0-py313h2c089d5_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sqlalchemy-2.0.44-py313h6535dbc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py311hc290fe0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py311he363849_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.1-py311h733dca1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.1-py311h971847e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.11.15-h0c9c016_1_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.3-py311hc290fe0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py311h8463d66_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-2026.6.3-py311haff49d3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sqlalchemy-2.0.51-py311he363849_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.2-py313h6535dbc_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.7-py311hc949640_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.1.0-py311h572238d_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h888dc83_9.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py313h9734d34_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py311h5bb9006_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-h6491c7d_2.conda - pypi: ./ - - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/37/83/3e29a6eb0487621980bdba69376fc4f44103748a75b0c2958e742ce52a45/strawberry_graphql-0.323.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7a/4a/78b52ec2edcbd9b123638f4e0421fd8699ebe653ebf63ac5f82abd2665bc/cross_web-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl - pypi: https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl @@ -1554,11 +1553,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl + - pypi: https://files.pythonhosted.org/packages/cb/c1/fb2c66d92b5d0167c57042b784456ee3f8531a997726c88cf6f012a22da6/json_merge_patch-0.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl win-64: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.17.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda @@ -1574,15 +1575,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.8.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.0-pyh7428d3b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda @@ -1590,6 +1593,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.10-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.0.0-pyhd8ed1ab_1.conda @@ -1600,6 +1604,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/json5-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.25.1-he01879c_0.conda @@ -1622,6 +1627,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.16.6-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.6.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda @@ -1629,6 +1635,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.5-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.1-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.23.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda @@ -1638,10 +1645,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda @@ -1666,77 +1672,77 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.35.4-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.2.14-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py313h5ea7bf4_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.1.0-py313hfe59770_4.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py311h3485c13_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py311hc5da9e4_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py313h5ea7bf4_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.17-py313h927ade5_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.3.0-py313hfe59770_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/jsonpointer-3.0.0-py313hfa70ccb_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py311h3485c13_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py311h5dfdfe8_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.5.4-py311h5dfdfe8_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.21.3-hdf4eb48_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.7.1-hac47afa_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.4.6-h537db12_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.1-h2466b09_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-h2466b09_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.20-hc70643c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.50.4-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.2-py313hb4c8b1a_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.5.3-h725018a_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.1.3-py313h5fd188c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.13.7-hdf00ec1_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py313h40c08fc_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-2.0.15-py313h5813708_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.2-py313hb4c8b1a_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312hbb5da91_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-0.30.0-py313hfbe8231_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.44-py313h5ea7bf4_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py311h3f79411_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py311hf893f09_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.11.15-h0159041_1_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py311hefeebc8_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-3.0.5-py311hda3d55a_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py311h3f79411_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py311h7a22eb7_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.6.3-py311h7337c20_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.51-py311hf893f09_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h2c6b04d_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.2-py313h5ea7bf4_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py311h3485c13_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ukkonen-1.1.0-py311h3fd045d_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.3-h41ae7f8_31.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_31.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.44.35208-h818238b_31.conda - conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h5bddc39_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zstandard-0.25.0-py313h5fd188c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zstandard-0.25.0-py311hf893f09_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-hbeecb71_2.conda - pypi: ./ - - pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/37/83/3e29a6eb0487621980bdba69376fc4f44103748a75b0c2958e742ce52a45/strawberry_graphql-0.323.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7a/4a/78b52ec2edcbd9b123638f4e0421fd8699ebe653ebf63ac5f82abd2665bc/cross_web-0.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/c1/fb2c66d92b5d0167c57042b784456ee3f8531a997726c88cf6f012a22da6/json_merge_patch-0.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl docs: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -1746,49 +1752,54 @@ environments: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py313h07c4f96_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.1.0-py313h7033f15_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py311h49ec1c0_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py311h66f275b_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py313hf01b4d8_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-46.0.2-py313hafb0bba_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.17-py313h5d5ffb9_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.3.0-py313h7033f15_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/jsonpointer-3.0.0-py313h78bf25f_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py311h03d9500_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dart-sass-1.102.0-hfc2019e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.21-py311hc665b79_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/deno-2.4.5-h6046fbb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/deno-dom-0.1.41-h4768de7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/esbuild-0.28.1-hfc2019e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.5.4-py311hc665b79_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-h1423503_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.6-h2dba641_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.1.0-h767d61c_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.1.0-h69a702a_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.1.0-h767d61c_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.20-h4ab18f5_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.1.0-h8f9b012_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.1.0-h4852527_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.2-py313h8060acc_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.3-h26f9b46_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.1.3-py313h54dd161_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.7-h2b335a9_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.2-py313h8060acc_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hfb55c3c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-0.30.0-py313h843e2db_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlalchemy-2.0.44-py313h07c4f96_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py311h3778330_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pandoc-3.8.3-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py311haee01d2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.11.15-h7508c33_1_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py311h3778330_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py311hca8cfc1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/quarto-1.9.38-h920450f_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-2026.6.3-py311h1baac5b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlalchemy-2.0.51-py311haee01d2_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.2-py313h07c4f96_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/watchdog-6.0.0-py313h78bf25f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.7-py311h49ec1c0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/typst-0.14.2-h4c46f8d_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h387f397_9.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py313h54dd161_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py311haee01d2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.17.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda @@ -1797,7 +1808,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/async-lru-2.0.5-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-25.4.0-pyh71513ae_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-h5f6438b_0.conda @@ -1806,16 +1816,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.8.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.0-pyh707e725_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda @@ -1832,6 +1839,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/json5-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.25.1-he01879c_0.conda @@ -1846,18 +1854,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mako-1.3.10-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.9-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.1.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-autorefs-1.4.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.6.20-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocstrings-0.30.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.10.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.16.6-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda @@ -1865,10 +1863,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pandocfilters-1.5.0-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.5-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda @@ -1878,26 +1874,21 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.16.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rich-14.2.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.3-pyh0d859eb_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda @@ -1921,24 +1912,51 @@ environments: - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2c/28/01ae5d1e195f5079e485c27f4710f08629f0a688db5bdc4ab1fdc9a9fa07/great_docs-0.16.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/45/a1/2b100e71aa1fe283ec8fb73f8b74a8576d855486a49117903b100dd3b78d/websockets-17.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/6b/fb/3c65d392feae6c36dc2b55a14dd8270b7b35a3171c93b71a4d2ee4abf241/griffe-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/73/96/00df2a4760f10f5af0f45c4955573cae6189931f9a30265a35865f8c1031/notebook-7.5.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/2f/513232ec1d5f5da182e4ce45a11427e37dd210844a9e2bca451fc9661fb3/griffecli-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/0c/1e1b171246a44753bf04c6399179da1ccf99cfb9de1e086afd7326765b34/py_yaml12-0.1.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/c1/fb2c66d92b5d0167c57042b784456ee3f8531a997726c88cf6f012a22da6/json_merge_patch-0.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl osx-arm64: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.17.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/appnope-0.1.4-pyhd8ed1ab_1.conda @@ -1948,7 +1966,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/async-lru-2.0.5-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-25.4.0-pyh71513ae_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-h5f6438b_0.conda @@ -1957,16 +1974,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.8.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.0-pyh707e725_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda @@ -1983,6 +1997,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/json5-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.25.1-he01879c_0.conda @@ -1997,18 +2012,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mako-1.3.10-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.9-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.1.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-autorefs-1.4.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.6.20-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocstrings-0.30.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.10.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.16.6-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda @@ -2016,10 +2021,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pandocfilters-1.5.0-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.5-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda @@ -2029,26 +2032,21 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.16.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rich-14.2.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.3-pyh31c8845_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda @@ -2068,66 +2066,96 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py313h6535dbc_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.1.0-py313hb4b7877_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py311h9408147_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py311hdc60ec4_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py313h89bd988_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.17-py313hc37fe24_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/greenlet-3.3.0-py313h0e822ff_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/jsonpointer-3.0.0-py313h8f79df9_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py311h833bfeb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/dart-sass-1.102.0-h820172f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.21-py311h8948835_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/deno-2.4.5-h248350f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/deno-dom-0.1.41-hde76f7a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/esbuild-0.28.1-hf76c51c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/greenlet-3.5.4-py311h8948835_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.21.3-h237132a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.2-hf598326_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.1-hec049ff_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.6-h1da3d7d_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.20-h99b78c6_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.50.4-h4237e3c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.2-py313ha9b7d5b_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.5.3-h5503f6c_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.1.3-py313h9734d34_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.0-py313h1bd1373_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.0-py313h3eb5a1b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.7-h5c937ed_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.2-py313ha9b7d5b_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312hd65ceae_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-0.30.0-py313h2c089d5_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sqlalchemy-2.0.44-py313h6535dbc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py311hc290fe0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandoc-3.8.3-hce30654_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py311he363849_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.1-py311h733dca1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.1-py311h971847e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.11.15-h0c9c016_1_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.3-py311hc290fe0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py311h8463d66_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/quarto-1.9.38-h78c4bfd_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-2026.6.3-py311haff49d3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sqlalchemy-2.0.51-py311he363849_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.2-py313h6535dbc_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/watchdog-6.0.0-py313hcdf3177_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.7-py311hc949640_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/typst-0.14.2-hd1458d2_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h888dc83_9.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py313h9734d34_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py311h5bb9006_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-h6491c7d_2.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/2c/28/01ae5d1e195f5079e485c27f4710f08629f0a688db5bdc4ab1fdc9a9fa07/great_docs-0.16.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6b/fb/3c65d392feae6c36dc2b55a14dd8270b7b35a3171c93b71a4d2ee4abf241/griffe-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/73/96/00df2a4760f10f5af0f45c4955573cae6189931f9a30265a35865f8c1031/notebook-7.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/2f/513232ec1d5f5da182e4ce45a11427e37dd210844a9e2bca451fc9661fb3/griffecli-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/87/2d/68439a174c74969fb51619bbe9af9496826610883b828a2edd2f022c94fc/websockets-17.0.1-cp311-cp311-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl - pypi: https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/be/d9/5f4ccd96d7c776e822e63d2b79e3594653bcf55720dbad1cdcbc18a170ad/py_yaml12-0.1.0-cp310-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl + - pypi: https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/c1/fb2c66d92b5d0167c57042b784456ee3f8531a997726c88cf6f012a22da6/json_merge_patch-0.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl win-64: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.17.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.12.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda @@ -2136,7 +2164,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/async-lru-2.0.5-pyh29332c3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-25.4.0-pyh71513ae_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-h5f6438b_0.conda @@ -2145,16 +2172,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.8.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.0-pyh7428d3b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda @@ -2171,6 +2195,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/json5-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.25.1-he01879c_0.conda @@ -2185,18 +2210,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mako-1.3.10-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.9-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.1.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-autorefs-1.4.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.6.20-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocstrings-0.30.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.10.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.16.6-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda @@ -2204,10 +2219,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pandocfilters-1.5.0-pyhd8ed1ab_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.5-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.4.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.23.1-pyhd8ed1ab_0.conda @@ -2215,26 +2228,21 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.16.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rich-14.2.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.3-pyh5737063_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda @@ -2255,64 +2263,94 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py313h5ea7bf4_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.1.0-py313hfe59770_4.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py311h3485c13_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py311hc5da9e4_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py313h5ea7bf4_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.17-py313h927ade5_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.3.0-py313hfe59770_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/jsonpointer-3.0.0-py313hfa70ccb_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py311h3485c13_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/dart-sass-1.102.0-h11686cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py311h5dfdfe8_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/deno-2.4.5-h9135563_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/deno-dom-0.1.41-h1a6af48_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/esbuild-0.28.1-h11686cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.5.4-py311h5dfdfe8_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.21.3-hdf4eb48_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.7.1-hac47afa_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.4.6-h537db12_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.1-h2466b09_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-h2466b09_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.20-hc70643c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.50.4-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.2-py313hb4c8b1a_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.5.3-h725018a_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.1.3-py313h5fd188c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.13.7-hdf00ec1_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py313h40c08fc_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-2.0.15-py313h5813708_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.2-py313hb4c8b1a_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312hbb5da91_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-0.30.0-py313hfbe8231_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.44-py313h5ea7bf4_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py311h3f79411_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pandoc-3.8.3-h57928b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py311hf893f09_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.11.15-h0159041_1_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py311hefeebc8_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-3.0.5-py311hda3d55a_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py311h3f79411_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py311h7a22eb7_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/quarto-1.9.38-h0e8a320_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.6.3-py311h7337c20_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.51-py311hf893f09_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h2c6b04d_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.2-py313h5ea7bf4_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py311h3485c13_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/typst-0.14.2-h77a83cd_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.3-h41ae7f8_31.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_31.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.44.35208-h818238b_31.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/watchdog-6.0.0-py313hfa70ccb_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h5bddc39_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zstandard-0.25.0-py313h5fd188c_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zstandard-0.25.0-py311hf893f09_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-hbeecb71_2.conda - pypi: ./ + - pypi: https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2c/28/01ae5d1e195f5079e485c27f4710f08629f0a688db5bdc4ab1fdc9a9fa07/great_docs-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/48/60/d50a1e5e44333aa6533080df9c587b6b25e0205071078c63619202592bb3/py_yaml12-0.1.0-cp310-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/6b/fb/3c65d392feae6c36dc2b55a14dd8270b7b35a3171c93b71a4d2ee4abf241/griffe-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/73/96/00df2a4760f10f5af0f45c4955573cae6189931f9a30265a35865f8c1031/notebook-7.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7b/b1/ba0ce59681db38c320a6d485f95a497ddea20356d9a9e8e70615ddd867b9/websockets-17.0.1-cp311-cp311-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/80/2f/513232ec1d5f5da182e4ce45a11427e37dd210844a9e2bca451fc9661fb3/griffecli-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/c1/fb2c66d92b5d0167c57042b784456ee3f8531a997726c88cf6f012a22da6/json_merge_patch-0.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl packages: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726 @@ -2335,52 +2373,55 @@ packages: purls: [] size: 23621 timestamp: 1650670423406 -- conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py313h07c4f96_2.conda - sha256: ad188ccc06a06c633dc124b09e9e06fb9df4c32ffc38acc96ecc86e506062090 - md5: 27bbec9f2f3a15d32b60ec5734f5b41c +- conda: https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py311h49ec1c0_2.conda + sha256: b81f852f13a1d148f6ad7e2a29ab375eb1558b73c9bfa38792d98ea7fb414cff + md5: 6e36e9d2b535c3fbe2e093108df26695 depends: - __glibc >=2.17,<3.0.a0 - cffi >=1.0.1 - libgcc >=14 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 license: MIT license_family: MIT purls: - pkg:pypi/argon2-cffi-bindings?source=hash-mapping - size: 35943 - timestamp: 1762509452935 -- conda: https://conda.anaconda.org/conda-forge/linux-64/backports.zstd-1.2.0-py313h18e8e13_0.conda - sha256: dac72e2f4f64d8d7eccd424dd02d90c2c7229d6d3e0fa4a819ab41e6c7d30351 - md5: ab79cf30dea6ef4d1ab2623c5ac5601b + run_exports: {} + size: 35831 + timestamp: 1762509453632 +- conda: https://conda.anaconda.org/conda-forge/linux-64/backports.zstd-1.6.0-py311h6b1f9c4_0.conda + sha256: bb42ea7eee74a6ade6be8ad94239f2dcc54d5939577a3488ee25a5f534bf0d4c + md5: 434f289a3aea1a1f5ace0f2226a53fe6 depends: - python - - libgcc >=14 - __glibc >=2.17,<3.0.a0 + - libgcc >=14 - zstd >=1.5.7,<1.6.0a0 - - python_abi 3.13.* *_cp313 + - python_abi 3.11.* *_cp311 license: BSD-3-Clause AND MIT AND EPL-2.0 purls: - pkg:pypi/backports-zstd?source=hash-mapping - size: 240935 - timestamp: 1765057668668 -- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.1.0-py313h7033f15_4.conda - sha256: b1941426e564d326097ded7af8b525540be219be7a88ca961d58a8d4fc116db2 - md5: bc8624c405856b1d047dd0a81829b08c + run_exports: {} + size: 246945 + timestamp: 1781450816739 +- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py311h66f275b_1.conda + sha256: c36eb061d9ead85f97644cfb740d485dba9b8823357f35c17851078e95e975c1 + md5: 86daecb8e4ed1042d5dc6efbe0152590 depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 - libstdcxx >=14 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 constrains: - - libbrotlicommon 1.1.0 hb03c661_4 + - libbrotlicommon 1.2.0 hb03c661_1 license: MIT license_family: MIT purls: - - pkg:pypi/brotli?source=compressed-mapping - size: 353639 - timestamp: 1756599425945 + - pkg:pypi/brotli?source=hash-mapping + run_exports: {} + size: 367573 + timestamp: 1764017405384 - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda sha256: c30daba32ddebbb7ded490f0e371eae90f51e72db620554089103b4a6934b0d5 md5: 51a19bba1b8ebfb60df25cde030b7ebc @@ -2392,54 +2433,67 @@ packages: purls: [] size: 260341 timestamp: 1757437258798 -- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py313hf01b4d8_0.conda - sha256: cbead764b88c986642578bb39f77d234fbc3890bd301ed29f849a6d3898ed0fc - md5: 062317cc1cd26fbf6454e86ddd3622c4 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py311h03d9500_0.conda + sha256: 20bd02f020486494baad216471fbb376308b5c8ff1df932b80347b7e66bc4435 + md5: c18940d107533b75160850885a6296ff depends: - __glibc >=2.17,<3.0.a0 - - libffi >=3.4.6,<3.5.0a0 + - libffi >=3.5.2,<3.6.0a0 - libgcc >=14 - pycparser - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 license: MIT + license_family: MIT purls: - - pkg:pypi/cffi?source=compressed-mapping - size: 298211 - timestamp: 1758716239290 -- conda: https://conda.anaconda.org/conda-forge/linux-64/cmarkgfm-2024.11.20-py313h07c4f96_1.conda - sha256: 8edab14df6259d9678c83ea876a56902da44b50022cda8789c706d4d5336a4b9 - md5: d57661f0421c6fdb9a617280e633e4c8 + - pkg:pypi/cffi?source=hash-mapping + run_exports: {} + size: 309660 + timestamp: 1785810908319 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cmarkgfm-2024.11.20-py311h49ec1c0_1.conda + sha256: 447b65e534b3104cbd55d98fcf06607d7d136f54be30577fbd654d9647717950 + md5: 59ff48e89b1a32ddfbe0d73de2498ec8 depends: - __glibc >=2.17,<3.0.a0 - cffi >=1.0.0 - libgcc >=14 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 license: MIT license_family: MIT purls: - pkg:pypi/cmarkgfm?source=hash-mapping - size: 142385 - timestamp: 1760363028602 -- conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-46.0.2-py313hafb0bba_0.conda - sha256: 019b338ea33ab2de07acd4e02ba417198b17e6b85e800803af3490c1ae788ddf - md5: 5473e4a4a4fd596593e65357ab6cd188 + run_exports: {} + size: 143640 + timestamp: 1760363101780 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-50.0.0-py311h2005dd1_0.conda + sha256: 559b0c714048999e09d5c7eeb49539dbd8a88abc234d17e0c97e6c65d5317e32 + md5: 92c90d7b36c3a544b45421647e629b64 depends: - __glibc >=2.17,<3.0.a0 - - cffi >=1.14 + - cffi >=2.0 - libgcc >=14 - - openssl >=3.5.3,<4.0a0 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - openssl >=3.5.7,<4.0a0 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 constrains: - __glibc >=2.17 license: Apache-2.0 AND BSD-3-Clause AND PSF-2.0 AND MIT license_family: BSD purls: - pkg:pypi/cryptography?source=hash-mapping - size: 1717809 - timestamp: 1759320828732 + run_exports: {} + size: 1906181 + timestamp: 1785640975036 +- conda: https://conda.anaconda.org/conda-forge/linux-64/dart-sass-1.102.0-hfc2019e_0.conda + sha256: 2eaf50080de8ccfa445728495082055e349a59ecf9580136d51b7ee056976889 + md5: 40af2242249bb920543a5efde6e03d09 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 4102688 + timestamp: 1784947175493 - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h3c4dab8_0.conda sha256: 3b988146a50e165f0fa4e839545c679af88e4782ec284cc7b6d07dd226d6a068 md5: 679616eb5ad4e521c83da4650860aba7 @@ -2456,49 +2510,90 @@ packages: purls: [] size: 437860 timestamp: 1747855126005 -- conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.17-py313h5d5ffb9_0.conda - sha256: 4c12ca7541d488f64ee92d6368e9a0a418e919c0b8c51517ff329b4259b4aaf8 - md5: be318961d544421f4c8d8a91bff4f118 +- conda: https://conda.anaconda.org/conda-forge/linux-64/debugpy-1.8.21-py311hc665b79_0.conda + sha256: 600beac442edeb098cd93c8d9459904c341dc4c258cee726191a3ead1e877672 + md5: f3d3ec5fc6db099c5b24e95a4c55817f depends: - python - - libgcc >=14 - libstdcxx >=14 - libgcc >=14 - __glibc >=2.17,<3.0.a0 - - python_abi 3.13.* *_cp313 + - python_abi 3.11.* *_cp311 license: MIT license_family: MIT purls: - pkg:pypi/debugpy?source=hash-mapping - size: 2868018 - timestamp: 1758162048107 -- conda: https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.3.0-py313h7033f15_0.conda - sha256: 03b5ae741e63901bdb20f9fafac2176a05d5b16bd71b8bcb7148bb7a2999cace - md5: 2b1cf80423628afd34b4c66b767d7f6b + run_exports: {} + size: 2730876 + timestamp: 1780390154098 +- conda: https://conda.anaconda.org/conda-forge/linux-64/deno-2.4.5-h6046fbb_0.conda + sha256: 3638c08b977b694b1f459da6ad2ba61e956f367e69e9893633b0270e068cb73e + md5: 39a168481d8b14ddc0c07b733fbbda97 + depends: + - libgcc >=15 + - __glibc >=2.28,<3.0.a0 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 38196953 + timestamp: 1776774768229 +- conda: https://conda.anaconda.org/conda-forge/linux-64/deno-dom-0.1.41-h4768de7_0.conda + sha256: 2726829e1a6117dc9555a2088a4ebaa05035218b8791106f036ba73f15b1b13e + md5: 89e80615c6b1b1ae4f5267bfc5fc7f66 depends: - __glibc >=2.17,<3.0.a0 - - libgcc >=14 + - deno >=1.24.2 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 433150 + timestamp: 1736703310469 +- conda: https://conda.anaconda.org/conda-forge/linux-64/esbuild-0.28.1-hfc2019e_0.conda + sha256: 2ff0603150014c840474d0ddc8813569d91f9ab20e83cb49b1019eb4155c0a63 + md5: 65b5461adebff76dc54e02c5c19a20a8 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 4486486 + timestamp: 1781248826728 +- conda: https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.5.4-py311hc665b79_0.conda + sha256: 73ce662beb03558a083bf1ed97004e93b59ac3c206094d8b09d049c2da1b4288 + md5: 5ecba0851c23280f0ee38b24eb45e7a9 + depends: + - python + - __glibc >=2.17,<3.0.a0 - libstdcxx >=14 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - libgcc >=14 + - python_abi 3.11.* *_cp311 license: MIT license_family: MIT purls: - pkg:pypi/greenlet?source=hash-mapping - size: 240282 - timestamp: 1764863692472 -- conda: https://conda.anaconda.org/conda-forge/linux-64/jsonpointer-3.0.0-py313h78bf25f_2.conda - sha256: 9174f5209f835cc8918acddc279be919674d874179197e025181fe2a71cb0bce - md5: c1375f38e5f3ee38a9ee0e405a601c35 - depends: - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/jsonpointer?source=hash-mapping - size: 18143 - timestamp: 1756754243113 + run_exports: {} + size: 280117 + timestamp: 1784885447579 +- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda + sha256: d7c260b7e1cf22ce04d6ba8a86eabf4e6c50bc96a5c27fe2ecb32298af3e88eb + md5: 4ef4b977bb216a3001a3334696a80850 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - icu >=78.3,<79.0a0 + size: 14455340 + timestamp: 1784916378180 - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda sha256: 0960d06048a7185d3542d850986d807c6e37ca2e644342dd0c72feefcf26c2a4 md5: b38117a3c920364aff79f870c984b4a3 @@ -2549,30 +2644,34 @@ packages: purls: [] size: 134676 timestamp: 1738479519902 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda - sha256: da2080da8f0288b95dd86765c801c6e166c4619b910b11f9a8446fb852438dc2 - md5: 4211416ecba1866fab0c6470986c22d6 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + sha256: 16feffd9ddbbe5b718515d38ee376c685ba95491cd901244e24671d20b952a77 + md5: b24d3c612f71e7aa74158d92106318b2 depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 constrains: - - expat 2.7.1.* + - expat 2.8.1.* license: MIT license_family: MIT purls: [] - size: 74811 - timestamp: 1752719572741 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.6-h2dba641_1.conda - sha256: 764432d32db45466e87f10621db5b74363a9f847d2b8b1f9743746cd160f06ab - md5: ede4673863426c0883c0063d853bbd85 + run_exports: {} + size: 77856 + timestamp: 1781203599810 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 + md5: a360c33a5abe61c07959e449fa1453eb depends: - __glibc >=2.17,<3.0.a0 - - libgcc >=13 + - libgcc >=14 license: MIT license_family: MIT purls: [] - size: 57433 - timestamp: 1743434498161 + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 58592 + timestamp: 1769456073053 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.1.0-h767d61c_5.conda sha256: 0caed73aac3966bfbf5710e06c728a24c6c138605121a3dacb2e03440e8baa6a md5: 264fbfba7fb20acf3b29cde153e345ce @@ -2597,22 +2696,25 @@ packages: purls: [] size: 29187 timestamp: 1757042549554 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.0-h1fed272_0.conda - sha256: 33336bd55981be938f4823db74291e1323454491623de0be61ecbe6cf3a4619c - md5: b8e4c93f4ab70c3b6f6499299627dbdc +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.3-h0d30a3d_0.conda + sha256: 69ea4df61403531e5b3e5f3d52ba2837423df361b4eb8727f5b63aaf6de6768a + md5: 17c3b7b6bcbd35b688c933eb4834c0bc depends: - - __glibc >=2.17,<3.0.a0 - - libffi >=3.4.6,<3.5.0a0 - libgcc >=14 + - __glibc >=2.17,<3.0.a0 - libiconv >=1.18,<2.0a0 - - libzlib >=1.3.1,<2.0a0 - - pcre2 >=10.46,<10.47.0a0 + - libzlib >=1.3.2,<2.0a0 + - pcre2 >=10.47,<10.48.0a0 + - libffi >=3.5.2,<3.6.0a0 constrains: - - glib 2.86.0 *_0 + - glib >2.66 license: LGPL-2.1-or-later purls: [] - size: 3978602 - timestamp: 1757403291664 + run_exports: + weak: + - libglib >=2.88.3,<3.0a0 + size: 4755324 + timestamp: 1785442107463 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.1.0-h767d61c_5.conda sha256: 125051d51a8c04694d0830f6343af78b556dd88cc249dfec5a97703ebfb1832d md5: dcd5ff1940cd38f6df777cac86819d60 @@ -2633,29 +2735,35 @@ packages: purls: [] size: 790176 timestamp: 1754908768807 -- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda - sha256: f2591c0069447bbe28d4d696b7fcb0c5bd0b4ac582769b89addbcf26fb3430d8 - md5: 1a580f7796c7bf6393fddb8bbbde58dc +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + sha256: ec30e52a3c1bf7d0425380a189d209a52baa03f22fb66dd3eb587acaa765bd6d + md5: b88d90cad08e6bc8ad540cb310a761fb depends: - __glibc >=2.17,<3.0.a0 - - libgcc >=13 + - libgcc >=14 constrains: - - xz 5.8.1.* + - xz 5.8.3.* license: 0BSD purls: [] - size: 112894 - timestamp: 1749230047870 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda - sha256: 3aa92d4074d4063f2a162cd8ecb45dccac93e543e565c01a787e16a43501f7ee - md5: c7e925f37e3b40d893459e625f6a53f1 + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 113478 + timestamp: 1775825492909 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda + sha256: 927fe72b054277cde6cb82597d0fcf6baf127dcbce2e0a9d8925a68f1265eef5 + md5: d864d34357c3b65a4b731f78c0801dc4 depends: - __glibc >=2.17,<3.0.a0 - libgcc >=13 - license: BSD-2-Clause - license_family: BSD + license: LGPL-2.1-only + license_family: GPL purls: [] - size: 91183 - timestamp: 1748393666725 + run_exports: + weak: + - libnsl >=2.0.1,<2.1.0a0 + size: 33731 + timestamp: 1750274110928 - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.20-h4ab18f5_0.conda sha256: 0105bd108f19ea8e6a78d2d994a6d4a8db16d19a41212070d2d1d48a63c34161 md5: a587892d3c13b6621a6091be690dbca2 @@ -2665,17 +2773,21 @@ packages: purls: [] size: 205978 timestamp: 1716828628198 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda - sha256: 6d9c32fc369af5a84875725f7ddfbfc2ace795c28f246dc70055a79f9b2003da - md5: 0b367fad34931cb79e0d6b7e5c06bb1c +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + sha256: 72023efc207fe681e26b65fc9d668062cf0b4f0eacf3431e6eb099b95c1f2efd + md5: df088a279cd5e6fd2790b4c196434da1 depends: - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 - libgcc >=14 - - libzlib >=1.3.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 license: blessing purls: [] - size: 932581 - timestamp: 1753948484112 + run_exports: + weak: + - libsqlite >=3.53.4,<4.0a0 + size: 964200 + timestamp: 1785016112246 - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.1.0-h8f9b012_5.conda sha256: 0f5f61cab229b6043541c13538d75ce11bd96fb2db76f94ecf81997b1fde6408 md5: 4e02a49aaa9d5190cb630fa43528fbe6 @@ -2697,55 +2809,78 @@ packages: purls: [] size: 29233 timestamp: 1757042603319 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda - sha256: e5ec6d2ad7eef538ddcb9ea62ad4346fde70a4736342c4ad87bd713641eb9808 - md5: 80c07c68d2f6870250959dcc95b209d1 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + sha256: 9b1bdce27a7e31f7d241aeecff67a1f3101d52a2b1e33ccc2cdf2613072bf81f + md5: 01bb81d12c957de066ea7362007df642 depends: - - __glibc >=2.17,<3.0.a0 - libgcc >=14 + - __glibc >=2.17,<3.0.a0 license: BSD-3-Clause + license_family: BSD purls: [] - size: 37135 - timestamp: 1758626800002 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda - sha256: d4bfe88d7cb447768e31650f06257995601f89076080e76df55e3112d4e47dc4 - md5: edb0dca6bc32e4f4789199455a1dbeb8 + run_exports: + weak: + - libuuid >=2.42.2,<3.0a0 + size: 40017 + timestamp: 1781625522462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda + sha256: f7e9292dd219a6435bbb1223da9586c3e70d66d169c5a92f08db3f2127df04e9 + md5: f7a7ff5a6ab331e037abd34f379a631d + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libxcrypt >=4.4.38 + size: 101957 + timestamp: 1785887123445 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + sha256: eb8a0db0aa570124f7d2a93d7c7f596e3390df5e047818d873baad32985fc736 + md5: 0de0122d9570a8ab637c6b73db268389 depends: - __glibc >=2.17,<3.0.a0 - - libgcc >=13 constrains: - - zlib 1.3.1 *_2 + - zlib 1.3.2 *_3 license: Zlib license_family: Other purls: [] - size: 60963 - timestamp: 1727963148474 -- conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.2-py313h8060acc_1.conda - sha256: d812caf52efcea7c9fd0eafb21d45dadfd0516812f667b928bee50e87634fae5 - md5: 21b62c55924f01b6eef6827167b46acb + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 63713 + timestamp: 1785362952714 +- conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py311h3778330_1.conda + sha256: 710e207b2e91308a34bcfe547c60ad86c1fa294827266ba18548c1fe1a9d8333 + md5: f9efdf9b0f3d0cc309d56af6edf2a6b0 depends: - __glibc >=2.17,<3.0.a0 - - libgcc >=13 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - libgcc >=14 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 constrains: - jinja2 >=3.0.0 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/markupsafe?source=hash-mapping - size: 24856 - timestamp: 1733219782830 -- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda - sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586 - md5: 47e340acb35de30501a76c7c799c41d7 + run_exports: {} + size: 26756 + timestamp: 1772445078834 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + sha256: fc89f74bbe362fb29fa3c037697a89bec140b346a2469a90f7936d1d7ea4d8a3 + md5: fc21868a1a5aacc937e7a18747acb8a5 depends: - __glibc >=2.17,<3.0.a0 - - libgcc >=13 + - libgcc >=14 license: X11 AND BSD-3-Clause purls: [] - size: 891641 - timestamp: 1738195959188 + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 918956 + timestamp: 1777422145199 - conda: https://conda.anaconda.org/conda-forge/linux-64/nh3-0.3.2-py310h1570de5_0.conda noarch: python sha256: f6095c759df15baa9cccc20394b21667f4d0440f3c432e07539c3b47ef195c0b @@ -2764,9 +2899,9 @@ packages: - pkg:pypi/nh3?source=hash-mapping size: 669207 timestamp: 1761831302988 -- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.3-h26f9b46_1.conda - sha256: 0572be1b7d3c4f4c288bb8ab1cb6007b5b8b9523985b34b862b5222dea3c45f5 - md5: 4fc6c4c88da64c0219c0c6c0408cedd4 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + sha256: 012096056b97abf1f68c46b7146bd2cbd68c1be762340b4f5dad4fbbe99177bc + md5: c5955c27917ff2234def47f075e71e02 depends: - __glibc >=2.17,<3.0.a0 - ca-certificates @@ -2774,11 +2909,23 @@ packages: license: Apache-2.0 license_family: Apache purls: [] - size: 3128517 - timestamp: 1758597915858 -- conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.46-h1321c63_0.conda - sha256: 5c7380c8fd3ad5fc0f8039069a45586aa452cf165264bc5a437ad80397b32934 - md5: 7fa07cb0fb1b625a089ccc01218ee5b1 + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3182423 + timestamp: 1785913583650 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pandoc-3.8.3-ha770c72_0.conda + sha256: 87ec986d1e0d16d9d2aa149653abeb73d1ac4bd9e6d7dc13ba33ec00134c8a7a + md5: 0e4aa34e44a68aeb850349fe51a6a3d0 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: {} + size: 22458834 + timestamp: 1764589637843 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda + sha256: 5e6f7d161356fefd981948bea5139c5aa0436767751a6930cb1ca801ebb113ff + md5: 7a3bff861a6583f1889021facefc08b1 depends: - __glibc >=2.17,<3.0.a0 - bzip2 >=1.0.8,<2.0a0 @@ -2787,139 +2934,178 @@ packages: license: BSD-3-Clause license_family: BSD purls: [] - size: 1209177 - timestamp: 1756742976157 -- conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.1.3-py313h54dd161_0.conda - sha256: 26cf5a69d04ef66f03516b8a8211a43bb23d5225faacd7d36e5c987b0d66af0a - md5: 1d719fc61f91ab2644a2eeb35fcab360 + run_exports: + weak: + - pcre2 >=10.47,<10.48.0a0 + size: 1222481 + timestamp: 1763655398280 +- conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py311haee01d2_0.conda + sha256: 8d9325af538a8f56013e42bbb91a4dc6935aece34476e20bafacf6007b571e86 + md5: 2ed8f6fe8b51d8e19f7621941f7bb95f depends: - python - - libgcc >=14 - __glibc >=2.17,<3.0.a0 - - python_abi 3.13.* *_cp313 + - libgcc >=14 + - python_abi 3.11.* *_cp311 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/psutil?source=hash-mapping - size: 501735 - timestamp: 1762092897061 -- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.7-h2b335a9_100_cp313.conda - build_number: 100 - sha256: 16cc30a5854f31ca6c3688337d34e37a79cdc518a06375fe3482ea8e2d6b34c8 - md5: 724dcf9960e933838247971da07fe5cf + run_exports: {} + size: 231786 + timestamp: 1769678156460 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.11.15-h7508c33_1_cpython.conda + build_number: 1 + sha256: e830c8c69605674a997ee280d79c0f05ff5c1ed80ce3743678b2f663f410dfb9 + md5: fa29f621acaa9c0db5fd2c0ffc65312c depends: - __glibc >=2.17,<3.0.a0 - bzip2 >=1.0.8,<2.0a0 - ld_impl_linux-64 >=2.36.1 - - libexpat >=2.7.1,<3.0a0 - - libffi >=3.4.6,<3.5.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 - libgcc >=14 - - liblzma >=5.8.1,<6.0a0 - - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.50.4,<4.0a0 - - libuuid >=2.38.1,<3.0a0 - - libzlib >=1.3.1,<2.0a0 - - ncurses >=6.5,<7.0a0 - - openssl >=3.5.2,<4.0a0 - - python_abi 3.13.* *_cp313 - - readline >=8.2,<9.0a0 + - liblzma >=5.8.3,<6.0a0 + - libnsl >=2.0.1,<2.1.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libuuid >=2.42.1,<3.0a0 + - libxcrypt >=4.4.36 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - readline >=8.3,<9.0a0 - tk >=8.6.13,<8.7.0a0 - tzdata + constrains: + - python_abi 3.11.* *_cp311 license: Python-2.0 purls: [] - size: 33583088 - timestamp: 1756911465277 - python_site_packages_path: lib/python3.13/site-packages -- conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.2-py313h8060acc_2.conda - sha256: 6826217690cfe92d6d49cdeedb6d63ab32f51107105d6a459d30052a467037a0 - md5: 50992ba61a8a1f8c2d346168ae1c86df + run_exports: + weak: + - python_abi 3.11.* *_cp311 + noarch: + - python + size: 30907259 + timestamp: 1781149782225 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py311h3778330_1.conda + sha256: c9a6cd2c290d7c3d2b30ea34a0ccda30f770e8ddb2937871f2c404faf60d0050 + md5: a24add9a3bababee946f3bc1c829acfe depends: - __glibc >=2.17,<3.0.a0 - - libgcc >=13 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - libgcc >=14 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 - yaml >=0.2.5,<0.3.0a0 license: MIT license_family: MIT purls: - pkg:pypi/pyyaml?source=hash-mapping - size: 205919 - timestamp: 1737454783637 -- conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hfb55c3c_0.conda - noarch: python - sha256: a00a41b66c12d9c60e66b391e9a4832b7e28743348cf4b48b410b91927cd7819 - md5: 3399d43f564c905250c1aea268ebb935 + run_exports: {} + size: 206190 + timestamp: 1770223702917 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py311hca8cfc1_3.conda + sha256: 2c15f4c797f3f0c2ee8f4446294b9cea60a20ca724e0ab3212d2930926f08757 + md5: efdc6d747c064d0c4cd6fcbbc9c57c58 depends: - python - - __glibc >=2.17,<3.0.a0 - libstdcxx >=14 - libgcc >=14 - - _python_abi3_support 1.* - - cpython >=3.12 + - __glibc >=2.17,<3.0.a0 - zeromq >=4.3.5,<4.4.0a0 + - python_abi 3.11.* *_cp311 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/pyzmq?source=hash-mapping - size: 212218 - timestamp: 1757387023399 -- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda - sha256: 2d6d0c026902561ed77cd646b5021aef2d4db22e57a5b0178dfc669231e06d2c - md5: 283b96675859b20a825f8fa30f311446 + run_exports: {} + size: 383048 + timestamp: 1779483879092 +- conda: https://conda.anaconda.org/conda-forge/linux-64/quarto-1.9.38-h920450f_0.conda + sha256: d94e426980932a2dfbcf4a43c48d600d473c355a7b11477911c3b13ad24e4b5d + md5: f13ebac1f2b59219c012fa992757be1c depends: - - libgcc >=13 + - __glibc >=2.17,<3.0.a0 + - dart-sass + - deno >=2.4.5,<2.4.6.0a0 + - deno-dom >=0.1.41,<0.1.42.0a0 + - esbuild + - libgcc >=14 + - openssl >=3.5.6,<4.0a0 + - pandoc 3.8.3 + - typst 0.14.2 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 26049109 + timestamp: 1779789331822 +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 - ncurses >=6.5,<7.0a0 license: GPL-3.0-only license_family: GPL purls: [] - size: 282480 - timestamp: 1740379431762 -- conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-0.30.0-py313h843e2db_0.conda - sha256: 076d26e51c62c8ecfca6eb19e3c1febdd7632df1990a7aa53da5df5e54482b1c - md5: 779e3307a0299518713765b83a36f4b1 + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 345073 + timestamp: 1765813471974 +- conda: https://conda.anaconda.org/conda-forge/linux-64/rpds-py-2026.6.3-py311h1baac5b_0.conda + sha256: ec844a6df82774ff6790e60ddf6137cac54ee5d41fe2226f0da2bad0ab84e1ae + md5: 0311b3d56cad517ab1a80fd4f9332e3f depends: - python - libgcc >=14 - __glibc >=2.17,<3.0.a0 - - python_abi 3.13.* *_cp313 + - python_abi 3.11.* *_cp311 constrains: - __glibc >=2.17 license: MIT + license_family: MIT purls: - - pkg:pypi/rpds-py?source=compressed-mapping - size: 383230 - timestamp: 1764543223529 -- conda: https://conda.anaconda.org/conda-forge/linux-64/secretstorage-3.4.1-py313h78bf25f_0.conda - sha256: 43ea89b53cbede879e57ac9dd20153c5cd2bb9575228e7faf5a8764aa6c201b7 - md5: 013a7d73eaef154f0dc5e415ffa8ff87 + - pkg:pypi/rpds-py?source=hash-mapping + run_exports: {} + size: 299898 + timestamp: 1782831307838 +- conda: https://conda.anaconda.org/conda-forge/linux-64/secretstorage-3.5.0-py311h38be061_0.conda + sha256: 2a9e51caca89cad9e7079fbe05ca6bcfc29503297a312dd4b00e30f080e3fa3e + md5: 7aca89ddc1696400582cf6b4de8cf75e depends: - cryptography >=2.0 - dbus - jeepney >=0.6 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/secretstorage?source=hash-mapping - size: 32933 - timestamp: 1763045369115 -- conda: https://conda.anaconda.org/conda-forge/linux-64/sqlalchemy-2.0.44-py313h07c4f96_0.conda - sha256: 0c67d1f65388aa8ba2ed9a577ada786a0443cf696209b856ed085cf4fee80081 - md5: 57247c3902e6466acad8dad9963bb146 + run_exports: {} + size: 33898 + timestamp: 1780858514693 +- conda: https://conda.anaconda.org/conda-forge/linux-64/sqlalchemy-2.0.51-py311haee01d2_0.conda + sha256: 5c8b9c1dd529ac37a37eca60b866a789746dd6c457fb9891fc455eeb1fa8ac26 + md5: 495dfb5670b33cf4f4db4b01ef0250aa depends: - - __glibc >=2.17,<3.0.a0 + - python - greenlet !=0.4.17 - - libgcc >=14 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - typing-extensions >=4.6.0 + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python_abi 3.11.* *_cp311 license: MIT license_family: MIT purls: - pkg:pypi/sqlalchemy?source=hash-mapping - size: 3671691 - timestamp: 1760114574436 + run_exports: {} + size: 3766089 + timestamp: 1781547880247 - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda sha256: a84ff687119e6d8752346d1d408d5cf360dee0badd487a472aa8ddedfdc219e1 md5: a0116df4f4ed05c303811a837d5b39d8 @@ -2932,20 +3118,53 @@ packages: purls: [] size: 3285204 timestamp: 1748387766691 -- conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.2-py313h07c4f96_2.conda - sha256: 8ef12814ebf787553b351c919d40a599e2331aefec639aef5ce6117cbcfc6a28 - md5: 7824f18e343d1f846dcde7b23c9bf31a +- conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.7-py311h49ec1c0_0.conda + sha256: c3174851462658028eb8e437869d19a03557621715c6cda46a14474ef0441b4e + md5: 035d21b7fa6e04c32dc4650da7bea88b depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 license: Apache-2.0 license_family: Apache purls: - pkg:pypi/tornado?source=hash-mapping - size: 871569 - timestamp: 1762506888003 + run_exports: {} + size: 881976 + timestamp: 1781006805257 +- conda: https://conda.anaconda.org/conda-forge/linux-64/typst-0.14.2-h4c46f8d_0.conda + sha256: 2051dee6a67d43a0ff0fd2ff9dacd8ef533e89d0b30feedf87613fc84b00c247 + md5: 6733bfb9b4338039e182ca3a41253ab5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - openssl >=3.5.4,<4.0a0 + constrains: + - __glibc >=2.17 + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: {} + size: 15445965 + timestamp: 1765697444419 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py311hdf67eae_0.conda + sha256: 31ad759e2d7668f1615021bca0ae2358a284939e3ca3fcb971b3f7aa83c2a6d6 + md5: 5a715cf5e3dc6cd68717c50e47dd7b6b + depends: + - __glibc >=2.17,<3.0.a0 + - cffi + - libgcc >=14 + - libstdcxx >=14 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 + license: MIT + license_family: MIT + purls: + - pkg:pypi/ukkonen?source=hash-mapping + run_exports: {} + size: 14898 + timestamp: 1769438724694 - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.9.17-h76e24b7_0.conda sha256: f4b58b6fac4950aa39e4e3aa78d07b2a83bdc9295c4fd4b723b9fc9342170a4d md5: 4cae6aca4d10274f47e4779a5a19a73d @@ -2959,19 +3178,6 @@ packages: purls: [] size: 17685917 timestamp: 1765353152876 -- conda: https://conda.anaconda.org/conda-forge/linux-64/watchdog-6.0.0-py313h78bf25f_1.conda - sha256: 73225611a442fdebc9d333951037a428f6e9b8449d4194030457c026970a3283 - md5: d643d87d7df61d3d47db87bdd4571ec6 - depends: - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - - pyyaml >=3.10 - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/watchdog?source=hash-mapping - size: 144014 - timestamp: 1756135338277 - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda sha256: 6d9ea2f731e284e9316d95fa61869fe7bbba33df7929f82693c121022810f4ad md5: a77f85f77be52ff59391544bfe73390a @@ -2998,9 +3204,9 @@ packages: purls: [] size: 310648 timestamp: 1757370847287 -- conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py313h54dd161_0.conda - sha256: 9d79d176afe50361cc3fd4366bedff20852dbea1e5b03f358b55f12aca22d60d - md5: 1fe43bd1fc86e22ad3eb0edec637f8a2 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py311haee01d2_1.conda + sha256: d534a6518c2d8eccfa6579d75f665261484f0f2f7377b50402446a9433d46234 + md5: ca45bfd4871af957aaa5035593d5efd2 depends: - python - cffi >=1.11 @@ -3008,13 +3214,14 @@ packages: - libgcc >=14 - __glibc >=2.17,<3.0.a0 - zstd >=1.5.7,<1.6.0a0 - - python_abi 3.13.* *_cp313 + - python_abi 3.11.* *_cp311 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/zstandard?source=hash-mapping - size: 471152 - timestamp: 1757930114245 + run_exports: {} + size: 466893 + timestamp: 1762512695614 - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda sha256: a4166e3d8ff4e35932510aaff7aa90772f84b4d07e9f6f83c614cba7ceefe0eb md5: 6432cb5d4ac0046c3ac0a8a0f95842f9 @@ -3183,17 +3390,6 @@ packages: - pkg:pypi/backports-tarfile?source=hash-mapping size: 32786 timestamp: 1733325872620 -- conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda - sha256: 3a0af23d357a07154645c41d035a4efbd15b7a642db397fa9ea0193fd58ae282 - md5: b16e2595d3a9042aa9d570375978835f - depends: - - python >=3.9 - license: MIT - license_family: MIT - purls: - - pkg:pypi/backrefs?source=hash-mapping - size: 143810 - timestamp: 1740887689966 - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda sha256: bf1e71c3c0a5b024e44ff928225a0874fc3c3356ec1a0b6fe719108e6d1288f6 md5: 5267bef8efea4127aacd1f4e1f149b6e @@ -3278,9 +3474,21 @@ packages: - python >=3.9 license: ISC purls: - - pkg:pypi/certifi?source=compressed-mapping + - pkg:pypi/certifi?source=hash-mapping size: 158692 timestamp: 1754231530168 +- conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + sha256: aa589352e61bb221351a79e5946d56916e3c595783994884accdb3b97fe9d449 + md5: 381bd45fb7aa032691f3063aff47e3a1 + depends: + - python >=3.10 + license: MIT + license_family: MIT + purls: + - pkg:pypi/cfgv?source=hash-mapping + run_exports: {} + size: 13589 + timestamp: 1763607964133 - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.3-pyhd8ed1ab_0.conda sha256: 838d5a011f0e7422be6427becba3de743c78f3874ad2743c341accbba9bb2624 md5: 7e7d5ef1b9ed630e4a1c358d6bc62284 @@ -3301,7 +3509,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/click?source=compressed-mapping + - pkg:pypi/click?source=hash-mapping size: 91622 timestamp: 1758270534287 - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.0-pyh7428d3b_0.conda @@ -3314,7 +3522,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/click?source=compressed-mapping + - pkg:pypi/click?source=hash-mapping size: 92148 timestamp: 1758270588199 - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda @@ -3340,17 +3548,18 @@ packages: - pkg:pypi/comm?source=hash-mapping size: 14690 timestamp: 1753453984907 -- conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda +- conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.11.15-py311hd8ed1ab_1.conda noarch: generic - sha256: 31da683e8a15e2062adfb29c9fb23d4253550a0b3c9be1cd45530f88796b4644 - md5: 367133808e89325690562099851529c8 + sha256: cfe29a7e71ab4553b9715ee4b6788824853ade58b8661ff3363acb3e762046a5 + md5: 842533c9d507e2025a4933a091dfa983 depends: - - python >=3.13,<3.14.0a0 - - python_abi * *_cp313 + - python >=3.11,<3.12.0a0 + - python_abi * *_cp311 license: Python-2.0 purls: [] - size: 48397 - timestamp: 1761175097707 + run_exports: {} + size: 48482 + timestamp: 1781148385557 - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.2.1-pyhd8ed1ab_0.conda sha256: c17c6b9937c08ad63cb20a26f403a3234088e57d4455600974a0ce865cb14017 md5: 9ce473d1d1be1cc3810856a48b3fab32 @@ -3391,7 +3600,7 @@ packages: - python >=3.10 license: CC-PDDC AND BSD-3-Clause AND BSD-2-Clause AND ZPL-2.1 purls: - - pkg:pypi/docutils?source=compressed-mapping + - pkg:pypi/docutils?source=hash-mapping size: 438002 timestamp: 1766092633160 - conda: https://conda.anaconda.org/conda-forge/noarch/editables-0.5-pyhd8ed1ab_1.conda @@ -3449,18 +3658,6 @@ packages: - pkg:pypi/fqdn?source=hash-mapping size: 16705 timestamp: 1733327494780 -- conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda - sha256: 40fdf5a9d5cc7a3503cd0c33e1b90b1e6eab251aaaa74e6b965417d089809a15 - md5: 93f742fe078a7b34c29a182958d4d765 - depends: - - python >=3.9 - - python-dateutil >=2.8.1 - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/ghp-import?source=hash-mapping - size: 16538 - timestamp: 1734344477841 - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhd8ed1ab_0.conda sha256: f64b68148c478c3bfc8f8d519541de7d2616bf59d44485a5271041d40c061887 md5: 4b69232755285701bc86a5afe4d9933a @@ -3484,7 +3681,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/h2?source=compressed-mapping + - pkg:pypi/h2?source=hash-mapping size: 95967 timestamp: 1756364871835 - conda: https://conda.anaconda.org/conda-forge/noarch/hatch-1.16.2-pyhcf101f3_0.conda @@ -3615,6 +3812,19 @@ packages: - pkg:pypi/id?source=hash-mapping size: 24444 timestamp: 1737528654512 +- conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda + sha256: 381cedccf0866babfc135d65ee40b778bd20e927d2a5ec810f750c5860a7c5b8 + md5: 84a3233b709a289a4ddd7a2fd27dd988 + depends: + - python >=3.10 + - ukkonen + license: MIT + license_family: MIT + purls: + - pkg:pypi/identify?source=hash-mapping + run_exports: {} + size: 79757 + timestamp: 1776455344188 - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.10-pyhd8ed1ab_1.conda sha256: d7a472c9fd479e2e8dcb83fb8d433fce971ea369d704ece380e876f9c3494e87 md5: 39a4f67be3286c86d696df570b1201b7 @@ -3899,6 +4109,19 @@ packages: - pkg:pypi/json5?source=hash-mapping size: 34191 timestamp: 1755034963991 +- conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda + sha256: a3d10301b6ff399ba1f3d39e443664804a3d28315a4fb81e745b6817845f70ae + md5: 89bf346df77603055d3c8fe5811691e6 + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/jsonpointer?source=hash-mapping + run_exports: {} + size: 14190 + timestamp: 1774311356147 - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda sha256: ac377ef7762e49cb9c4f985f1281eeff471e9adc3402526eea78e6ac6589cf1d md5: 341fd940c242cf33e832c0402face56f @@ -4098,7 +4321,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/jupyterlab?source=compressed-mapping + - pkg:pypi/jupyterlab?source=hash-mapping size: 8323112 timestamp: 1763479901072 - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda @@ -4196,7 +4419,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/lark?source=compressed-mapping + - pkg:pypi/lark?source=hash-mapping size: 94312 timestamp: 1761596921009 - conda: https://conda.anaconda.org/conda-forge/noarch/mako-1.3.10-pyhcf101f3_1.conda @@ -4210,21 +4433,9 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/mako?source=compressed-mapping + - pkg:pypi/mako?source=hash-mapping size: 71947 timestamp: 1764678340587 -- conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.9-pyhd8ed1ab_0.conda - sha256: d05fa62dd1fedfa4c6bc86fe5a34d8a256d7a5b051edc14678371cfb3e8d5e87 - md5: 17784de2c4da64a3595328b34982cdc5 - depends: - - importlib-metadata >=4.4 - - python >=3.10 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/markdown?source=compressed-mapping - size: 80879 - timestamp: 1757093529525 - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda sha256: 7b1da4b5c40385791dbc3cc85ceea9fad5da680a27d5d3cb8bfaa185e304a89e md5: 5b5203189eb668f042ac2b0826244964 @@ -4246,7 +4457,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/matplotlib-inline?source=compressed-mapping + - pkg:pypi/matplotlib-inline?source=hash-mapping size: 15175 timestamp: 1761214578417 - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda @@ -4260,17 +4471,6 @@ packages: - pkg:pypi/mdurl?source=hash-mapping size: 14465 timestamp: 1733255681319 -- conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda - sha256: e5b555fd638334a253d83df14e3c913ef8ce10100090e17fd6fb8e752d36f95d - md5: d9a8fc1f01deae61735c88ec242e855c - depends: - - python >=3.9 - license: MIT - license_family: MIT - purls: - - pkg:pypi/mergedeep?source=hash-mapping - size: 11676 - timestamp: 1734157119152 - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.1.4-pyhcf101f3_0.conda sha256: 609ea628ace5c6cdbdce772704e6cb159ead26969bb2f386ca1757632b0f74c6 md5: f5a4d548d1d3bdd517260409fc21e205 @@ -4284,117 +4484,6 @@ packages: - pkg:pypi/mistune?source=hash-mapping size: 72996 timestamp: 1756495311698 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda - sha256: 902d2e251f9a7ffa7d86a3e62be5b2395e28614bd4dbe5f50acf921fd64a8c35 - md5: 14661160be39d78f2b210f2cc2766059 - depends: - - click >=7.0 - - colorama >=0.4 - - ghp-import >=1.0 - - importlib-metadata >=4.4 - - jinja2 >=2.11.1 - - markdown >=3.3.6 - - markupsafe >=2.0.1 - - mergedeep >=1.3.4 - - mkdocs-get-deps >=0.2.0 - - packaging >=20.5 - - pathspec >=0.11.1 - - python >=3.9 - - pyyaml >=5.1 - - pyyaml-env-tag >=0.1 - - watchdog >=2.0 - constrains: - - babel >=2.9.0 - license: BSD-2-Clause - license_family: BSD - purls: - - pkg:pypi/mkdocs?source=hash-mapping - size: 3524754 - timestamp: 1734344673481 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-autorefs-1.4.3-pyhd8ed1ab_0.conda - sha256: 1631568d0d36bc182ec20c5b4c58cc053cdd77698b4741977776f592996d345b - md5: 1c024504ac97f1199023327a69066a8f - depends: - - markdown >=3.3 - - markupsafe >=2.0.1 - - mkdocs >=1.1 - - pymdown-extensions - - python >=3.10 - license: ISC - purls: - - pkg:pypi/mkdocs-autorefs?source=compressed-mapping - size: 35016 - timestamp: 1756236211878 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda - sha256: e0b501b96f7e393757fb2a61d042015966f6c5e9ac825925e43f9a6eafa907b6 - md5: 84382acddb26c27c70f2de8d4c830830 - depends: - - importlib-metadata >=4.3 - - mergedeep >=1.3.4 - - platformdirs >=2.2.0 - - python >=3.9 - - pyyaml >=5.1 - license: MIT - license_family: MIT - purls: - - pkg:pypi/mkdocs-get-deps?source=hash-mapping - size: 14757 - timestamp: 1734353035244 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.6.20-pyhcf101f3_0.conda - sha256: 7ac586c59a61ddde28b0bc22f6813c1cf981ef2835f22b1aa3a0b05a301e8dad - md5: a2c6536fe3c53f22cf2369c86556ad29 - depends: - - python >=3.10 - - jinja2 >=3.0,<4.dev0 - - markdown >=3.2,<4.dev0 - - mkdocs >=1.6,<2.dev0 - - mkdocs-material-extensions >=1.3,<2.dev0 - - pygments >=2.16,<3.dev0 - - pymdown-extensions >=10.2,<11.dev0 - - babel >=2.10,<3.dev0 - - colorama >=0.4,<1.dev0 - - paginate >=0.5,<1.dev0 - - backrefs >=5.7.post1,<6.dev0 - - requests >=2.26,<3.dev0 - - python - license: MIT - license_family: MIT - purls: - - pkg:pypi/mkdocs-material?source=hash-mapping - size: 4742738 - timestamp: 1757960338257 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda - sha256: f62955d40926770ab65cc54f7db5fde6c073a3ba36a0787a7a5767017da50aa3 - md5: de8af4000a4872e16fb784c649679c8e - depends: - - python >=3.9 - constrains: - - mkdocs-material >=5.0.0 - license: MIT - license_family: MIT - purls: - - pkg:pypi/mkdocs-material-extensions?source=hash-mapping - size: 16122 - timestamp: 1734641109286 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocstrings-0.30.1-pyhd8ed1ab_0.conda - sha256: bbbf409f50442f196debddcd0be5135c5fc6e25bd656185975383e0ee23b741d - md5: 04b12b1aef52ba590a7ec9a78c77a51a - depends: - - click >=7.0 - - importlib-metadata >=4.6 - - jinja2 >=2.11.1 - - markdown >=3.6 - - markupsafe >=1.1 - - mkdocs >=1.6 - - mkdocs-autorefs >=1.4 - - pymdown-extensions >=6.3 - - python >=3.10,<4.0 - - typing-extensions >=4.1 - license: ISC - purls: - - pkg:pypi/mkdocstrings?source=hash-mapping - size: 35475 - timestamp: 1758481651047 - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.8.0-pyhcf101f3_1.conda sha256: 449609f0d250607a300754474350a3b61faf45da183d3071e9720e453c765b8a md5: 32f78e9d06e8593bc4bbf1338da06f5f @@ -4449,7 +4538,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/nbconvert?source=compressed-mapping + - pkg:pypi/nbconvert?source=hash-mapping size: 199273 timestamp: 1760797634443 - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda @@ -4478,6 +4567,19 @@ packages: - pkg:pypi/nest-asyncio?source=hash-mapping size: 11543 timestamp: 1733325673691 +- conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + sha256: 4fa40e3e13fc6ea0a93f67dfc76c96190afd7ea4ffc1bac2612d954b42cdc3ee + md5: eb52d14a901e23c39e9e7b4a1a5c015f + depends: + - python >=3.10 + - setuptools + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/nodeenv?source=hash-mapping + run_exports: {} + size: 40866 + timestamp: 1766261270149 - conda: https://conda.anaconda.org/conda-forge/noarch/notebook-shim-0.2.4-pyhd8ed1ab_1.conda sha256: 7b920e46b9f7a2d2aa6434222e5c8d739021dbc5cc75f32d124a8191d86f9056 md5: e7f89ea5f7ea9401642758ff50a2d9c1 @@ -4514,17 +4616,6 @@ packages: - pkg:pypi/packaging?source=hash-mapping size: 62477 timestamp: 1745345660407 -- conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda - sha256: f6fef1b43b0d3d92476e1870c08d7b9c229aebab9a0556b073a5e1641cf453bd - md5: c3f35453097faf911fd3f6023fc2ab24 - depends: - - python >=3.9 - license: MIT - license_family: MIT - purls: - - pkg:pypi/paginate?source=hash-mapping - size: 18865 - timestamp: 1734618649164 - conda: https://conda.anaconda.org/conda-forge/noarch/pandocfilters-1.5.0-pyhd8ed1ab_0.tar.bz2 sha256: 2bb9ba9857f4774b85900c2562f7e711d08dd48e2add9bee4e1612fbee27e16f md5: 457c2c8c08e54905d6954e79cb5b5db9 @@ -4579,7 +4670,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/platformdirs?source=compressed-mapping + - pkg:pypi/platformdirs?source=hash-mapping size: 23653 timestamp: 1756227402815 - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda @@ -4593,6 +4684,23 @@ packages: - pkg:pypi/pluggy?source=hash-mapping size: 24246 timestamp: 1747339794916 +- conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.1-pyha770c72_0.conda + sha256: d1d528a9933378ea3734f3ecac83ed4989dc5cba6c0427b237cf35228873a259 + md5: 5f7aee6aa51642db4b57a7a11611dda1 + depends: + - cfgv >=2.0.0 + - identify >=1.0.0 + - nodeenv >=0.11.1 + - python >=3.10 + - pyyaml >=5.1 + - virtualenv >=20.10.0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pre-commit?source=hash-mapping + run_exports: {} + size: 202254 + timestamp: 1784706397465 - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.23.1-pyhd8ed1ab_0.conda sha256: 13dc67de68db151ff909f2c1d2486fa7e2d51355b25cee08d26ede1b62d48d40 md5: a1e91db2d17fd258c64921cb38e6745a @@ -4662,19 +4770,6 @@ packages: - pkg:pypi/pygments?source=hash-mapping size: 889287 timestamp: 1750615908735 -- conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.16.1-pyhd8ed1ab_0.conda - sha256: 8f575f123694e5acd2829440da55828f2cea60b0af5d8fa5406d83251ba80f61 - md5: 26e013bc453e643991cfa9b76911fb79 - depends: - - markdown >=3.6 - - python >=3.9 - - pyyaml - license: MIT - license_family: MIT - purls: - - pkg:pypi/pymdown-extensions?source=hash-mapping - size: 170121 - timestamp: 1753743741894 - conda: https://conda.anaconda.org/conda-forge/noarch/pyproject_hooks-1.2.0-pyhd8ed1ab_1.conda sha256: 065ac44591da9abf1ff740feb25929554b920b00d09287a551fcced2c9791092 md5: d4582021af437c931d7d77ec39007845 @@ -4758,16 +4853,17 @@ packages: - pkg:pypi/fastjsonschema?source=hash-mapping size: 244628 timestamp: 1755304154927 -- conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda - sha256: 7535b9cb2414e34c73ed4a97a90bcadcc76b9d47d0bb8ef5002c592d85fe022d - md5: f41e3c1125e292e6bfcea8392a3de3d8 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.11.15-hd8ed1ab_1.conda + sha256: 9eed0e05f90866823f7dbb2092c79076b8f11a34c7171165df02532d0ff34cce + md5: 336ca63d560b4a4004d4c0fdf78a9075 depends: - - cpython 3.13.9.* - - python_abi * *_cp313 + - cpython 3.11.15.* + - python_abi * *_cp311 license: Python-2.0 purls: [] - size: 48385 - timestamp: 1761175154112 + run_exports: {} + size: 48417 + timestamp: 1781148405955 - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda sha256: 4790787fe1f4e8da616edca4acf6a4f8ed4e7c6967aa31b920208fc8f95efcca md5: a61bf9ec79426938ff785eb69dbb1960 @@ -4790,17 +4886,18 @@ packages: - pkg:pypi/tzdata?source=hash-mapping size: 144160 timestamp: 1742745254292 -- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda build_number: 8 - sha256: 210bffe7b121e651419cb196a2a63687b087497595c9be9d20ebe97dd06060a7 - md5: 94305520c52a4aa3f6c2b1ff6008d9f8 + sha256: fddf123692aa4b1fc48f0471e346400d9852d96eeed77dbfdd746fa50a8ff894 + md5: 8fcb6b0e2161850556231336dae58358 constrains: - - python 3.13.* *_cp313 + - python 3.11.* *_cpython license: BSD-3-Clause license_family: BSD purls: [] - size: 7002 - timestamp: 1752805902938 + run_exports: {} + size: 7003 + timestamp: 1752805919375 - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda sha256: 8d2a8bf110cc1fc3df6904091dead158ba3e614d8402a83e51ed3a8aa93cdeb0 md5: bc8e3267d44011051f2eb14d22fb0960 @@ -4812,18 +4909,6 @@ packages: - pkg:pypi/pytz?source=hash-mapping size: 189015 timestamp: 1742920947249 -- conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda - sha256: 69ab63bd45587406ae911811fc4d4c1bf972d643fa57a009de7c01ac978c4edd - md5: e8e53c4150a1bba3b160eacf9d53a51b - depends: - - python >=3.9 - - pyyaml - license: MIT - license_family: MIT - purls: - - pkg:pypi/pyyaml-env-tag?source=hash-mapping - size: 11137 - timestamp: 1747237061448 - conda: https://conda.anaconda.org/conda-forge/noarch/readme_renderer-44.0-pyhd8ed1ab_1.conda sha256: 66f3adf6aaabf977cfcc22cb65607002b1de4a22bc9fac7be6bb774bc6f85a3a md5: c58dd5d147492671866464405364c0f1 @@ -4942,7 +5027,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/rich?source=compressed-mapping + - pkg:pypi/rich?source=hash-mapping size: 200840 timestamp: 1760026188268 - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.3-pyh0d859eb_1.conda @@ -5025,7 +5110,7 @@ packages: license: Apache-2.0 license_family: Apache purls: - - pkg:pypi/sniffio?source=compressed-mapping + - pkg:pypi/sniffio?source=hash-mapping size: 15698 timestamp: 1762941572482 - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda @@ -5105,7 +5190,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/tinycss2?source=compressed-mapping + - pkg:pypi/tinycss2?source=hash-mapping size: 30906 timestamp: 1763577784986 - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.2.1-pyhe01879c_2.conda @@ -5117,7 +5202,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/tomli?source=compressed-mapping + - pkg:pypi/tomli?source=hash-mapping size: 21238 timestamp: 1753796677376 - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.2.0-pyhd8ed1ab_0.conda @@ -5345,52 +5430,54 @@ packages: - pkg:pypi/zipp?source=hash-mapping size: 22963 timestamp: 1749421737203 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py313h6535dbc_2.conda - sha256: 05ea6fa7109235cfb4fc24526bae1fe82d88bbb5e697ab3945c313f5f041af5b - md5: e23e087109b2096db4cf9a3985bab329 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-25.1.0-py311h9408147_2.conda + sha256: c15fc1aa6184185f1b31670a16b76d59f484d3efa25b467f28d59088cf0085c0 + md5: 501c2a606787bcd55b9ddee776208333 depends: - __osx >=11.0 - cffi >=1.0.1 - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python >=3.11,<3.12.0a0 *_cpython + - python_abi 3.11.* *_cp311 license: MIT license_family: MIT purls: - pkg:pypi/argon2-cffi-bindings?source=hash-mapping - size: 33947 - timestamp: 1762510144907 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/backports.zstd-1.2.0-py313hf42fe89_0.conda - sha256: 7b0ac5df8d6cbcd5a9c20421ac984369c6204b891a4bb2e66c1754416c2115fc - md5: 1d0e9ba81b540cd787ef87e914ef4826 + run_exports: {} + size: 34403 + timestamp: 1762509963182 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/backports.zstd-1.6.0-py311h36d4fbb_0.conda + sha256: 51b1b6c4c7c0b77bc8f145f4dd6d9fcb97ee5bd999cc125a0650ebc632107fbe + md5: ab2cfcf1499efba573df019a9aa1f3dc depends: - python - - python 3.13.* *_cp313 - __osx >=11.0 - - python_abi 3.13.* *_cp313 + - python_abi 3.11.* *_cp311 - zstd >=1.5.7,<1.6.0a0 license: BSD-3-Clause AND MIT AND EPL-2.0 purls: - pkg:pypi/backports-zstd?source=hash-mapping - size: 244691 - timestamp: 1765057712738 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.1.0-py313hb4b7877_4.conda - sha256: a6402a7186ace5c3eb21ed4ce50eda3592c44ce38ab4e9a7ddd57d72b1e61fb3 - md5: 9518cd948fc334d66119c16a2106a959 + run_exports: {} + size: 246885 + timestamp: 1781450824672 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py311hdc60ec4_1.conda + sha256: 617545ec0e97d35ed2ff7852f2581a20c0dda80b366d0c42a43706687f971ba8 + md5: 150cbf381febcf0a5e470a8d066e1bc0 depends: - __osx >=11.0 - libcxx >=19 - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python >=3.11,<3.12.0a0 *_cpython + - python_abi 3.11.* *_cp311 constrains: - - libbrotlicommon 1.1.0 h6caf38d_4 + - libbrotlicommon 1.2.0 hc919400_1 license: MIT license_family: MIT purls: - pkg:pypi/brotli?source=hash-mapping - size: 341104 - timestamp: 1756600117644 + run_exports: {} + size: 359588 + timestamp: 1764018467340 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda sha256: b456200636bd5fecb2bec63f7e0985ad2097cf1b83d60ce0b6968dffa6d02aa1 md5: 58fd217444c2a5701a44244faf518206 @@ -5401,89 +5488,130 @@ packages: purls: [] size: 125061 timestamp: 1757437486465 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py313h89bd988_0.conda - sha256: 97404dd3e363d7fe546ef317502a0f26a4f314b986adc700de2c9840084459cd - md5: 7768e6a259b378e0722b7f64e3f64c80 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.1.1-py311h833bfeb_0.conda + sha256: eb7ccfacea83f4868f6a75481d1c5e893d04090ce48f3d6aae11b275c00cecb1 + md5: f618024eb821cf246056ec2c39ba3c29 depends: - __osx >=11.0 - - libffi >=3.4.6,<3.5.0a0 + - libffi >=3.5.2,<3.6.0a0 - pycparser - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python >=3.11,<3.12.0a0 *_cpython + - python_abi 3.11.* *_cp311 license: MIT + license_family: MIT purls: - - pkg:pypi/cffi?source=compressed-mapping - size: 291107 - timestamp: 1758716485269 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cmarkgfm-2024.11.20-py313hcdf3177_1.conda - sha256: 22ef41f6533fb1241097d4e9db11bbc0191b8a9fd0543cd85dcb56a197191b34 - md5: 49f95432e803116f993f2e680a88bdf3 + - pkg:pypi/cffi?source=hash-mapping + run_exports: {} + size: 299076 + timestamp: 1785811345074 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cmarkgfm-2024.11.20-py311h3696347_1.conda + sha256: 28b92e9d1331fdc7f81a9d39257eea7f3ef69c8cf6d91cbb77ee1e608a1e5213 + md5: a7c6978dd4d1489314b0d3bfb5d9d7ee depends: - __osx >=11.0 - cffi >=1.0.0 - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python >=3.11,<3.12.0a0 *_cpython + - python_abi 3.11.* *_cp311 license: MIT license_family: MIT purls: - pkg:pypi/cmarkgfm?source=hash-mapping - size: 115969 - timestamp: 1760363255005 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.17-py313hc37fe24_0.conda - sha256: ada3d5ab7e33fdefe66b7d21f2a7876e6a00ba6d8866ee1b2101b9a34d1fad66 - md5: 42070edf971f5e14d0f51670ea1fb5e0 + run_exports: {} + size: 116336 + timestamp: 1760363201998 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/dart-sass-1.102.0-h820172f_0.conda + sha256: 03284917c08187847c77758ed5648201c5a835deeb53c4431e012bdfb5bd20b9 + md5: 611d66c6fd646b5b49e5bf5ba76512d1 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 3763399 + timestamp: 1784947183174 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/debugpy-1.8.21-py311h8948835_0.conda + sha256: 2345e7ba2c9deeed28e83a3cf273c45bef23e5d7bfd9de557749dd1d3b85ded0 + md5: 336c8a771fad6546f51b5fe92bd68c96 depends: - python - __osx >=11.0 - libcxx >=19 - - python 3.13.* *_cp313 - - python_abi 3.13.* *_cp313 + - python 3.11.* *_cpython + - python_abi 3.11.* *_cp311 license: MIT license_family: MIT purls: - pkg:pypi/debugpy?source=hash-mapping - size: 2757716 - timestamp: 1758162092566 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/greenlet-3.3.0-py313h0e822ff_0.conda - sha256: 14c20358de6482554c325e47f0ea7971af1f836695ef2a5054096f2afddb4ac0 - md5: 9d0c0b693416989ccec3e44b4b984d2e + run_exports: {} + size: 2675614 + timestamp: 1780390235468 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/deno-2.4.5-h248350f_0.conda + sha256: a58cfdd110f9a08f60ba9636094096a0665bbcd47422fa9eb4aa510801175ec0 + md5: 2190a7bbed5d068ea14c6dc9db1c0f09 depends: - __osx >=11.0 - - libcxx >=19 - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 + - libzlib >=1.3.2,<2.0a0 + constrains: + - __osx >=11.0 license: MIT license_family: MIT - purls: - - pkg:pypi/greenlet?source=hash-mapping - size: 234158 - timestamp: 1764863999368 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda - sha256: 9ba12c93406f3df5ab0a43db8a4b4ef67a5871dfd401010fbe29b218b2cbe620 - md5: 5eb22c1d7b3fc4abb50d92d621583137 + purls: [] + run_exports: {} + size: 35445880 + timestamp: 1776774792403 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/deno-dom-0.1.41-hde76f7a_0.conda + sha256: 4a92d9c785bccae80961875900eb7b86149737b7a219e66b27dfcbfff4a1798b + md5: 02bff7fa3d076f6351ef027cd682c6e4 depends: - __osx >=11.0 + - deno >=1.24.2 + constrains: + - __osx >=11.0 license: MIT license_family: MIT purls: [] - size: 11857802 - timestamp: 1720853997952 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/jsonpointer-3.0.0-py313h8f79df9_2.conda - sha256: 6e6097b156b2c69bcf05842f86a6650eb474477bec5e2233b4b8bcd2936cda5a - md5: 51a61cf0de7b177b4c09fa5eb4775c76 - depends: - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 - license: BSD-3-Clause - license_family: BSD + run_exports: {} + size: 367613 + timestamp: 1736703524102 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/esbuild-0.28.1-hf76c51c_0.conda + sha256: 340c65b3acdbe58b815b363304f93a25dd5cfd96bc0479b24f3d656b71522853 + md5: 7023b247571c6428ac7752c87193d68d + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 4033332 + timestamp: 1781248962480 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/greenlet-3.5.4-py311h8948835_0.conda + sha256: a1988604b1076c7cd3ec309530356df691287407dd713280ebab03b819782849 + md5: a78514f7ca64b3cbd8018dd63054e698 + depends: + - python + - python 3.11.* *_cpython + - libcxx >=19 + - __osx >=11.0 + - python_abi 3.11.* *_cp311 + license: MIT + license_family: MIT purls: - - pkg:pypi/jsonpointer?source=hash-mapping - size: 18604 - timestamp: 1756754452012 + - pkg:pypi/greenlet?source=hash-mapping + run_exports: {} + size: 275019 + timestamp: 1784885594447 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda + sha256: f0b22bc30e4cc29e29ba3234cb38497fe8def2c2aae4b775d42fe5b378a018c9 + md5: 6133ddbb17ba2b50700dd88e9303ce27 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - icu >=78.3,<79.0a0 + size: 14070698 + timestamp: 1784916459058 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.21.3-h237132a_0.conda sha256: 4442f957c3c77d69d9da3521268cad5d54c9033f1a73f99cde0a3658937b159b md5: c6dc8a0fdec13a0565936655c33069a1 @@ -5520,49 +5648,46 @@ packages: purls: [] size: 107691 timestamp: 1738479560845 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.1-hec049ff_0.conda - sha256: 8fbb17a56f51e7113ed511c5787e0dec0d4b10ef9df921c4fd1cccca0458f648 - md5: b1ca5f21335782f71a8bd69bdc093f67 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + sha256: 5af74261101e3c777399c6294b2b5d290e508153268eb2e9ff99c4d69834612f + md5: a915151d5d3c5bf039f5ccc8402a436f depends: - __osx >=11.0 constrains: - - expat 2.7.1.* + - expat 2.8.1.* license: MIT license_family: MIT purls: [] - size: 65971 - timestamp: 1752719657566 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.6-h1da3d7d_1.conda - sha256: c6a530924a9b14e193ea9adfe92843de2a806d1b7dbfd341546ece9653129e60 - md5: c215a60c2935b517dcda8cad4705734d + run_exports: {} + size: 69362 + timestamp: 1781203631990 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + sha256: 6686a26466a527585e6a75cc2a242bf4a3d97d6d6c86424a441677917f28bec7 + md5: 43c04d9cb46ef176bb2a4c77e324d599 depends: - __osx >=11.0 license: MIT license_family: MIT purls: [] - size: 39839 - timestamp: 1743434670405 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda - sha256: 0cb92a9e026e7bd4842f410a5c5c665c89b2eb97794ffddba519a626b8ce7285 - md5: d6df911d4564d77c4374b02552cb17d1 + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 40979 + timestamp: 1769456747661 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + sha256: 34878d87275c298f1a732c6806349125cebbf340d24c6c23727268184bba051e + md5: b1fd823b5ae54fbec272cea0811bd8a9 depends: - __osx >=11.0 constrains: - - xz 5.8.1.* + - xz 5.8.3.* license: 0BSD purls: [] - size: 92286 - timestamp: 1749230283517 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda - sha256: 0a1875fc1642324ebd6c4ac864604f3f18f57fbcf558a8264f6ced028a3c75b2 - md5: 85ccccb47823dd9f7a99d2c7f530342f - depends: - - __osx >=11.0 - license: BSD-2-Clause - license_family: BSD - purls: [] - size: 71829 - timestamp: 1748393749336 + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 92472 + timestamp: 1775825802659 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.20-h99b78c6_0.conda sha256: fade8223e1e1004367d7101dd17261003b60aa576df6d7802191f8972f7470b1 md5: a7ce36e284c5faaf93c220dfc39e3abd @@ -5572,54 +5697,64 @@ packages: purls: [] size: 164972 timestamp: 1716828607917 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.50.4-h4237e3c_0.conda - sha256: 802ebe62e6bc59fc26b26276b793e0542cfff2d03c086440aeaf72fb8bbcec44 - md5: 1dcb0468f5146e38fae99aef9656034b +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + sha256: 745662565e103f290e9dc4263bbd88285082f8cf699854fe2d5f1e35a4a0d326 + md5: 0e3477c0c3e718dcf2eb74ccc8f68570 depends: - __osx >=11.0 - - icu >=75.1,<76.0a0 - - libzlib >=1.3.1,<2.0a0 + - icu >=78.3,<79.0a0 + - libzlib >=1.3.2,<2.0a0 license: blessing purls: [] - size: 902645 - timestamp: 1753948599139 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - sha256: ce34669eadaba351cd54910743e6a2261b67009624dbc7daeeafdef93616711b - md5: 369964e85dc26bfe78f41399b366c435 + run_exports: + weak: + - libsqlite >=3.53.4,<4.0a0 + size: 929203 + timestamp: 1785016131414 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + sha256: a18fa5d5bac452401459f966cf0d872224e8080c4ff93c77e168d43ab42ef9d7 + md5: f39288f0ea63ae962e1a2e4f355a0d75 depends: - __osx >=11.0 constrains: - - zlib 1.3.1 *_2 + - zlib 1.3.2 *_3 license: Zlib license_family: Other purls: [] - size: 46438 - timestamp: 1727963202283 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.2-py313ha9b7d5b_1.conda - sha256: 81759af8a9872c8926af3aa59dc4986eee90a0956d1ec820b42ac4f949a71211 - md5: 3acf05d8e42ff0d99820d2d889776fff + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 47822 + timestamp: 1785277049190 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py311hc290fe0_1.conda + sha256: d635f2b1d9e19e8e68c5d33150f7e4f62df08ef2ef0e85977f743e81939afc01 + md5: ff068874356bbc7f9bd2d793f809f44b depends: - __osx >=11.0 - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python >=3.11,<3.12.0a0 *_cpython + - python_abi 3.11.* *_cp311 constrains: - jinja2 >=3.0.0 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/markupsafe?source=hash-mapping - size: 24757 - timestamp: 1733219916634 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda - sha256: 2827ada40e8d9ca69a153a45f7fd14f32b2ead7045d3bbb5d10964898fe65733 - md5: 068d497125e4bf8a66bf707254fff5ae + run_exports: {} + size: 26511 + timestamp: 1772445369187 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + sha256: 4ea6c620b87bd1d42bb2ccc2c87cd2483fa2d7f9e905b14c223f11ff3f4c455d + md5: 343d10ed5b44030a2f67193905aea159 depends: - __osx >=11.0 license: X11 AND BSD-3-Clause purls: [] - size: 797030 - timestamp: 1738196177597 + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 805509 + timestamp: 1777423252320 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nh3-0.3.2-py310h06fc29a_0.conda noarch: python sha256: cee5e0acd60b4377cc5b66a7d4741fbe3dbd6613660d5fa10a7a92003710f405 @@ -5637,160 +5772,203 @@ packages: - pkg:pypi/nh3?source=hash-mapping size: 624675 timestamp: 1761831518941 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.5.3-h5503f6c_1.conda - sha256: d5499ee2611a0ca9d84e9d60a5978d1f17350e94915c89026f5d9346ccf0a987 - md5: 4b23b1e2aa9d81b16204e1304241ccae +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + sha256: 66be2283b5b37dcda1332b5e74c1782a8cb14fd2e62e0d38017c2d35bf73c119 + md5: 65d1906712b85d1679263c518d011b5b depends: - __osx >=11.0 - ca-certificates license: Apache-2.0 license_family: Apache purls: [] - size: 3069376 - timestamp: 1758598263612 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.1.3-py313h9734d34_0.conda - sha256: a175fee131b28ecd2dadd2b3fdc9b75b50ad5ad502d984280ae064152739c567 - md5: b17da028e6650dce95f8247faf84ba48 + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3109132 + timestamp: 1785913735357 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandoc-3.8.3-hce30654_0.conda + sha256: 39af2080d16088c0b9c19db5d0f8b2c845e70c428126a4773d0e54b609d8af91 + md5: 68bc0f4209fe5cbb03a401177f3a36c2 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: {} + size: 28522262 + timestamp: 1764589967786 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py311he363849_0.conda + sha256: 2b774e8f4ccaac8783d1908f281e4bb20b7036d6708dc913ee7811b070f5b4b5 + md5: 7cff50265141513c960f00ba586780ea depends: - python - - python 3.13.* *_cp313 + - python 3.11.* *_cpython - __osx >=11.0 - - python_abi 3.13.* *_cp313 + - python_abi 3.11.* *_cp311 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/psutil?source=hash-mapping - size: 515398 - timestamp: 1762093071645 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.0-py313h1bd1373_0.conda - sha256: 67bd2c812166974499e5547a9cd94d429a79911d89fefd733e34e506c2a1dd58 - md5: 412dcfb6f06603977d914d90397a57b5 - depends: - - __osx >=11.0 - - libffi >=3.4.6,<3.5.0a0 - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 + run_exports: {} + size: 245203 + timestamp: 1769678306347 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.1-py311h733dca1_0.conda + sha256: 84a11d0c00d07670ce319765c94e82453cc77a52c3f2d8f0271a70691c861f04 + md5: 1855ac2247ef86521c88d611e4282bee + depends: + - __osx >=11.3 + - libffi >=3.5.2,<3.6.0a0 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 - setuptools license: MIT license_family: MIT purls: - pkg:pypi/pyobjc-core?source=hash-mapping - size: 480282 - timestamp: 1761038173609 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.0-py313h3eb5a1b_0.conda - sha256: d01d6dfca972a3e4cc90e67427102c6200ced902a39d6e45d8eccfab9e498e89 - md5: d56cd4bcca8270c0edb20f8e932cc04b - depends: - - __osx >=11.0 - - libffi >=3.4.6,<3.5.0a0 - - pyobjc-core 12.0.* - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 + run_exports: {} + size: 2121288 + timestamp: 1782231944803 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-framework-cocoa-12.2.1-py311h971847e_0.conda + sha256: c07c16db20b3597e65bcd2a279233d8a9ac5612d7a48cdb1e3d1aeabdf1706fa + md5: a21e88ba3bcc2d3b5f8f0d6fd158a9e8 + depends: + - __osx >=11.3 + - libffi >=3.5.2,<3.6.0a0 + - pyobjc-core 12.2.1.* + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 license: MIT license_family: MIT purls: - pkg:pypi/pyobjc-framework-cocoa?source=hash-mapping - size: 376153 - timestamp: 1761043609574 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.7-h5c937ed_100_cp313.conda - build_number: 100 - sha256: b9776cc330fa4836171a42e0e9d9d3da145d7702ba6ef9fad45e94f0f016eaef - md5: 445d057271904b0e21e14b1fa1d07ba5 + run_exports: {} + size: 388997 + timestamp: 1782265822867 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.11.15-h0c9c016_1_cpython.conda + build_number: 1 + sha256: a44be5222fe8d3c072ecd22491d37316724b70be6b8e8dabdc1a25e6d293fba8 + md5: 91607d75cdf9fafc95061e3763582657 depends: - __osx >=11.0 - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.7.1,<3.0a0 - - libffi >=3.4.6,<3.5.0a0 - - liblzma >=5.8.1,<6.0a0 - - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.50.4,<4.0a0 - - libzlib >=1.3.1,<2.0a0 - - ncurses >=6.5,<7.0a0 - - openssl >=3.5.2,<4.0a0 - - python_abi 3.13.* *_cp313 - - readline >=8.2,<9.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.3,<6.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - readline >=8.3,<9.0a0 - tk >=8.6.13,<8.7.0a0 - tzdata + constrains: + - python_abi 3.11.* *_cp311 license: Python-2.0 purls: [] - size: 11926240 - timestamp: 1756909724811 - python_site_packages_path: lib/python3.13/site-packages -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.2-py313ha9b7d5b_2.conda - sha256: 58c41b86ff2dabcf9ccd9010973b5763ec28b14030f9e1d9b371d22b538bce73 - md5: 03a7926e244802f570f25401c25c13bc + run_exports: + weak: + - python_abi 3.11.* *_cp311 + noarch: + - python + size: 15389700 + timestamp: 1781148926804 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.3-py311hc290fe0_1.conda + sha256: 984e73d7957460689e10533059de8adb38a308853d298900a37acc58edd84cec + md5: e4b908da7cd496b3fa6798c0f60a2a19 depends: - __osx >=11.0 - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python >=3.11,<3.12.0a0 *_cpython + - python_abi 3.11.* *_cp311 - yaml >=0.2.5,<0.3.0a0 license: MIT license_family: MIT purls: - pkg:pypi/pyyaml?source=hash-mapping - size: 194243 - timestamp: 1737454911892 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312hd65ceae_0.conda - noarch: python - sha256: ef33812c71eccf62ea171906c3e7fc1c8921f31e9cc1fbc3f079f3f074702061 - md5: bbd22b0f0454a5972f68a5f200643050 + run_exports: {} + size: 192948 + timestamp: 1770223655988 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py311h8463d66_3.conda + sha256: e461e38dc527e2c24929b19debd9ebf006d36bca7fb222bb9465fd19dc41af40 + md5: dc07ae3c579fe19354d3e10a90992415 depends: - python - - __osx >=11.0 - libcxx >=19 - - _python_abi3_support 1.* - - cpython >=3.12 + - python 3.11.* *_cpython + - __osx >=11.0 + - python_abi 3.11.* *_cp311 - zeromq >=4.3.5,<4.4.0a0 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/pyzmq?source=hash-mapping - size: 191115 - timestamp: 1757387128258 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda - sha256: 7db04684d3904f6151eff8673270922d31da1eea7fa73254d01c437f49702e34 - md5: 63ef3f6e6d6d5c589e64f11263dc5676 + run_exports: {} + size: 359949 + timestamp: 1779484138996 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/quarto-1.9.38-h78c4bfd_0.conda + sha256: 76d9c068ee8bdfd7b198b0aff9e0fbd9f69ede8b9c0f6480135ce437860ab3e9 + md5: 181bc833726b113f12aae8ccca6ba500 + depends: + - __osx >=11.0 + - dart-sass + - deno >=2.4.5,<2.4.6.0a0 + - deno-dom >=0.1.41,<0.1.42.0a0 + - esbuild + - pandoc 3.8.3 + - typst 0.14.2 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 25892459 + timestamp: 1779790473219 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + sha256: a77010528efb4b548ac2a4484eaf7e1c3907f2aec86123ed9c5212ae44502477 + md5: f8381319127120ce51e081dce4865cf4 depends: + - __osx >=11.0 - ncurses >=6.5,<7.0a0 license: GPL-3.0-only license_family: GPL purls: [] - size: 252359 - timestamp: 1740379663071 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-0.30.0-py313h2c089d5_0.conda - sha256: db63344f91e8bfe77703c6764aa9eeafb44d165e286053214722814eabda0264 - md5: 190c2d0d4e98ec97df48cdb74caf44d8 + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 313930 + timestamp: 1765813902568 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/rpds-py-2026.6.3-py311haff49d3_0.conda + sha256: 466a9df1864e3104ca95bb7292fb045fd320c0b40e71647162341329437eb23e + md5: f55ea3840a1a4c76fdb03dc8d95e7a76 depends: - python - __osx >=11.0 - - python 3.13.* *_cp313 - - python_abi 3.13.* *_cp313 + - python_abi 3.11.* *_cp311 constrains: - __osx >=11.0 license: MIT + license_family: MIT purls: - pkg:pypi/rpds-py?source=hash-mapping - size: 358961 - timestamp: 1764543165314 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/sqlalchemy-2.0.44-py313h6535dbc_0.conda - sha256: 42de1f39ba66da685199a624cfab047c5c672146931f15e019948f9728392c26 - md5: 066933639205f800447df89acddae3fc + run_exports: {} + size: 286691 + timestamp: 1782831311985 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/sqlalchemy-2.0.51-py311he363849_0.conda + sha256: 72cb4607bdcdff5479c8936ef9e018ceb54f501b650c1ee41b883752c036e320 + md5: 4e0069e964cbf452a6c4a96030f46013 depends: - - __osx >=11.0 + - python - greenlet !=0.4.17 - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 - typing-extensions >=4.6.0 + - python 3.11.* *_cpython + - __osx >=11.0 + - python_abi 3.11.* *_cp311 license: MIT license_family: MIT purls: - pkg:pypi/sqlalchemy?source=hash-mapping - size: 3632604 - timestamp: 1760114906739 + run_exports: {} + size: 3758835 + timestamp: 1781547979931 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_2.conda sha256: cb86c522576fa95c6db4c878849af0bccfd3264daf0cc40dd18e7f4a7bfced0e md5: 7362396c170252e7b7b0c8fb37fe9c78 @@ -5802,20 +5980,51 @@ packages: purls: [] size: 3125538 timestamp: 1748388189063 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.2-py313h6535dbc_2.conda - sha256: 621bcd6a7ca399bb739aa32a2fb639b2389dd5f030af3c7a2d5e639cfe194be4 - md5: c7fea1e31871009ff882a327ba4b7d9a +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.7-py311hc949640_0.conda + sha256: 0b9584a50a8982f03b2c5a97c929a8b30a2075c025eeee9eb5a8b1a4997c4515 + md5: b2b1f6b7efe80bc80f40a7c42dff45c0 depends: - __osx >=11.0 - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python >=3.11,<3.12.0a0 *_cpython + - python_abi 3.11.* *_cp311 license: Apache-2.0 license_family: Apache purls: - pkg:pypi/tornado?source=hash-mapping - size: 876232 - timestamp: 1762507414014 + run_exports: {} + size: 881244 + timestamp: 1781007287281 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/typst-0.14.2-hd1458d2_0.conda + sha256: 201e08fc74e3dc8161d583bb50d23ae07b4537c5efa41c8a7e1ba46a30261ea6 + md5: 750223ac708cb65d0d32fa61d84e35e4 + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: {} + size: 14708782 + timestamp: 1765697750603 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.1.0-py311h572238d_0.conda + sha256: 1bf370d4d4698b339d54342b2d653d9ed26f5004af2da3e5556b641fb5e56e1b + md5: 2855259ce88c41c1b2297041ec75f540 + depends: + - __osx >=11.0 + - cffi + - libcxx >=19 + - python >=3.11,<3.12.0a0 + - python >=3.11,<3.12.0a0 *_cpython + - python_abi 3.11.* *_cp311 + license: MIT + license_family: MIT + purls: + - pkg:pypi/ukkonen?source=hash-mapping + run_exports: {} + size: 14760 + timestamp: 1769438970347 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.9.17-h1bde295_0.conda sha256: 10b90d25fd0e898910743a4a386e326c9a23836b8fcada517b792794fe8cb67f md5: d6182c2d49e53e8ceb628f5bb677dddb @@ -5828,21 +6037,6 @@ packages: purls: [] size: 15488097 timestamp: 1765353175319 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/watchdog-6.0.0-py313hcdf3177_1.conda - sha256: ba78762d8b75bc1278861c81860685a82bf5f5b2c6cf1aadfbb9e08d7904161e - md5: 1aeffac355c1e143fb0564e9f756be16 - depends: - - __osx >=11.0 - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 - - pyyaml >=3.10 - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/watchdog?source=hash-mapping - size: 152813 - timestamp: 1756135648190 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda sha256: b03433b13d89f5567e828ea9f1a7d5c5d697bf374c28a4168d71e9464f5dafac md5: 78a0fe9e9c50d2c381e8ee47e3ea437d @@ -5866,23 +6060,24 @@ packages: purls: [] size: 244772 timestamp: 1757371008525 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py313h9734d34_0.conda - sha256: 8a1bf8a66c05f724e8a56cb1918eae70bcb467a7c5d43818e37e04d86332c513 - md5: ce17795bf104a29a2c7ed0bba7a804cb +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py311h5bb9006_1.conda + sha256: 2ee455765fe831cca8fe127c56ae99938e353797135fe33140b28abb4fbe1049 + md5: 651594b8f9b9cccc5948287a18903c34 depends: - python - cffi >=1.11 - zstd >=1.5.7,<1.5.8.0a0 + - python 3.11.* *_cpython - __osx >=11.0 - - python 3.13.* *_cp313 + - python_abi 3.11.* *_cp311 - zstd >=1.5.7,<1.6.0a0 - - python_abi 3.13.* *_cp313 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/zstandard?source=hash-mapping - size: 396477 - timestamp: 1757930170468 + run_exports: {} + size: 390026 + timestamp: 1762512731928 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-h6491c7d_2.conda sha256: 0d02046f57f7a1a3feae3e9d1aa2113788311f3cf37a3244c71e61a93177ba67 md5: e6f69c7bcccdefa417f056fa593b40f0 @@ -5894,13 +6089,13 @@ packages: purls: [] size: 399979 timestamp: 1742433432699 -- conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py313h5ea7bf4_2.conda - sha256: 3f8a1affdfeb2be5289d709e365fc6e386d734773895215cf8cbc5100fa6af9a - md5: eabb4b677b54874d7d6ab775fdaa3d27 +- conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py311h3485c13_2.conda + sha256: 787e9a5f0a5624fee2af4f58c823bd7933b8ca560e794dd9a821e990b31d2d49 + md5: 5fde0926a521a37d454a5e3162cb6e51 depends: - cffi >=1.0.1 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 - ucrt >=10.0.20348.0 - vc >=14.3,<15 - vc14_runtime >=14.44.35208 @@ -5908,40 +6103,43 @@ packages: license_family: MIT purls: - pkg:pypi/argon2-cffi-bindings?source=hash-mapping - size: 38779 - timestamp: 1762509796090 -- conda: https://conda.anaconda.org/conda-forge/win-64/backports.zstd-1.2.0-py313h2a31948_0.conda - sha256: afb9e0b53476d4292c2f94b29eee08aea090a6c982775ecb4b03f60b87bd0f1a - md5: c83a610d87511dd7b04c33dc3180dda3 + run_exports: {} + size: 38502 + timestamp: 1762509668838 +- conda: https://conda.anaconda.org/conda-forge/win-64/backports.zstd-1.6.0-py311h71c1bcc_0.conda + sha256: 42c0ea81c8fd7fb514d8e94e5f0c99541cfed0df4b7aa960af9b39f10bf13e21 + md5: 572691e3dbd869573222e9a91c07d5de depends: - python - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 - - python_abi 3.13.* *_cp313 + - python_abi 3.11.* *_cp311 - zstd >=1.5.7,<1.6.0a0 license: BSD-3-Clause AND MIT AND EPL-2.0 purls: - pkg:pypi/backports-zstd?source=hash-mapping - size: 240694 - timestamp: 1765057700937 -- conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.1.0-py313hfe59770_4.conda - sha256: 0e98ebafd586c4da7d848f9de94770cb27653ba9232a2badb28f8a01f6e48fb5 - md5: 477bf04a8a3030368068ccd39b8c5532 - depends: - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + run_exports: {} + size: 245115 + timestamp: 1781450835602 +- conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py311hc5da9e4_1.conda + sha256: 1803c838946d79ef6485ae8c7dafc93e28722c5999b059a34118ef758387a4c9 + md5: b0c459f98ac5ea504a9d9df6242f7ee1 + depends: + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 - ucrt >=10.0.20348.0 - vc >=14.3,<15 - vc14_runtime >=14.44.35208 constrains: - - libbrotlicommon 1.1.0 hfd05255_4 + - libbrotlicommon 1.2.0 hfd05255_1 license: MIT license_family: MIT purls: - - pkg:pypi/brotli?source=compressed-mapping - size: 323459 - timestamp: 1756600051044 + - pkg:pypi/brotli?source=hash-mapping + run_exports: {} + size: 335333 + timestamp: 1764018370925 - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda sha256: d882712855624641f48aa9dc3f5feea2ed6b4e6004585d3616386a18186fe692 md5: 1077e9333c41ff0be8edd1a5ec0ddace @@ -5954,28 +6152,30 @@ packages: purls: [] size: 55977 timestamp: 1757437738856 -- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py313h5ea7bf4_0.conda - sha256: 099c0e4739e530259bb9ac7fee5e11229e36acf131e3c672824dbe215af61031 - md5: 2ede5fcd7d154a6e1bc9e57af2968ba2 +- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py311h3485c13_0.conda + sha256: c847b9c8360e8d2edebd730875789c28512eff9bd9c52bce286067c9c8f080b2 + md5: b02ac53915f4bf373db24ec3cbc66807 depends: - pycparser - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 - ucrt >=10.0.20348.0 - vc >=14.3,<15 - vc14_runtime >=14.44.35208 license: MIT + license_family: MIT purls: - pkg:pypi/cffi?source=hash-mapping - size: 292670 - timestamp: 1758716395348 -- conda: https://conda.anaconda.org/conda-forge/win-64/cmarkgfm-2024.11.20-py313h5ea7bf4_1.conda - sha256: 609a28ebd5a4f53268467c5f7f0e8825e8b1d260f63cd13763007230861bdd4c - md5: 62b9ded54b533430655662909db7c63c + run_exports: {} + size: 351787 + timestamp: 1785811068139 +- conda: https://conda.anaconda.org/conda-forge/win-64/cmarkgfm-2024.11.20-py311h3485c13_1.conda + sha256: 9554d54e859ab29300df23b80baf59ca9959280325d3b30558fc308c512256e2 + md5: fe8e46518e092091c583bdf10bb84559 depends: - cffi >=1.0.0 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 - ucrt >=10.0.20348.0 - vc >=14.3,<15 - vc14_runtime >=14.44.35208 @@ -5983,53 +6183,87 @@ packages: license_family: MIT purls: - pkg:pypi/cmarkgfm?source=hash-mapping - size: 124891 - timestamp: 1760363397548 -- conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.17-py313h927ade5_0.conda - sha256: 83e33b2f0821ef043b502ed7261592eb18a7dcc43ec76213e2888d6fd99973e2 - md5: 9b792915c34565e7856fa9682879ccd2 + run_exports: {} + size: 126102 + timestamp: 1760363231416 +- conda: https://conda.anaconda.org/conda-forge/win-64/dart-sass-1.102.0-h11686cb_0.conda + sha256: 5f679253766a2988c70945c726f3f8fb3d822f9c02e3e5a71587dee7bdff6465 + md5: c4a778ea85c0bc0e89b33ee76464a990 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 4078007 + timestamp: 1784947214099 +- conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py311h5dfdfe8_0.conda + sha256: 7066ce37b217cc706823de4a36f23bedf809a772ef70e879885d8d5ba4fbadf4 + md5: 87de2c9605b6801b790459bfef5372b3 depends: - python - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 + - python_abi 3.11.* *_cp311 + license: MIT + license_family: MIT + purls: + - pkg:pypi/debugpy?source=hash-mapping + run_exports: {} + size: 3952445 + timestamp: 1780390182092 +- conda: https://conda.anaconda.org/conda-forge/win-64/deno-2.4.5-h9135563_0.conda + sha256: ec2b52d16b8de758142d5aee3f97bc9fa34850f9a6753e188d03f806acbee205 + md5: d652d13c4a2fc47e8120cb1639a72597 + depends: - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 - - python_abi 3.13.* *_cp313 + - vc >=14.3 license: MIT license_family: MIT - purls: - - pkg:pypi/debugpy?source=hash-mapping - size: 4000809 - timestamp: 1758162072333 -- conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.3.0-py313hfe59770_0.conda - sha256: 21ed1bb0ee799d11b74bc1d6a70a5099fae954daf98625ca5a7d1bb226add754 - md5: 003aa3c2300518877fb6e350722011db - depends: - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + purls: [] + run_exports: {} + size: 38773826 + timestamp: 1776774858202 +- conda: https://conda.anaconda.org/conda-forge/win-64/deno-dom-0.1.41-h1a6af48_0.conda + sha256: 199ed9fea4d58cea10572cc5a7fb3ad0fbefd189cb785667639dbfc4210ed2ab + md5: 84bc9003c48bf69d4663b00f692404cb + depends: + - deno >=1.24.2 - ucrt >=10.0.20348.0 - vc >=14.3,<15 + - vc14_runtime >=14.42.34433 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 294346 + timestamp: 1736703764252 +- conda: https://conda.anaconda.org/conda-forge/win-64/esbuild-0.28.1-h11686cb_0.conda + sha256: 85a9e0b9659eae8f2e4e4211bf64d25e6e1f34278ed9c4277b7458b51cc20a7b + md5: 91cae9c8811689e71bbbfe81d4a15886 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 4387697 + timestamp: 1781248857546 +- conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.5.4-py311h5dfdfe8_0.conda + sha256: 203fde1f74beb83f4dd0b9cbbe4f9a6823d9eb2fd617755ac867f8eaf03a1efe + md5: 6829e8108803d615894e2fb58ea5049c + depends: + - python + - vc >=14.3,<15 - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.11.* *_cp311 license: MIT license_family: MIT purls: - pkg:pypi/greenlet?source=hash-mapping - size: 225470 - timestamp: 1764863992249 -- conda: https://conda.anaconda.org/conda-forge/win-64/jsonpointer-3.0.0-py313hfa70ccb_2.conda - sha256: dda25a66128a7b883515a659cd53c694e735374ccfbfa87a998160a33679424a - md5: 8da802c2a92986f7054f97c45e0f4bee - depends: - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/jsonpointer?source=hash-mapping - size: 43276 - timestamp: 1756754377785 + run_exports: {} + size: 261117 + timestamp: 1784885504741 - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.21.3-hdf4eb48_0.conda sha256: 18e8b3430d7d232dad132f574268f56b3eb1a19431d6d5de8c53c29e6c18fa81 md5: 31aec030344e962fbd7dbbbbd68e60a9 @@ -6043,57 +6277,52 @@ packages: purls: [] size: 712034 timestamp: 1719463874284 -- conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.7.1-hac47afa_0.conda - sha256: 8432ca842bdf8073ccecf016ccc9140c41c7114dc4ec77ca754551c01f780845 - md5: 3608ffde260281fa641e70d6e34b1b96 +- conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda + sha256: 1a54d874addda73b6f7164d5f3905821277a1831bcc05edd74b3085391688571 + md5: ccc490c81ffe14181861beac0e8f3169 depends: - ucrt >=10.0.20348.0 - vc >=14.3,<15 - vc14_runtime >=14.44.35208 constrains: - - expat 2.7.1.* + - expat 2.8.1.* license: MIT license_family: MIT purls: [] - size: 141322 - timestamp: 1752719767870 -- conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.4.6-h537db12_1.conda - sha256: d3b0b8812eab553d3464bbd68204f007f1ebadf96ce30eb0cbc5159f72e353f5 - md5: 85d8fa5e55ed8f93f874b3b23ed54ec6 + run_exports: {} + size: 71631 + timestamp: 1781203724164 +- conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + sha256: 59d01f2dfa8b77491b5888a5ab88ff4e1574c9359f7e229da254cdfe27ddc190 + md5: 720b39f5ec0610457b725eb3f396219a depends: - ucrt >=10.0.20348.0 - - vc >=14.2,<15 - - vc14_runtime >=14.29.30139 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 license: MIT license_family: MIT purls: [] - size: 44978 - timestamp: 1743435053850 -- conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.1-h2466b09_2.conda - sha256: 55764956eb9179b98de7cc0e55696f2eff8f7b83fc3ebff5e696ca358bca28cc - md5: c15148b2e18da456f5108ccb5e411446 + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 45831 + timestamp: 1769456418774 +- conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda + sha256: d636d1a25234063642f9c531a7bb58d84c1c496411280a36ea000bd122f078f1 + md5: 8f83619ab1588b98dd99c90b0bfc5c6d depends: - ucrt >=10.0.20348.0 - - vc >=14.2,<15 - - vc14_runtime >=14.29.30139 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 constrains: - - xz 5.8.1.* + - xz 5.8.3.* license: 0BSD purls: [] - size: 104935 - timestamp: 1749230611612 -- conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-h2466b09_0.conda - sha256: fc529fc82c7caf51202cc5cec5bb1c2e8d90edbac6d0a4602c966366efe3c7bf - md5: 74860100b2029e2523cf480804c76b9b - depends: - - ucrt >=10.0.20348.0 - - vc >=14.2,<15 - - vc14_runtime >=14.29.30139 - license: BSD-2-Clause - license_family: BSD - purls: [] - size: 88657 - timestamp: 1723861474602 + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 106486 + timestamp: 1775825663227 - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.20-hc70643c_0.conda sha256: 7bcb3edccea30f711b6be9601e083ecf4f435b9407d70fc48fbcf9e5d69a0fc6 md5: 198bb594f202b205c7d18b936fa4524f @@ -6105,48 +6334,55 @@ packages: purls: [] size: 202344 timestamp: 1716828757533 -- conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.50.4-hf5d6505_0.conda - sha256: 5dc4f07b2d6270ac0c874caec53c6984caaaa84bc0d3eb593b0edf3dc8492efa - md5: ccb20d946040f86f0c05b644d5eadeca +- conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda + sha256: 62e1c45ec71ab2e5deeeb0e47e7df6a609991e91d46348f16df50c68fee145c8 + md5: ca0d59f40a02a15e9b5d0ff8db0f85e3 depends: - ucrt >=10.0.20348.0 - vc >=14.3,<15 - vc14_runtime >=14.44.35208 license: blessing purls: [] - size: 1288499 - timestamp: 1753948889360 -- conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda - sha256: ba945c6493449bed0e6e29883c4943817f7c79cbff52b83360f7b341277c6402 - md5: 41fbfac52c601159df6c01f875de31b9 + run_exports: + weak: + - libsqlite >=3.53.4,<4.0a0 + size: 1313790 + timestamp: 1785016158097 +- conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda + sha256: 0629c2cc0404d3bb29d6baa7b4ba62da80797015e86de050db81ea5a07050527 + md5: 5d2ff29d465097458cc3ff6569151991 depends: - ucrt >=10.0.20348.0 - - vc >=14.2,<15 - - vc14_runtime >=14.29.30139 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 constrains: - - zlib 1.3.1 *_2 + - zlib 1.3.2 *_3 license: Zlib license_family: Other purls: [] - size: 55476 - timestamp: 1727963768015 -- conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.2-py313hb4c8b1a_1.conda - sha256: f16cb398915f52d582bcea69a16cf69a56dab6ea2fab6f069da9c2c10f09534c - md5: ec9ecf6ee4cceb73a0c9a8cdfdf58bed - depends: - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 58529 + timestamp: 1785276664143 +- conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py311h3f79411_1.conda + sha256: 3d37fb1900e31131f84549560e7a4bfea5f39aa3ecd73345fef1f33975cf0baa + md5: f55de41c947bdd2ff9bbeffedf8089f7 + depends: + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 - ucrt >=10.0.20348.0 - - vc >=14.2,<15 - - vc14_runtime >=14.29.30139 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 constrains: - jinja2 >=3.0.0 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/markupsafe?source=hash-mapping - size: 27930 - timestamp: 1733220059655 + run_exports: {} + size: 29362 + timestamp: 1772445178723 - conda: https://conda.anaconda.org/conda-forge/win-64/nh3-0.3.2-py310hdba2031_0.conda noarch: python sha256: 42142d3a3adc1b318d34ccd35f00b257d21d37fd6e05abea04a811da643ffee1 @@ -6164,9 +6400,9 @@ packages: - pkg:pypi/nh3?source=hash-mapping size: 588090 timestamp: 1761831340611 -- conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.5.3-h725018a_1.conda - sha256: 72dc204b0d59a7262bc77ca0e86cba11cbc6706cb9b4d6656fe7fab9593347c9 - md5: c84884e2c1f899de9a895a1f0b7c9cd8 +- conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_1.conda + sha256: 2ebff5a1b5793e82495bf33c91fba040e11ff23333c2385ac66d0c3aee2cc14c + md5: a978392692a910ba1c8920ccb1e784b3 depends: - ca-certificates - ucrt >=10.0.20348.0 @@ -6175,86 +6411,99 @@ packages: license: Apache-2.0 license_family: Apache purls: [] - size: 9276051 - timestamp: 1758599639304 -- conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.1.3-py313h5fd188c_0.conda - sha256: 460ad6347bcd4d83533322af7e09b41347491f867142972cde24ea16c8d8680b - md5: d61d8550d0dfe99408532c33e7ec26b5 + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 9427535 + timestamp: 1785915614585 +- conda: https://conda.anaconda.org/conda-forge/win-64/pandoc-3.8.3-h57928b3_0.conda + sha256: b3d37c502e405e7d1997a028e7eae246acd52436eacdd4f053cb345bde0da8a9 + md5: 904ca93f4f00a75ee3c49147cb00f14d + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: {} + size: 26699611 + timestamp: 1764589773519 +- conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py311hf893f09_0.conda + sha256: 32da17824abadd1f5b46faedfa4964c7b1817b11887c2e8bb4e48628da51b93a + md5: fd968cdacc7967efd0ff5ef1805b812c depends: - python - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.13.* *_cp313 + - python_abi 3.11.* *_cp311 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/psutil?source=hash-mapping - size: 520035 - timestamp: 1762092908165 -- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.13.7-hdf00ec1_100_cp313.conda - build_number: 100 - sha256: b86b5b3a960de2fff0bb7e0932b50846b22b75659576a257b1872177aab444cd - md5: 7cd6ebd1a32d4a5d99f8f8300c2029d5 + run_exports: {} + size: 249478 + timestamp: 1769678166841 +- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.11.15-h0159041_1_cpython.conda + build_number: 1 + sha256: 32716d8df907696e856cbd4cdcc5fe89ddae01c7c9a8cc99bd42260bf6d9a4a2 + md5: 06b84fcf19e4d5101a1d105d15dcfc88 depends: - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.7.1,<3.0a0 - - libffi >=3.4.6,<3.5.0a0 - - liblzma >=5.8.1,<6.0a0 - - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.50.4,<4.0a0 - - libzlib >=1.3.1,<2.0a0 - - openssl >=3.5.2,<4.0a0 - - python_abi 3.13.* *_cp313 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.3,<6.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.7,<4.0a0 - tk >=8.6.13,<8.7.0a0 - tzdata - ucrt >=10.0.20348.0 - vc >=14.3,<15 - vc14_runtime >=14.44.35208 + constrains: + - python_abi 3.11.* *_cp311 license: Python-2.0 purls: [] - size: 16386672 - timestamp: 1756909324921 - python_site_packages_path: Lib/site-packages -- conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-311-py313h40c08fc_1.conda - sha256: 87eaeb79b5961e0f216aa840bc35d5f0b9b123acffaecc4fda4de48891901f20 - md5: 1ce4f826332dca56c76a5b0cc89fb19e + run_exports: + weak: + - python_abi 3.11.* *_cp311 + noarch: + - python + size: 18439395 + timestamp: 1781148714198 +- conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py311hefeebc8_0.conda + sha256: f77bdcffb3def47a4747829939fd3c9f7a5b4ce1a1a7019039371b665e98416f + md5: 233e99d07b434a27c39b43b559252ef6 depends: - python - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.13.* *_cp313 + - python_abi 3.11.* *_cp311 license: PSF-2.0 license_family: PSF purls: - pkg:pypi/pywin32?source=hash-mapping - size: 6695114 - timestamp: 1756487139550 -- conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-ctypes-0.2.3-py313hfa70ccb_3.conda - sha256: dec893227662cf003f161d5a80af8d01d4a21c772737768c0d2d56ed67819473 - md5: 21a8bad6a2c8e821379595ad48577c23 - depends: - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + run_exports: {} + size: 4482214 + timestamp: 1781362876562 +- conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-ctypes-0.2.3-py311h1ea47a8_3.conda + sha256: 8e94e513389ac1f85c0adf8fca4c0b151a7017b1907d9342519b9820400c07d9 + md5: 11d9c1f337374208a513330b682e9aea + depends: + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/pywin32-ctypes?source=hash-mapping - size: 57717 - timestamp: 1762489947867 -- conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-2.0.15-py313h5813708_1.conda - sha256: d34a7cd0a4a7dc79662cb6005e01d630245d9a942e359eb4d94b2fb464ed2552 - md5: 8f01ed27e2baa455e753301218e054fd - depends: - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + run_exports: {} + size: 59305 + timestamp: 1762489964585 +- conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-3.0.5-py311hda3d55a_0.conda + sha256: c2723739dbee6a2f5258673cf833209d57ff0c2034ba623ef9e654fb4f4f154f + md5: 043089f11244aa6e4ad1f01bfeeb9e8c + depends: + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 - ucrt >=10.0.20348.0 - vc >=14.2,<15 - vc14_runtime >=14.29.30139 @@ -6263,76 +6512,96 @@ packages: license_family: MIT purls: - pkg:pypi/pywinpty?source=hash-mapping - size: 216075 - timestamp: 1759556799508 -- conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.2-py313hb4c8b1a_2.conda - sha256: 5b496c96e48f495de41525cb1b603d0147f2079f88a8cf061aaf9e17a2fe1992 - md5: d14f685b5d204b023c641b188a8d0d7c - depends: - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + run_exports: {} + size: 742021 + timestamp: 1785742522838 +- conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py311h3f79411_1.conda + sha256: 301c3ba100d25cd5ae37895988ee3ab986210d4d972aa58efed948fbe857773d + md5: a0153c033dc55203e11d1cac8f6a9cf2 + depends: + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 - ucrt >=10.0.20348.0 - - vc >=14.2,<15 - - vc14_runtime >=14.29.30139 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 - yaml >=0.2.5,<0.3.0a0 license: MIT license_family: MIT purls: - pkg:pypi/pyyaml?source=hash-mapping - size: 182783 - timestamp: 1737455202579 -- conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312hbb5da91_0.conda - noarch: python - sha256: fd46b30e6a1e4c129045e3174446de3ca90da917a595037d28595532ab915c5d - md5: 808d263ec97bbd93b41ca01552b5fbd4 + run_exports: {} + size: 187108 + timestamp: 1770223467913 +- conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py311h7a22eb7_3.conda + sha256: 5c6e92b109add92e28ddd0b0e352a279f8d3d4bea2dfb69c252bc5ed69c6ca30 + md5: 4b98b0c67d4f4d4e1181b07706a7bf0c depends: - python - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - zeromq >=4.3.5,<4.3.6.0a0 - - _python_abi3_support 1.* - - cpython >=3.12 + - python_abi 3.11.* *_cp311 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/pyzmq?source=hash-mapping - size: 185711 - timestamp: 1757387025899 -- conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-0.30.0-py313hfbe8231_0.conda - sha256: 27bd383787c0df7a0a926b11014fd692d60d557398dcf1d50c55aa2378507114 - md5: 58ae648b12cfa6df3923b5fd219931cb + run_exports: {} + size: 362998 + timestamp: 1779483941678 +- conda: https://conda.anaconda.org/conda-forge/win-64/quarto-1.9.38-h0e8a320_0.conda + sha256: 78310e993d858f1b7d566fe0228f85978eb38a7595175ffc149bb67b8f3ae14c + md5: 8448f287862e806fa94c7900edb64cf5 + depends: + - dart-sass + - deno >=2.4.5,<2.4.6.0a0 + - deno-dom >=0.1.41,<0.1.42.0a0 + - esbuild + - pandoc 3.8.3 + - typst 0.14.2 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 26182767 + timestamp: 1779790174898 +- conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.6.3-py311h7337c20_0.conda + sha256: 3d3642bd56d248c6cdcd31831fc0cafaa5a9a3376e2d760d781f930ab0caa81e + md5: 1ae11d687fc860a2cd3b68e9c50c8dcc depends: - python - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 - - python_abi 3.13.* *_cp313 + - python_abi 3.11.* *_cp311 license: MIT + license_family: MIT purls: - pkg:pypi/rpds-py?source=hash-mapping - size: 243419 - timestamp: 1764543047271 -- conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.44-py313h5ea7bf4_0.conda - sha256: ea958a8a628b16882532a5cabca1b5cf8c870c897b88b9283905af065de27258 - md5: 09cfbd00baeae0b8050e112be724ff23 + run_exports: {} + size: 222540 + timestamp: 1782831456252 +- conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.51-py311hf893f09_0.conda + sha256: 9a85b94f7610d503f32a74cc5328f5e6c7e6bdb1aab80a1738d34317adb98b21 + md5: 20aab397bcbf138453f6a932af09737c depends: + - python - greenlet !=0.4.17 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - typing-extensions >=4.6.0 - - ucrt >=10.0.20348.0 - vc >=14.3,<15 - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.11.* *_cp311 license: MIT license_family: MIT purls: - pkg:pypi/sqlalchemy?source=hash-mapping - size: 3603290 - timestamp: 1760114795152 + run_exports: {} + size: 3726026 + timestamp: 1781547904809 - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h2c6b04d_2.conda sha256: e3614b0eb4abcc70d98eae159db59d9b4059ed743ef402081151a948dce95896 md5: ebd0e761de9aa879a51d22cc721bd095 @@ -6345,12 +6614,12 @@ packages: purls: [] size: 3466348 timestamp: 1748388121356 -- conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.2-py313h5ea7bf4_2.conda - sha256: 79a13678078dbdcb800b75d32e7d60f460a2284f1d6ede15ff5478b656608a28 - md5: 81bf54645cb6686c47158450cd913ec2 +- conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py311h3485c13_0.conda + sha256: ac78e0731b5d2bbe81dfd6b22550d99174ab55dddf0e258313d98aa2a33f6fc6 + md5: b20b96955a12c35fda1de549f08a3743 depends: - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 - ucrt >=10.0.20348.0 - vc >=14.3,<15 - vc14_runtime >=14.44.35208 @@ -6358,8 +6627,22 @@ packages: license_family: Apache purls: - pkg:pypi/tornado?source=hash-mapping - size: 876064 - timestamp: 1762506921139 + run_exports: {} + size: 885527 + timestamp: 1781006887264 +- conda: https://conda.anaconda.org/conda-forge/win-64/typst-0.14.2-h77a83cd_0.conda + sha256: 6e6f4dfb5d3e43d627e248723ccc963c7889fc59e7039c67b4d6ca2aea20ea99 + md5: c7d150437cdf4a2facdb156fe1dbe9b7 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: {} + size: 15463461 + timestamp: 1765698111186 - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda sha256: 3005729dce6f3d3f5ec91dfc49fc75a0095f9cd23bab49efb899657297ac91a5 md5: 71b24316859acd00bdb8b38f5e2ce328 @@ -6370,6 +6653,23 @@ packages: purls: [] size: 694692 timestamp: 1756385147981 +- conda: https://conda.anaconda.org/conda-forge/win-64/ukkonen-1.1.0-py311h3fd045d_0.conda + sha256: f30fdd8cdc5aa9800d6f05ed772d8e5b6427202c4bf2c86513ac62070215f19d + md5: fb1cf06b4259739eaa61d94bc219c0e8 + depends: + - cffi + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + purls: + - pkg:pypi/ukkonen?source=hash-mapping + run_exports: {} + size: 18452 + timestamp: 1769438859987 - conda: https://conda.anaconda.org/conda-forge/win-64/uv-0.9.17-h3bd95fe_0.conda sha256: 2d2871d0cdb16e09bb2f325c240718553f8dcd6b5a46fe355eb8eacda3808686 md5: 152e69b3d8e0994030c75ec55152a892 @@ -6418,19 +6718,6 @@ packages: purls: [] size: 113963 timestamp: 1753739198723 -- conda: https://conda.anaconda.org/conda-forge/win-64/watchdog-6.0.0-py313hfa70ccb_1.conda - sha256: 06263427dcd0100706d26c0461f2ff8356f23f4b2ee047f67d0cd7f073e2c842 - md5: ff8bfc0c2479faccdfa9ec9859ed663b - depends: - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - - pyyaml >=3.10 - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/watchdog?source=hash-mapping - size: 168440 - timestamp: 1756135584322 - conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 sha256: 9df10c5b607dd30e05ba08cbd940009305c75db242476f4e845ea06008b0a283 md5: 1cee351bf20b830d991dbe0bc8cd7dfe @@ -6470,9 +6757,9 @@ packages: purls: [] size: 265212 timestamp: 1757370864284 -- conda: https://conda.anaconda.org/conda-forge/win-64/zstandard-0.25.0-py313h5fd188c_0.conda - sha256: d20a163a466621e41c3d06bc5dc3040603b8b0bfa09f493e2bfc0dbd5aa6e911 - md5: edfe43bb6e955d4d9b5e7470ea92dead +- conda: https://conda.anaconda.org/conda-forge/win-64/zstandard-0.25.0-py311hf893f09_1.conda + sha256: 10f089bedef1a28c663ef575fb9cec66b2058e342c4cf4a753083ab07591008f + md5: b2d90bca78b57c17205ce3ca1c427813 depends: - python - cffi >=1.11 @@ -6483,14 +6770,15 @@ packages: - vc >=14.3,<15 - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 - - python_abi 3.13.* *_cp313 + - python_abi 3.11.* *_cp311 - zstd >=1.5.7,<1.6.0a0 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/zstandard?source=hash-mapping - size: 380849 - timestamp: 1757930139118 + run_exports: {} + size: 375869 + timestamp: 1762512737575 - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-hbeecb71_2.conda sha256: bc64864377d809b904e877a98d0584f43836c9f2ef27d3d2a1421fa6eae7ca04 md5: 21f56217d6125fb30c3c3f10c786d751 @@ -6507,29 +6795,31 @@ packages: - pypi: ./ name: pyrecap requires_dist: + - alembic>=1.17.2,<2 - httpx2>=2.0 - mcp[cli]>=1.26.0,<2 - pydantic>=2.0 - python-slugify + - pyyaml>=6.0 - sqlalchemy>=2.0 - pytest ; extra == 'dev' + - fastapi>=0.115 ; extra == 'server' + - json-merge-patch>=0.3.0 ; extra == 'server' + - psycopg[binary]>=3.2,<4 ; extra == 'server' - pydantic-settings>=2.0 ; extra == 'server' - pyyaml>=6.0 ; extra == 'server' - - strawberry-graphql[fastapi]>=0.220 ; extra == 'server' - uvicorn[standard]>=0.30 ; extra == 'server' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl - name: graphql-core - version: 3.2.11 - sha256: 0b3e35ff41e9adba53021ab0cef475eb18f57c7f53f0f2ca55567fbf3c537ea0 - requires_dist: - - typing-extensions>=4.7,<5 ; python_full_version < '3.10' - requires_python: '>=3.7,<4' -- pypi: https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl - name: httptools - version: 0.8.0 - sha256: 7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1 - requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl + name: ruff + version: 0.16.2 + sha256: bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl + name: websockets + version: 16.1.1 + sha256: 1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl name: python-dotenv version: 1.2.1 @@ -6537,23 +6827,6 @@ packages: requires_dist: - click>=5.0 ; extra == 'cli' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: uvloop - version: 0.22.1 - sha256: 0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e - requires_dist: - - aiohttp>=3.10.5 ; extra == 'test' - - flake8~=6.1 ; extra == 'test' - - psutil ; extra == 'test' - - pycodestyle~=2.11.0 ; extra == 'test' - - pyopenssl~=25.3.0 ; extra == 'test' - - mypy>=0.800 ; extra == 'test' - - setuptools>=60 ; extra == 'dev' - - cython~=3.0 ; extra == 'dev' - - sphinx~=4.1.2 ; extra == 'docs' - - sphinxcontrib-asyncio~=0.3.0 ; extra == 'docs' - - sphinx-rtd-theme~=0.5.2 ; extra == 'docs' - requires_python: '>=3.8.1' - pypi: https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl name: truststore version: 0.10.4 @@ -6569,11 +6842,49 @@ packages: version: 0.0.4 sha256: 571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl - name: httptools - version: 0.8.0 - sha256: 2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c +- pypi: https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl + name: psycopg-binary + version: 3.3.4 + sha256: 13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl + name: pydantic-core + version: 2.33.2 + sha256: e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246 + requires_dist: + - typing-extensions>=4.6.0,!=4.7.0 requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/2c/28/01ae5d1e195f5079e485c27f4710f08629f0a688db5bdc4ab1fdc9a9fa07/great_docs-0.16.0-py3-none-any.whl + name: great-docs + version: 0.16.0 + sha256: aab52ed8fb114170cfc4055a8037faf590bcf6048a482906da694a72f6f5e816 + requires_dist: + - jupyter>=1.1.1 + - py-yaml12>=0.1.0 + - click>=8.0.0 + - griffe~=2.1.0 + - pygments>=2.0.0 + - requests>=2.25.0 + - pillow>=9.0.0 + - ruff>=0.9.9 + - cairosvg>=2.5.0 ; extra == 'svg' + - mcp>=1.0.0 ; extra == 'mcp' + - pytest>=6.0 ; extra == 'dev' + - pytest-cov>=3.0 ; extra == 'dev' + - pytest-xdist>=3.5 ; extra == 'dev' + - ruff>=0.9.9 ; extra == 'dev' + - mypy>=1.0.0 ; extra == 'dev' + - pyright>=1.1.407 ; extra == 'dev' + - build>=0.7 ; extra == 'dev' + - twine>=3.4 ; extra == 'dev' + - great-tables>=0.21.0 ; extra == 'dev' + - pandas>=2.0 ; extra == 'dev' + - polars>=0.20 ; extra == 'dev' + - pyarrow>=12.0 ; extra == 'dev' + - mcp>=1.0.0 ; extra == 'dev' + - jupyter>=1.0.0 ; extra == 'docs' + - plotnine>=0.13.0 ; extra == 'docs' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl name: cryptography version: 46.0.4 @@ -6604,69 +6915,57 @@ packages: - check-sdist ; extra == 'pep8test' - click>=8.0.1 ; extra == 'pep8test' requires_python: '>=3.8,!=3.9.0,!=3.9.1' -- pypi: https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - name: httptools - version: 0.8.0 - sha256: eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7 - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl - name: watchfiles - version: 1.2.0 - sha256: 7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44 - requires_dist: - - anyio>=3.0.0 +- pypi: https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl + name: ruff + version: 0.16.2 + sha256: a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: websockets + version: 16.1.1 + sha256: da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/37/83/3e29a6eb0487621980bdba69376fc4f44103748a75b0c2958e742ce52a45/strawberry_graphql-0.323.0-py3-none-any.whl - name: strawberry-graphql - version: 0.323.0 - sha256: a9ba9edb8cd101d1d1c9e57177f5328fac0ed32281bcad39e3624689984baefd +- pypi: https://files.pythonhosted.org/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl + name: jupyter + version: 1.1.1 + sha256: 7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83 requires_dist: - - graphql-core>=3.2.0,<3.4.0 - - typing-extensions>=4.5.0 - - python-dateutil>=2.7 - - packaging>=23 - - cross-web>=0.6.0 - - aiohttp>=3.7.4.post0,<4 ; extra == 'aiohttp' - - protobuf>=3.20 ; extra == 'apollo-federation' - - starlette>=0.18.0 ; extra == 'asgi' - - python-multipart>=0.0.7 ; extra == 'asgi' - - chalice>=1.22 ; extra == 'chalice' - - channels>=3.0.5 ; extra == 'channels' - - asgiref>=3.2 ; extra == 'channels' - - typer>=0.12.4 ; extra == 'cli' - - pygments>=2.3 ; extra == 'cli' - - rich>=12.0.0 ; extra == 'cli' - - libcst ; extra == 'cli' - - starlette>=0.18.0 ; extra == 'cli' - - uvicorn>=0.11.6 ; extra == 'cli' - - websockets>=15.0.1,<17 ; extra == 'cli' - - python-multipart>=0.0.7 ; extra == 'cli' - - rich>=12.0.0 ; extra == 'debug' - - libcst ; extra == 'debug' - - django>=3.2 ; extra == 'django' - - asgiref>=3.2 ; extra == 'django' - - fastapi>=0.65.2 ; extra == 'fastapi' - - python-multipart>=0.0.7 ; extra == 'fastapi' - - flask>=1.1 ; extra == 'flask' - - litestar>=2 ; python_full_version >= '3.10' and python_full_version < '4' and extra == 'litestar' - - opentelemetry-api<2 ; extra == 'opentelemetry' - - opentelemetry-sdk<2 ; extra == 'opentelemetry' - - pydantic>1.6.1 ; extra == 'pydantic' - - pyinstrument>=4.0.0 ; extra == 'pyinstrument' - - quart>=0.19.3 ; extra == 'quart' - - sanic>=20.12.2 ; extra == 'sanic' - requires_python: '>=3.10,<4.0' -- pypi: https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl - name: pydantic-core - version: 2.33.2 - sha256: f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6 + - notebook + - jupyter-console + - nbconvert + - ipykernel + - ipywidgets + - jupyterlab +- pypi: https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: pillow + version: 12.3.0 + sha256: 23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd requires_dist: - - typing-extensions>=4.6.0,!=4.7.0 - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl - name: websockets - version: 16.1.1 - sha256: cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac + - furo ; extra == 'docs' + - olefile ; extra == 'docs' + - sphinx>=8.2 ; extra == 'docs' + - sphinx-autobuild ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-inline-tabs ; extra == 'docs' + - sphinxext-opengraph ; extra == 'docs' + - olefile ; extra == 'fpx' + - olefile ; extra == 'mic' + - arro3-compute ; extra == 'test-arrow' + - arro3-core ; extra == 'test-arrow' + - nanoarrow ; extra == 'test-arrow' + - pyarrow ; extra == 'test-arrow' + - coverage>=7.4.2 ; extra == 'tests' + - defusedxml ; extra == 'tests' + - markdown2 ; extra == 'tests' + - olefile ; extra == 'tests' + - packaging ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-timeout ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + - setuptools ; extra == 'tests' + - trove-classifiers>=2024.10.12 ; extra == 'tests' + - defusedxml ; extra == 'xmp' requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl name: uvicorn @@ -6684,6 +6983,11 @@ packages: - watchfiles>=0.13 ; extra == 'standard' - websockets>=10.4 ; extra == 'standard' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl + name: annotated-doc + version: 0.0.5 + sha256: 117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl name: pydantic version: 2.11.9 @@ -6696,6 +7000,78 @@ packages: - email-validator>=2.0.0 ; extra == 'email' - tzdata ; python_full_version >= '3.9' and sys_platform == 'win32' and extra == 'timezone' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl + name: widgetsnbextension + version: 4.0.15 + sha256: 8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/45/a1/2b100e71aa1fe283ec8fb73f8b74a8576d855486a49117903b100dd3b78d/websockets-17.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: websockets + version: 17.0.1 + sha256: d41e9845514754a42d1d83b2fca9d27fee2ca7b3b0bee6843ba5a9bb2b6e25ac + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: pydantic-core + version: 2.33.2 + sha256: 881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef + requires_dist: + - typing-extensions>=4.6.0,!=4.7.0 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/48/60/d50a1e5e44333aa6533080df9c587b6b25e0205071078c63619202592bb3/py_yaml12-0.1.0-cp310-abi3-win_amd64.whl + name: py-yaml12 + version: 0.1.0 + sha256: 228ce36885a16dcaf20007bf7b74ae1b36ccd20a46149f369e4b8164e7542023 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl + name: ipywidgets + version: 8.1.8 + sha256: ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e + requires_dist: + - comm>=0.1.3 + - ipython>=6.1.0 + - traitlets>=4.3.1 + - widgetsnbextension~=4.0.14 + - jupyterlab-widgets~=3.0.15 + - jsonschema ; extra == 'test' + - ipykernel ; extra == 'test' + - pytest>=3.6.0 ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytz ; extra == 'test' + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl + name: psycopg + version: 3.3.4 + sha256: b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a + requires_dist: + - typing-extensions>=4.6 ; python_full_version < '3.13' + - tzdata ; sys_platform == 'win32' + - psycopg-c==3.3.4 ; implementation_name != 'pypy' and extra == 'c' + - psycopg-binary==3.3.4 ; implementation_name != 'pypy' and extra == 'binary' + - psycopg-pool ; extra == 'pool' + - anyio>=4.0 ; extra == 'test' + - mypy>=1.19.0 ; implementation_name != 'pypy' and extra == 'test' + - pproxy>=2.7 ; extra == 'test' + - pytest>=6.2.5 ; extra == 'test' + - pytest-cov>=3.0 ; extra == 'test' + - pytest-randomly>=3.5 ; extra == 'test' + - ast-comments>=1.1.2 ; extra == 'dev' + - black>=26.1.0 ; extra == 'dev' + - codespell>=2.2 ; extra == 'dev' + - cython-lint>=0.16 ; extra == 'dev' + - dnspython>=2.1 ; extra == 'dev' + - flake8>=4.0 ; extra == 'dev' + - isort[colors]>=6.0 ; extra == 'dev' + - isort-psycopg ; extra == 'dev' + - mypy>=1.19.0 ; extra == 'dev' + - pre-commit>=4.0.1 ; extra == 'dev' + - types-setuptools>=57.4 ; extra == 'dev' + - types-shapely>=2.0 ; extra == 'dev' + - wheel>=0.37 ; extra == 'dev' + - sphinx>=9.1 ; extra == 'docs' + - furo==2025.12.19 ; extra == 'docs' + - sphinx-autobuild>=2025.8.25 ; extra == 'docs' + - sphinx-autodoc-typehints>=3.10.2 ; extra == 'docs' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl name: fastapi version: 0.139.2 @@ -6734,6 +7110,55 @@ packages: - pydantic-settings>=2.0.0 ; extra == 'all' - pydantic-extra-types>=2.0.0 ; extra == 'all' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl + name: pillow + version: 12.3.0 + sha256: 8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a + requires_dist: + - furo ; extra == 'docs' + - olefile ; extra == 'docs' + - sphinx>=8.2 ; extra == 'docs' + - sphinx-autobuild ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-inline-tabs ; extra == 'docs' + - sphinxext-opengraph ; extra == 'docs' + - olefile ; extra == 'fpx' + - olefile ; extra == 'mic' + - arro3-compute ; extra == 'test-arrow' + - arro3-core ; extra == 'test-arrow' + - nanoarrow ; extra == 'test-arrow' + - pyarrow ; extra == 'test-arrow' + - coverage>=7.4.2 ; extra == 'tests' + - defusedxml ; extra == 'tests' + - markdown2 ; extra == 'tests' + - olefile ; extra == 'tests' + - packaging ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-timeout ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + - setuptools ; extra == 'tests' + - trove-classifiers>=2024.10.12 ; extra == 'tests' + - defusedxml ; extra == 'xmp' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl + name: cryptography + version: 50.0.0 + sha256: b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae + requires_dist: + - cffi>=2.0.0 ; platform_python_implementation != 'PyPy' + - typing-extensions>=4.13.2 ; python_full_version < '3.11' + - bcrypt>=3.1.5 ; extra == 'ssh' + requires_python: '!=3.9.0,>=3.9,!=3.9.1' +- pypi: https://files.pythonhosted.org/packages/6b/fb/3c65d392feae6c36dc2b55a14dd8270b7b35a3171c93b71a4d2ee4abf241/griffe-2.1.0-py3-none-any.whl + name: griffe + version: 2.1.0 + sha256: 2ccdab17fb9cd76f278d7b5611cfc8f68cbe846d8d48df63dff80b62ecfa6f65 + requires_dist: + - griffecli==2.1.0 + - griffelib==2.1.0 + - griffelib[pypi]==2.1.0 ; extra == 'pypi' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl name: pyjwt version: 2.11.0 @@ -6753,6 +7178,62 @@ packages: - coverage[toml]==7.10.7 ; extra == 'tests' - pytest>=8.4.2,<9.0.0 ; extra == 'tests' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl + name: websockets + version: 16.1.1 + sha256: 7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: psycopg-binary + version: 3.3.4 + sha256: ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/73/96/00df2a4760f10f5af0f45c4955573cae6189931f9a30265a35865f8c1031/notebook-7.5.0-py3-none-any.whl + name: notebook + version: 7.5.0 + sha256: 3300262d52905ca271bd50b22617681d95f08a8360d099e097726e6d2efb5811 + requires_dist: + - jupyter-server>=2.4.0,<3 + - jupyterlab-server>=2.28.0,<3 + - jupyterlab>=4.5.0rc0,<4.6 + - notebook-shim>=0.2,<0.3 + - tornado>=6.2.0 + - hatch ; extra == 'dev' + - pre-commit ; extra == 'dev' + - myst-parser ; extra == 'docs' + - nbsphinx ; extra == 'docs' + - pydata-sphinx-theme ; extra == 'docs' + - sphinx>=1.3.6 ; extra == 'docs' + - sphinxcontrib-github-alt ; extra == 'docs' + - sphinxcontrib-spelling ; extra == 'docs' + - importlib-resources>=5.0 ; python_full_version < '3.10' and extra == 'test' + - ipykernel ; extra == 'test' + - jupyter-server[test]>=2.4.0,<3 ; extra == 'test' + - jupyterlab-server[test]>=2.28.0,<3 ; extra == 'test' + - nbval ; extra == 'test' + - pytest-console-scripts ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest-tornasync ; extra == 'test' + - pytest>=7.0 ; extra == 'test' + - requests ; extra == 'test' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: uvloop + version: 0.22.1 + sha256: 56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702 + requires_dist: + - aiohttp>=3.10.5 ; extra == 'test' + - flake8~=6.1 ; extra == 'test' + - psutil ; extra == 'test' + - pycodestyle~=2.11.0 ; extra == 'test' + - pyopenssl~=25.3.0 ; extra == 'test' + - mypy>=0.800 ; extra == 'test' + - setuptools>=60 ; extra == 'dev' + - cython~=3.0 ; extra == 'dev' + - sphinx~=4.1.2 ; extra == 'docs' + - sphinxcontrib-asyncio~=0.3.0 ; extra == 'docs' + - sphinx-rtd-theme~=0.5.2 ; extra == 'docs' + requires_python: '>=3.8.1' - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl name: annotated-types version: 0.7.0 @@ -6760,13 +7241,19 @@ packages: requires_dist: - typing-extensions>=4.0.0 ; python_full_version < '3.9' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/7a/4a/78b52ec2edcbd9b123638f4e0421fd8699ebe653ebf63ac5f82abd2665bc/cross_web-0.7.0-py3-none-any.whl - name: cross-web - version: 0.7.0 - sha256: ddea9be3c68b48eaf16561847a5831a559786949c544b3701432e00a4e8d19d9 +- pypi: https://files.pythonhosted.org/packages/7b/b1/ba0ce59681db38c320a6d485f95a497ddea20356d9a9e8e70615ddd867b9/websockets-17.0.1-cp311-cp311-win_amd64.whl + name: websockets + version: 17.0.1 + sha256: 02f0b037a737d0cb0c33866c97bcd1a0b73170dfbf42d69d8fb86f51002fd5ae + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/80/2f/513232ec1d5f5da182e4ce45a11427e37dd210844a9e2bca451fc9661fb3/griffecli-2.1.0-py3-none-any.whl + name: griffecli + version: 2.1.0 + sha256: 6e22b1423d562ddc510997b4be1fe89de59e19dcff78831c0f4bfc3b8134a718 requires_dist: - - typing-extensions>=4.14.0 - requires_python: '>=3.9' + - colorama>=0.4 + - griffelib==2.1.0 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl name: starlette version: 0.52.1 @@ -6780,6 +7267,27 @@ packages: - python-multipart>=0.0.18 ; extra == 'full' - pyyaml ; extra == 'full' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl + name: rich + version: 15.0.0 + sha256: 33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb + requires_dist: + - ipywidgets>=7.5.1,<9 ; extra == 'jupyter' + - markdown-it-py>=2.2.0 + - pygments>=2.13.0,<3.0.0 + requires_python: '>=3.9.0' +- pypi: https://files.pythonhosted.org/packages/87/2d/68439a174c74969fb51619bbe9af9496826610883b828a2edd2f022c94fc/websockets-17.0.1-cp311-cp311-macosx_11_0_arm64.whl + name: websockets + version: 17.0.1 + sha256: 0b52c76b8a870b141b7ca0705289452183ce7a523101954ccfe29a25986a673f + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl + name: watchfiles + version: 1.2.0 + sha256: 6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5 + requires_dist: + - anyio>=3.0.0 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl name: httpx2 version: 2.3.0 @@ -6798,23 +7306,6 @@ packages: - socksio==1.* ; extra == 'socks' - zstandard>=0.18.0 ; python_full_version < '3.14' and extra == 'zstd' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl - name: uvloop - version: 0.22.1 - sha256: 561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705 - requires_dist: - - aiohttp>=3.10.5 ; extra == 'test' - - flake8~=6.1 ; extra == 'test' - - psutil ; extra == 'test' - - pycodestyle~=2.11.0 ; extra == 'test' - - pyopenssl~=25.3.0 ; extra == 'test' - - mypy>=0.800 ; extra == 'test' - - setuptools>=60 ; extra == 'dev' - - cython~=3.0 ; extra == 'dev' - - sphinx~=4.1.2 ; extra == 'docs' - - sphinxcontrib-asyncio~=0.3.0 ; extra == 'docs' - - sphinx-rtd-theme~=0.5.2 ; extra == 'docs' - requires_python: '>=3.8.1' - pypi: https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl name: cryptography version: 46.0.4 @@ -6845,6 +7336,11 @@ packages: - check-sdist ; extra == 'pep8test' - click>=8.0.1 ; extra == 'pep8test' requires_python: '>=3.8,!=3.9.0,!=3.9.1' +- pypi: https://files.pythonhosted.org/packages/8e/0c/1e1b171246a44753bf04c6399179da1ccf99cfb9de1e086afd7326765b34/py_yaml12-0.1.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: py-yaml12 + version: 0.1.0 + sha256: fbc5d54ec0471c0f64b931ea0907f655c728597b98a9e7f4a98f4c76cc3614ba + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl name: sse-starlette version: 3.2.0 @@ -6870,13 +7366,6 @@ packages: - shellingham>=1.3.0 - rich>=10.11.0 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl - name: pydantic-core - version: 2.33.2 - sha256: c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9 - requires_dist: - - typing-extensions>=4.6.0,!=4.7.0 - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl name: python-slugify version: 8.0.4 @@ -6889,22 +7378,85 @@ packages: name: text-unidecode version: '1.3' sha256: 1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8 -- pypi: https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - name: watchfiles - version: 1.2.0 - sha256: ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71 +- pypi: https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl + name: jupyterlab-widgets + version: 3.0.16 + sha256: 45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl + name: pillow + version: 12.3.0 + sha256: 37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 requires_dist: - - anyio>=3.0.0 + - furo ; extra == 'docs' + - olefile ; extra == 'docs' + - sphinx>=8.2 ; extra == 'docs' + - sphinx-autobuild ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-inline-tabs ; extra == 'docs' + - sphinxext-opengraph ; extra == 'docs' + - olefile ; extra == 'fpx' + - olefile ; extra == 'mic' + - arro3-compute ; extra == 'test-arrow' + - arro3-core ; extra == 'test-arrow' + - nanoarrow ; extra == 'test-arrow' + - pyarrow ; extra == 'test-arrow' + - coverage>=7.4.2 ; extra == 'tests' + - defusedxml ; extra == 'tests' + - markdown2 ; extra == 'tests' + - olefile ; extra == 'tests' + - packaging ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-timeout ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + - setuptools ; extra == 'tests' + - trove-classifiers>=2024.10.12 ; extra == 'tests' + - defusedxml ; extra == 'xmp' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl - name: websockets - version: 16.1.1 - sha256: d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68 +- pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl + name: mdurl + version: 0.1.2 + sha256: 84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl + name: markdown-it-py + version: 4.2.0 + sha256: 9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + requires_dist: + - mdurl~=0.1 + - psutil ; extra == 'benchmarking' + - pytest ; extra == 'benchmarking' + - pytest-benchmark ; extra == 'benchmarking' + - commonmark~=0.9 ; extra == 'compare' + - markdown~=3.4 ; extra == 'compare' + - mistletoe~=1.0 ; extra == 'compare' + - mistune~=3.0 ; extra == 'compare' + - panflute~=2.3 ; extra == 'compare' + - markdown-it-pyrs ; extra == 'compare' + - linkify-it-py>=1,<3 ; extra == 'linkify' + - mdit-py-plugins>=0.5.0 ; extra == 'plugins' + - gprof2dot ; extra == 'profiling' + - mdit-py-plugins>=0.5.0 ; extra == 'rtd' + - myst-parser ; extra == 'rtd' + - pyyaml ; extra == 'rtd' + - sphinx ; extra == 'rtd' + - sphinx-copybutton ; extra == 'rtd' + - sphinx-design ; extra == 'rtd' + - sphinx-book-theme~=1.0 ; extra == 'rtd' + - jupyter-sphinx ; extra == 'rtd' + - ipykernel ; extra == 'rtd' + - coverage ; extra == 'testing' + - pytest ; extra == 'testing' + - pytest-cov ; extra == 'testing' + - pytest-regressions ; extra == 'testing' + - pytest-timeout ; extra == 'testing' + - requests ; extra == 'testing' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - name: websockets - version: 16.1.1 - sha256: f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8 +- pypi: https://files.pythonhosted.org/packages/be/d9/5f4ccd96d7c776e822e63d2b79e3594653bcf55720dbad1cdcbc18a170ad/py_yaml12-0.1.0-cp310-abi3-macosx_11_0_arm64.whl + name: py-yaml12 + version: 0.1.0 + sha256: f6dcce183b6744c0909a8440d1f7a00836023826e463601d03b879c0e84c8a19 requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl name: pydantic-settings @@ -6934,6 +7486,105 @@ packages: - socksio==1.* ; extra == 'socks' - trio>=0.22.0,<1.0 ; extra == 'trio' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: ruff + version: 0.16.2 + sha256: ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl + name: uvloop + version: 0.22.1 + sha256: c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9 + requires_dist: + - aiohttp>=3.10.5 ; extra == 'test' + - flake8~=6.1 ; extra == 'test' + - psutil ; extra == 'test' + - pycodestyle~=2.11.0 ; extra == 'test' + - pyopenssl~=25.3.0 ; extra == 'test' + - mypy>=0.800 ; extra == 'test' + - setuptools>=60 ; extra == 'dev' + - cython~=3.0 ; extra == 'dev' + - sphinx~=4.1.2 ; extra == 'docs' + - sphinxcontrib-asyncio~=0.3.0 ; extra == 'docs' + - sphinx-rtd-theme~=0.5.2 ; extra == 'docs' + requires_python: '>=3.8.1' +- pypi: https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl + name: jupyter-console + version: 6.6.3 + sha256: 309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485 + requires_dist: + - ipykernel>=6.14 + - ipython + - jupyter-client>=7.0.0 + - jupyter-core>=4.12,!=5.0.* + - prompt-toolkit>=3.0.30 + - pygments + - pyzmq>=17 + - traitlets>=5.4 + - flaky ; extra == 'test' + - pexpect ; extra == 'test' + - pytest ; extra == 'test' + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl + name: fastapi + version: 0.141.1 + sha256: bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 + requires_dist: + - starlette>=0.46.0 + - pydantic>=2.9.0 + - typing-extensions>=4.8.0 + - typing-inspection>=0.4.2 + - annotated-doc>=0.0.2 + - fastapi-cli[standard]>=0.0.32 ; extra == 'standard' + - fastar>=0.9.0 ; extra == 'standard' + - httpx>=0.23.0,<1.0.0 ; extra == 'standard' + - jinja2>=3.1.5 ; extra == 'standard' + - python-multipart>=0.0.18 ; extra == 'standard' + - email-validator>=2.0.0 ; extra == 'standard' + - uvicorn[standard]>=0.12.0 ; extra == 'standard' + - pydantic-settings>=2.0.0 ; extra == 'standard' + - pydantic-extra-types>=2.0.0 ; extra == 'standard' + - fastapi-cli[standard-no-fastapi-cloud-cli]>=0.0.32 ; extra == 'standard-no-fastapi-cloud-cli' + - httpx>=0.23.0,<1.0.0 ; extra == 'standard-no-fastapi-cloud-cli' + - jinja2>=3.1.5 ; extra == 'standard-no-fastapi-cloud-cli' + - python-multipart>=0.0.18 ; extra == 'standard-no-fastapi-cloud-cli' + - email-validator>=2.0.0 ; extra == 'standard-no-fastapi-cloud-cli' + - uvicorn[standard]>=0.12.0 ; extra == 'standard-no-fastapi-cloud-cli' + - pydantic-settings>=2.0.0 ; extra == 'standard-no-fastapi-cloud-cli' + - pydantic-extra-types>=2.0.0 ; extra == 'standard-no-fastapi-cloud-cli' + - fastapi-cli[standard]>=0.0.32 ; extra == 'all' + - httpx>=0.23.0,<1.0.0 ; extra == 'all' + - jinja2>=3.1.5 ; extra == 'all' + - python-multipart>=0.0.18 ; extra == 'all' + - itsdangerous>=1.1.0 ; extra == 'all' + - pyyaml>=5.3.1 ; extra == 'all' + - email-validator>=2.0.0 ; extra == 'all' + - uvicorn[standard]>=0.12.0 ; extra == 'all' + - pydantic-settings>=2.0.0 ; extra == 'all' + - pydantic-extra-types>=2.0.0 ; extra == 'all' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/cb/c1/fb2c66d92b5d0167c57042b784456ee3f8531a997726c88cf6f012a22da6/json_merge_patch-0.3.0-py3-none-any.whl + name: json-merge-patch + version: 0.3.0 + sha256: e0a593719b293ff63858ecaae3afbcb4ff0b57f785453c6783d7b0c3e2708b76 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl + name: httptools + version: 0.8.0 + sha256: a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: httptools + version: 0.8.0 + sha256: 57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl + name: watchfiles + version: 1.2.0 + sha256: dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22 + requires_dist: + - anyio>=3.0.0 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl name: httpx-sse version: 0.4.3 @@ -6946,20 +7597,44 @@ packages: requires_dist: - typing-extensions>=4.12.0 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - name: pydantic-core - version: 2.33.2 - sha256: 9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d +- pypi: https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl + name: psycopg-binary + version: 3.3.4 + sha256: 41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl + name: shellingham + version: 1.5.4 + sha256: 7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl + name: griffelib + version: 2.1.0 + sha256: cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00 requires_dist: - - typing-extensions>=4.6.0,!=4.7.0 - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl + - pip>=24.0 ; extra == 'pypi' + - platformdirs>=4.2 ; extra == 'pypi' + - wheel>=0.42 ; extra == 'pypi' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: watchfiles version: 1.2.0 - sha256: a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9 + sha256: a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e requires_dist: - anyio>=3.0.0 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl + name: httptools + version: 0.8.0 + sha256: 9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl + name: click + version: 8.4.2 + sha256: e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + requires_dist: + - colorama ; sys_platform == 'win32' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl name: mcp version: 1.26.0 @@ -6984,3 +7659,10 @@ packages: - rich>=13.9.4 ; extra == 'rich' - websockets>=15.0.1 ; extra == 'ws' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl + name: pydantic-core + version: 2.33.2 + sha256: 1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab + requires_dist: + - typing-extensions>=4.6.0,!=4.7.0 + requires_python: '>=3.9' diff --git a/pyproject.toml b/pyproject.toml index 9ce732a..7715018 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,8 +11,10 @@ requires-python = ">=3.10" readme = "README.md" license = { text = "BSD-3-Clause" } dependencies = [ + "alembic>=1.17.2,<2", "sqlalchemy>=2.0", "pydantic>=2.0", + "pyyaml>=6.0", "python-slugify", "mcp[cli]>=1.26.0,<2", "httpx2>=2.0", @@ -41,14 +43,17 @@ dev = [ "pytest" ] server = [ - "strawberry-graphql[fastapi]>=0.220", + "psycopg[binary]>=3.2,<4", + "fastapi>=0.115", "uvicorn[standard]>=0.30", "pydantic-settings>=2.0", "pyyaml>=6.0", + "json-merge-patch>=0.3.0" ] [project.scripts] recap-server = "recap.server.__main__:main" +recap-authz-compile = "recap.authorization.compiler:main" [tool.hatch.version] source = "vcs" @@ -115,11 +120,12 @@ httpx2 = ">=2.0" [tool.pixi.environments] default = { solve-group = "default" } dev = { features = ["lint", "test", "server"], solve-group = "default" } -docs = { features = ["docs"], solve-group = "default" } +docs = { features = ["docs", "default", "server"], solve-group = "docs" } build = { features = [ "build" ], solve-group = "default"} [tool.pixi.feature.server.pypi-dependencies] -"strawberry-graphql" = {version = ">=0.220", extras = ["fastapi"]} +psycopg = {version = ">=3.2,<4", extras = ["binary"]} +fastapi = ">=0.115" uvicorn = {version = ">=0.30", extras = ["standard"]} pydantic-settings = ">=2.0" @@ -130,6 +136,9 @@ pytest = ">=8.4.2,<9" hatch = "*" twine = "*" +[tool.pixi.feature.lint.dependencies] +pre-commit = ">=4.0.1" + [tool.pixi.feature.build.tasks] build = { cmd = ["hatch", "build"] } publish = { cmd = ["hatch", "publish"]} @@ -137,20 +146,32 @@ check-build = { cmd = ["twine", "check", "dist/*"] } [tool] +[tool.pytest.ini_options] +markers = [ + "integration: end-to-end public API workflows across deployment modes", + "performance: SQL statement-budget regression tests; run with test-performance", +] + [tool.pixi.feature.lint.tasks] lint = { cmd = ["pre-commit", "run", "--all-files"] } [tool.pixi.feature.docs.dependencies] -mkdocs-material = "*" -mkdocstrings = "*" -pymdown-extensions = "*" +python = "3.11.*" +quarto = "*" + +[tool.pixi.feature.docs.pypi-dependencies] +great-docs = "*" [tool.pixi.feature.docs.tasks] -docs-build = { cmd = ["mkdocs", "build", "--strict"] } -docs-deploy = { cmd = ["mkdocs", "gh-deploy", "--force"] } +docs-build = { cmd = ["great-docs", "build"] } +docs-preview = { cmd = ["great-docs", "preview"] } +docs-check-links = { cmd = ["great-docs", "check-links", "--ignore", "recap\\.test"] } [tool.pixi.feature.test.tasks] -test = { cmd = ["pytest", "-s", "-ra", "recap/tests"] } +test = { cmd = ["pytest", "-ra", "-m", "not performance", "recap/tests"] } +test-integration = { cmd = ["pytest", "-ra", "-m", "integration", "recap/tests"] } +test-performance = { cmd = ["pytest", "-ra", "-m", "performance", "recap/tests"] } +test-all = { cmd = ["pytest", "-ra", "recap/tests"] } [tool.pixi.tasks] diff --git a/recap/__init__.py b/recap/__init__.py index 26d23ba..3988d01 100644 --- a/recap/__init__.py +++ b/recap/__init__.py @@ -1,3 +1,185 @@ +# ruff: noqa: E402 +# Direction must be defined before imports that use it to avoid initialization cycles. +import enum + from ._version import __version__ +from .exceptions import ( + RecapAuthenticationError, + RecapConflictError, + RecapConnectionError, + RecapError, + RecapInternalError, + RecapNotFoundError, + RecapPermissionDeniedError, + RecapProtocolError, + RecapRequestError, + RecapServiceUnavailableError, + RecapValidationError, + error_from_code, +) + + +class Direction(str, enum.Enum): + """Direction of resource flow through a process step.""" + + input = "input" + output = "output" + + +from .client import RecapClient +from .client.permissions import ( + ActorPermissions, + DenialCode, + EffectivePermissions, + PermissionDecision, +) +from .dsl.attribute_builder import AttributeGroupBuilder +from .dsl.process_builder import ( + ProcessRunBuilder, + ProcessTemplateBuilder, + StepTemplateBuilder, +) +from .dsl.query import ( + BaseQuery, + Field, + FieldOrdering, + FieldPredicate, + NamespaceQuery, + ProcessRunQuery, + ProcessTemplateQuery, + QueryDSL, + ResourceQuery, + ResourceTemplateQuery, +) +from .dsl.resource_builder import ResourceBuilder, ResourceTemplateBuilder +from .exporters import ExportContext, Exporter, ExporterRegistry +from .lifecycle import LifecycleStatus, validate_transition +from .schemas.attribute import ( + AttributeGroupRef, + AttributeGroupTemplateSchema, + AttributeTemplateSchema, + AttributeTemplateValidator, + AttributeValueSchema, +) +from .schemas.namespace import NamespaceContext, NamespaceRef, NamespaceSchema +from .schemas.process import ( + ProcessRunRef, + ProcessRunSchema, + ProcessTemplateRef, + ProcessTemplateSchema, +) +from .schemas.resource import ( + ResourceAssignmentSchema, + ResourceCopyChanges, + ResourceCopyOptions, + ResourceRef, + ResourceSchema, + ResourceTemplateRef, + ResourceTemplateSchema, + ResourceTypeSchema, +) +from .schemas.step import ( + ParameterSchema, + StepSchema, + StepTemplateRef, + StepTemplateSchema, +) + +__all__ = [ + "__version__", + "Direction", + "RecapAuthenticationError", + "RecapConflictError", + "RecapConnectionError", + "RecapError", + "RecapInternalError", + "RecapNotFoundError", + "RecapPermissionDeniedError", + "RecapProtocolError", + "RecapRequestError", + "RecapServiceUnavailableError", + "RecapValidationError", + "error_from_code", + "Field", + "LifecycleStatus", + "QueryDSL", + "RecapClient", + "validate_transition", + "ActorPermissions", + "BaseQuery", + "DenialCode", + "EffectivePermissions", + "FieldOrdering", + "FieldPredicate", + "NamespaceQuery", + "PermissionDecision", + "ProcessRunBuilder", + "ProcessRunQuery", + "ProcessTemplateBuilder", + "ProcessTemplateQuery", + "ResourceBuilder", + "ResourceQuery", + "ResourceTemplateBuilder", + "ResourceTemplateQuery", + "StepTemplateBuilder", + "AttributeGroupBuilder", + "AttributeGroupRef", + "AttributeGroupTemplateSchema", + "AttributeTemplateSchema", + "AttributeTemplateValidator", + "AttributeValueSchema", + "NamespaceContext", + "NamespaceRef", + "NamespaceSchema", + "ParameterSchema", + "ProcessRunRef", + "ProcessRunSchema", + "ProcessTemplateRef", + "ProcessTemplateSchema", + "ResourceAssignmentSchema", + "ResourceCopyChanges", + "ResourceCopyOptions", + "ResourceRef", + "ResourceSchema", + "ResourceTemplateRef", + "ResourceTemplateSchema", + "ResourceTypeSchema", + "StepSchema", + "StepTemplateRef", + "StepTemplateSchema", + "ExportContext", + "Exporter", + "ExporterRegistry", +] + +_LAZY_EXPORTS = { + "ActorPermissions": (".client.permissions", "ActorPermissions"), + "DenialCode": (".client.permissions", "DenialCode"), + "EffectivePermissions": (".client.permissions", "EffectivePermissions"), + "PermissionDecision": (".client.permissions", "PermissionDecision"), + "BaseQuery": (".dsl.query", "BaseQuery"), + "FieldOrdering": (".dsl.query", "FieldOrdering"), + "FieldPredicate": (".dsl.query", "FieldPredicate"), + "NamespaceQuery": (".dsl.query", "NamespaceQuery"), + "ProcessRunQuery": (".dsl.query", "ProcessRunQuery"), + "ProcessTemplateQuery": (".dsl.query", "ProcessTemplateQuery"), + "ResourceQuery": (".dsl.query", "ResourceQuery"), + "ResourceTemplateQuery": (".dsl.query", "ResourceTemplateQuery"), + "ProcessRunBuilder": (".dsl.process_builder", "ProcessRunBuilder"), + "ProcessTemplateBuilder": (".dsl.process_builder", "ProcessTemplateBuilder"), + "StepTemplateBuilder": (".dsl.process_builder", "StepTemplateBuilder"), + "ResourceBuilder": (".dsl.resource_builder", "ResourceBuilder"), + "ResourceTemplateBuilder": (".dsl.resource_builder", "ResourceTemplateBuilder"), + "AttributeGroupBuilder": (".dsl.attribute_builder", "AttributeGroupBuilder"), +} + + +def __getattr__(name): + target = _LAZY_EXPORTS.get(name) + if target is None: + raise AttributeError(name) + from importlib import import_module -__all__ = ["__version__"] + value = getattr(import_module(target[0], __name__), target[1]) + globals()[name] = value + return value diff --git a/recap/adapter/__init__.py b/recap/adapter/__init__.py index 8b6bc77..7c368df 100644 --- a/recap/adapter/__init__.py +++ b/recap/adapter/__init__.py @@ -1,232 +1,93 @@ -from typing import Any, Literal, Protocol, overload, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from uuid import UUID -from pydantic import BaseModel - +from recap.client.permissions import ActorPermissions +from recap.commands.models import CommandContext, CommandModel from recap.dsl.query import QuerySpec, SchemaT -from recap.schemas.attribute import AttributeGroupRef, AttributeTemplateSchema -from recap.schemas.process import ( - CampaignSchema, - ProcessRunSchema, - ProcessTemplateRef, - ProcessTemplateSchema, -) -from recap.schemas.resource import ( - ResourceRef, - ResourceSchema, - ResourceSlotSchema, - ResourceTemplateRef, - ResourceTemplateSchema, - ResourceTypeSchema, -) -from recap.schemas.step import StepSchema, StepTemplateRef -from recap.utils.general import Direction - - -class UnitOfWork(Protocol): - def commit(self, clear_session: bool = True) -> None: ... - def rollback(self) -> None: ... - def end_session(self) -> None: ... +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext + +if TYPE_CHECKING: + from recap.authorization.query import AuthorizedQuery @runtime_checkable class ReadBackend(Protocol): - """Read-only backend contract. Implemented by LocalBackend and GraphQLAdapter.""" - - def query(self, schema: type[SchemaT], spec: QuerySpec) -> list[SchemaT]: ... - def count(self, schema: type[SchemaT], spec: QuerySpec) -> int: ... - - @overload - def get_process_template( - self, - name: str | None, - version: str | None, - expand: Literal[False], - id: UUID | str | None = None, - ) -> ProcessTemplateRef: ... - - @overload - def get_process_template( - self, - name: str | None, - version: str | None, - expand: Literal[True], - id: UUID | str | None = None, - ) -> ProcessTemplateSchema: ... - - @overload - def get_resource_template( - self, - name: str | None, - version: str | None = None, - id: UUID | str | None = None, - parent: ResourceTemplateRef | ResourceTemplateSchema | None = None, - expand: Literal[False] = False, - ) -> ResourceTemplateRef: ... - - @overload - def get_resource_template( - self, - name: str | None, - version: str | None = None, - id: UUID | str | None = None, - parent: ResourceTemplateRef | ResourceTemplateSchema | None = None, - expand: Literal[True] = False, - ) -> ResourceTemplateSchema: ... - - def get_resource( - self, - name: str, - template_name: str, - template_version: str | None = "1.0", - expand: bool = False, - ) -> ResourceSchema: ... + """Read-only backend contract implemented by local and REST backends.""" - def find_resources_by_identity( - self, - name: str, - parent_id: UUID | None, - resource_template_id: UUID, - ) -> list: ... - - def get_steps(self, process_run: ProcessRunSchema) -> list[StepSchema]: ... - def get_params(self, step_schema: StepSchema) -> type[BaseModel]: ... + def query( + self, schema: type[SchemaT], spec: QuerySpec, *, namespace_path: str + ) -> list[SchemaT]: ... + def count( + self, schema: type[SchemaT], spec: QuerySpec, *, namespace_path: str + ) -> int: ... @runtime_checkable -class WriteBackend(Protocol): - """Write-only backend contract. LocalBackend (Phase 1), RESTAdapter (Phase 2).""" - - def begin(self) -> UnitOfWork: ... - - def create_campaign( - self, - name: str, - proposal: str, - saf: str | None, - metadata: dict[str, Any] | None = None, - ) -> CampaignSchema: ... - - def set_campaign(self, id: UUID) -> CampaignSchema: ... - def update_campaign(self, campaign: CampaignSchema) -> CampaignSchema: ... - - def create_process_template( - self, name: str, version: str - ) -> ProcessTemplateRef: ... - - def add_resource_slot( - self, - name: str, - resource_type: str, - direction: Direction, - process_template_ref: ProcessTemplateRef, - create_resource_type: bool = False, - required: bool = True, - ) -> ResourceSlotSchema: ... - - def add_step( - self, name: str, process_template_ref: ProcessTemplateRef - ) -> StepTemplateRef: ... - - def bind_slot( - self, - role: str, - slot_name: str, - step_template_ref: StepTemplateRef, - process_template_ref: ProcessTemplateRef, - ) -> ResourceSlotSchema: ... +class AuthorizedReadBackend(ReadBackend, Protocol): + """Server-only read contract with authorization-aware query operations.""" - def add_attr_group( + def query_authorized( self, - group_name: str, - template: ResourceTemplateRef | StepTemplateRef, - ) -> AttributeGroupRef: ... + schema: type[SchemaT], + spec: QuerySpec, + *, + authorization: "AuthorizedQuery", + ) -> list[SchemaT]: ... - def add_attribute( + def count_authorized( self, - name: str, - value_type: str, - unit: str, - default: Any, - group: AttributeGroupRef, - ) -> AttributeTemplateSchema: ... - - def remove_attribute(self, name: str) -> None: ... + schema: type[SchemaT], + spec: QuerySpec, + *, + authorization: "AuthorizedQuery", + ) -> int: ... - def add_resource_types(self, type_names: list[str]) -> list[ResourceTypeSchema]: ... - def add_resource_template( - self, name: str, type_names: list[ResourceTypeSchema], version: str = "1.0" - ) -> ResourceTemplateRef: ... - - def add_child_resource_template( - self, - name: str, - resource_types: list[ResourceTypeSchema], - parent_resource_template: ResourceTemplateRef | ResourceTemplateSchema, - version: str = "1.0", - ) -> ResourceTemplateRef: ... - - @overload - def create_resource( - self, - name: str, - resource_template: ResourceTemplateRef | ResourceTemplateSchema, - parent_resource: ResourceRef | ResourceSchema | None, - expand: Literal[False], - ) -> ResourceRef: ... - - @overload - def create_resource( - self, - name: str, - resource_template: ResourceTemplateRef | ResourceTemplateSchema, - parent_resource: ResourceRef | ResourceSchema | None, - expand: Literal[True], - ) -> ResourceSchema: ... +@runtime_checkable +class NamespaceContextResolver(Protocol): + def get_namespace_context(self, path: str) -> NamespaceContext: ... - def add_child_resources( - self, - parent_resource: ResourceSchema | ResourceRef, - child_resources: list[ResourceSchema | ResourceRef], - ) -> None: ... - def update_resource(self, resource: ResourceSchema) -> ResourceSchema: ... +@runtime_checkable +class NamespaceCatalog(Protocol): + def list_child_namespaces(self, parent_path: str) -> list[str]: ... - def create_process_run( - self, - name: str, - description: str, - process_template: ProcessTemplateRef | ProcessTemplateSchema, - campaign: CampaignSchema, - ) -> ProcessRunSchema: ... - def assign_resource( - self, - resource_slot: ResourceSlotSchema, - resource: ResourceRef | ResourceSchema, - process_run: ProcessRunSchema, - ) -> ProcessRunSchema: ... +@runtime_checkable +class PermissionsBackend(Protocol): + def permissions(self, namespace_path: str) -> ActorPermissions: ... - def check_resource_assignment( - self, - process_template: ProcessTemplateRef | ProcessTemplateSchema, - process_run: ProcessRunSchema, - ) -> None: ... - def update_process_run(self, process_run: ProcessRunSchema) -> ProcessRunSchema: ... +@runtime_checkable +class WriteBackend(Protocol): + """Write contract accepting closed commands plus trusted request context.""" - def add_child_step( + def execute( self, - process_run: ProcessRunSchema, - child_step: StepSchema, - ) -> StepSchema: ... - - def set_params(self, filled_params: type[BaseModel]) -> None: ... + command: CommandModel, + context: CommandContext, + *, + etag_override: str | None = None, + ) -> object: ... @runtime_checkable -class Backend(ReadBackend, WriteBackend, Protocol): - """Combined read+write protocol. Implemented by LocalBackend.""" - - pass +class NamespaceWriter(Protocol): + def create_namespace( + self, + path: str, + metadata: dict[str, Any] | None, + context: CommandContext, + ) -> NamespaceContext: ... + + def update_namespace( + self, + namespace_id: UUID, + expected_revision: int, + metadata: dict[str, Any] | None, + status: LifecycleStatus | None, + context: CommandContext, + *, + etag: str | None = None, + ) -> NamespaceContext: ... diff --git a/recap/adapter/entity_hydration.py b/recap/adapter/entity_hydration.py new file mode 100644 index 0000000..0b21696 --- /dev/null +++ b/recap/adapter/entity_hydration.py @@ -0,0 +1,277 @@ +from functools import lru_cache +from typing import Any, Literal, TypeVar +from uuid import UUID + +from pydantic import BaseModel + +from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate +from recap.db.process import ProcessTemplate, ResourceSlot +from recap.db.resource import Property, ResourceType +from recap.db.step import Parameter, StepTemplate +from recap.schemas.attribute import ( + AttributeGroupTemplateSchema, + AttributeTemplateSchema, + AttributeValueSchema, +) +from recap.schemas.common import SIMPLE_FIELD, LoadAware +from recap.schemas.process import ProcessTemplateSchema +from recap.schemas.resource import ( + PropertySchema, + ResourceSchema, + ResourceSlotSchema, + ResourceTemplateSchema, + ResourceTypeSchema, +) +from recap.schemas.step import ParameterSchema, StepTemplateSchema +from recap.utils.dsl import build_param_values_model + +SchemaT = TypeVar("SchemaT", bound=BaseModel) + + +@lru_cache(maxsize=128) +def _simple_field_mapping( + schema: type[BaseModel], +) -> tuple[tuple[str, str | None], ...]: + return tuple( + (name, field.alias) + for name, field in schema.model_fields.items() + if SIMPLE_FIELD in field.metadata + ) + + +class EntityHydrationContext: + """Shared caches and constructors for one backend hydration operation.""" + + def __init__(self): + self.process_template_cache: dict[UUID, ProcessTemplateSchema] = {} + self.step_template_cache: dict[UUID, StepTemplateSchema] = {} + self.resource_slot_cache: dict[UUID, ResourceSlotSchema] = {} + self.resource_type_cache: dict[UUID, ResourceTypeSchema] = {} + self.resource_template_cache: dict[UUID, ResourceTemplateSchema] = {} + self.resource_cache: dict[UUID, ResourceSchema] = {} + self.attr_group_cache: dict[UUID, AttributeGroupTemplateSchema] = {} + self.attr_template_cache: dict[UUID, AttributeTemplateSchema] = {} + self._in_progress: set[tuple[str, UUID]] = set() + + def in_progress(self, family: str, entity_id: UUID) -> bool: + return (family, entity_id) in self._in_progress + + def begin(self, family: str, entity_id: UUID) -> None: + self._in_progress.add((family, entity_id)) + + def finish(self, family: str, entity_id: UUID) -> None: + self._in_progress.discard((family, entity_id)) + + @staticmethod + def merge_load_state( + model: LoadAware, + updates: dict[str, bool], + *, + on_unloaded: Literal["silent", "warn", "raise"], + ) -> None: + private = getattr(model, "__pydantic_private__", None) or {} + current = dict(private.get("_loaded_relations", {})) + merged = dict(current) + for relation, loaded in updates.items(): + merged[relation] = current.get(relation, False) or loaded + model.set_loaded_relations(merged, on_unloaded=on_unloaded) + + def simple_field_values(self, schema: type[SchemaT], source: Any) -> dict[str, Any]: + values = {} + for field_name, alias in _simple_field_mapping(schema): + if hasattr(source, field_name): + values[field_name] = getattr(source, field_name) + elif alias and hasattr(source, alias): + values[field_name] = getattr(source, alias) + return values + + def construct_with_simple_fields( + self, schema: type[SchemaT], source: Any, **overrides: Any + ) -> SchemaT: + values = self.simple_field_values(schema, source) + values.update(overrides) + return schema.model_construct(**values) + + def build_param_values_model( + self, template: AttributeGroupTemplateSchema + ) -> type[BaseModel]: + key = tuple( + (at.name, at.slug, at.value_type, at.metadata, at.unit) + for at in template.attribute_templates + ) + return build_param_values_model(template.slug or template.name, key) + + def construct_attribute_template( + self, template: AttributeTemplate + ) -> AttributeTemplateSchema: + cached = self.attr_template_cache.get(template.id) + if cached is None: + cached = AttributeTemplateSchema.model_validate(template, from_attributes=True) + self.attr_template_cache[template.id] = cached + return cached + + def construct_attribute_group_template( + self, template: AttributeGroupTemplate + ) -> AttributeGroupTemplateSchema: + cached = self.attr_group_cache.get(template.id) + if cached is not None: + return cached + cached = self.construct_with_simple_fields( + AttributeGroupTemplateSchema, template, attribute_templates=[] + ) + self.attr_group_cache[template.id] = cached + cached.attribute_templates = [ + self.construct_attribute_template(item) + for item in template.attribute_templates + ] + cached.set_loaded_relations({"attribute_templates": True}) + return cached + + def construct_resource_type(self, resource_type: ResourceType) -> ResourceTypeSchema: + cached = self.resource_type_cache.get(resource_type.id) + if cached is None: + cached = ResourceTypeSchema.model_validate(resource_type, from_attributes=True) + self.resource_type_cache[resource_type.id] = cached + return cached + + def construct_resource_slot(self, slot: ResourceSlot) -> ResourceSlotSchema: + cached = self.resource_slot_cache.get(slot.id) + if cached is None: + cached = ResourceSlotSchema.model_validate(slot, from_attributes=True) + self.resource_slot_cache[slot.id] = cached + return cached + + def construct_step_template( + self, + template: StepTemplate, + *, + include_relations: bool, + on_unloaded: Literal["silent", "warn", "raise"], + ) -> StepTemplateSchema: + schema = self.step_template_cache.get(template.id) + if schema is None: + schema = self.construct_with_simple_fields( + StepTemplateSchema, + template, + attribute_group_templates=[], + resource_slots={}, + ) + schema.set_loaded_relations( + {"attribute_group_templates": False, "resource_slots": False}, + on_unloaded=on_unloaded, + ) + self.step_template_cache[template.id] = schema + + if not include_relations or self.in_progress("step_template", template.id): + return schema + + self.begin("step_template", template.id) + loaded_now: dict[str, bool] = {} + try: + if not schema.is_loaded("attribute_group_templates"): + schema.attribute_group_templates = [ + self.construct_attribute_group_template(item) + for item in template.attribute_group_templates + ] + loaded_now["attribute_group_templates"] = True + if not schema.is_loaded("resource_slots"): + schema.resource_slots = { + binding.role: self.construct_resource_slot(binding.resource_slot) + for binding in template.bindings.values() + } + loaded_now["resource_slots"] = True + self.merge_load_state(schema, loaded_now, on_unloaded=on_unloaded) + return schema + finally: + self.finish("step_template", template.id) + + def construct_process_template( + self, + template: ProcessTemplate, + *, + include_relations: bool, + on_unloaded: Literal["silent", "warn", "raise"], + ) -> ProcessTemplateSchema: + schema = self.process_template_cache.get(template.id) + if schema is None: + schema = self.construct_with_simple_fields( + ProcessTemplateSchema, + template, + step_templates={}, + resource_slots=[], + ) + schema.set_loaded_relations( + {"step_templates": False, "resource_slots": False}, + on_unloaded=on_unloaded, + ) + self.process_template_cache[template.id] = schema + + if not include_relations or self.in_progress("process_template", template.id): + return schema + + self.begin("process_template", template.id) + loaded_now: dict[str, bool] = {} + try: + if not schema.is_loaded("resource_slots"): + schema.resource_slots = [ + self.construct_resource_slot(item) for item in template.resource_slots + ] + loaded_now["resource_slots"] = True + if not schema.is_loaded("step_templates"): + schema.step_templates = { + item.name: self.construct_step_template( + item, + include_relations=True, + on_unloaded=on_unloaded, + ) + for item in template.step_templates.values() + } + loaded_now["step_templates"] = True + self.merge_load_state(schema, loaded_now, on_unloaded=on_unloaded) + return schema + finally: + self.finish("process_template", template.id) + + def construct_property_schema(self, prop: Property) -> PropertySchema: + group = self.construct_attribute_group_template(prop.template) + values_model = self.build_param_values_model(group) + value_fields = { + at.slug: AttributeValueSchema.model_construct( + value=prop._values[at.name].value if at.name in prop._values else None, + unit=prop._values[at.name].unit if at.name in prop._values else at.unit, + metadata_json=( + prop._values[at.name].metadata_json + if at.name in prop._values + else {} + ), + ) + for at in group.attribute_templates + } + return self.construct_with_simple_fields( + PropertySchema, + prop, + template=group, + values=values_model.model_construct(**value_fields), + ) + + def construct_parameter_schema(self, param: Parameter) -> ParameterSchema: + group = self.construct_attribute_group_template(param.template) + values_model = self.build_param_values_model(group) + value_fields = { + at.slug: AttributeValueSchema.model_construct( + value=param._values[at.name].value if at.name in param._values else None, + unit=param._values[at.name].unit if at.name in param._values else at.unit, + metadata_json=( + param._values[at.name].metadata_json + if at.name in param._values + else {} + ), + ) + for at in group.attribute_templates + } + return self.construct_with_simple_fields( + ParameterSchema, + param, + template=group, + values=values_model.model_construct(**value_fields), + ) diff --git a/recap/adapter/graphql.py b/recap/adapter/graphql.py deleted file mode 100644 index e1af629..0000000 --- a/recap/adapter/graphql.py +++ /dev/null @@ -1,143 +0,0 @@ -"""GraphQL read-only adapter for RecapClient.""" - -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from typing import Any, cast -from uuid import UUID - -import httpx2 as httpx - -from recap.adapter.transport import QueryRequest, QueryResult, hydrate_result -from recap.dsl.query import QuerySpec, SchemaT -from recap.schemas.process import ProcessRunSchema -from recap.schemas.resource import ResourceSchema -from recap.schemas.step import StepSchema - -_EXECUTE_QUERY = ( - "query ExecuteQuery($schema_name: String!, $spec: JSON!) " - "{ execute_query(schema_name: $schema_name, spec: $spec) }" -) -_EXECUTE_COUNT = ( - "query ExecuteCount($schema_name: String!, $spec: JSON!) " - "{ execute_count(schema_name: $schema_name, spec: $spec) }" -) - - -def _check_graphql_errors(body: Mapping[str, Any]) -> None: - errors = body.get("errors") - if not errors: - return - if ( - not isinstance(errors, Sequence) - or isinstance(errors, str | bytes) - or not all(isinstance(error, Mapping) for error in errors) - ): - raise RuntimeError("GraphQL request failed: malformed error response") - messages = [error.get("message", "Unknown GraphQL error") for error in errors] - raise RuntimeError(f"GraphQL request failed: {'; '.join(messages)}") - - -class GraphQLAdapter: - """ReadBackend implementation over HTTP GraphQL. - - Sends QuerySpec through the transport codec and hydrates returned schemas. - - Phase 1 constraint: read-only. Write methods raise NotImplementedError. - Use LocalBackend (via RecapClient.from_url()) for writes. - """ - - def __init__(self, graphql_url: str): - self._url = graphql_url - self._client = httpx.Client(timeout=30.0) - - def close(self) -> None: - """Close the underlying HTTP client and release connections.""" - self._client.close() - - def __enter__(self) -> GraphQLAdapter: - return self - - def __exit__(self, *args: object) -> None: - self.close() - - def query(self, schema: type[SchemaT], spec: QuerySpec) -> list[SchemaT]: - request = QueryRequest.from_query(schema, spec) - response = self._client.post( - self._url, - json={ - "query": _EXECUTE_QUERY, - "variables": request.model_dump(mode="json"), - }, - ) - response.raise_for_status() - body = response.json() - _check_graphql_errors(body) - result = QueryResult.model_validate(body["data"]["execute_query"]) - return cast(list[SchemaT], hydrate_result(schema, result)) - - def count(self, schema: type[SchemaT], spec: QuerySpec) -> int: - request = QueryRequest.from_query(schema, spec) - response = self._client.post( - self._url, - json={ - "query": _EXECUTE_COUNT, - "variables": request.model_dump(mode="json"), - }, - ) - response.raise_for_status() - body = response.json() - _check_graphql_errors(body) - return body["data"]["execute_count"] - - # ------------------------------------------------------------------ # - # Read methods delegated to server (minimal implementations for now) - # Full implementations to be added as needed in follow-up tasks. - # ------------------------------------------------------------------ # - - def get_resource( - self, - name: str, - template_name: str, - template_version: str | None = "1.0", - expand: bool = False, - ) -> ResourceSchema: - raise NotImplementedError( - "get_resource via GraphQL not yet implemented — use query()" - ) - - def get_resource_template( - self, - name: str | None, - version: str | None = None, - id: UUID | str | None = None, - parent=None, - expand=False, - ): - raise NotImplementedError( - "get_resource_template via GraphQL not yet implemented — use query()" - ) - - def get_process_template( - self, - name: str | None, - version: str | None, - expand=False, - id: UUID | str | None = None, - ): - raise NotImplementedError( - "get_process_template via GraphQL not yet implemented — use query()" - ) - - def find_resources_by_identity( - self, name: str, parent_id: UUID | None, resource_template_id: UUID - ) -> list: - raise NotImplementedError( - "find_resources_by_identity via GraphQL not yet implemented" - ) - - def get_steps(self, process_run: ProcessRunSchema) -> list[StepSchema]: - raise NotImplementedError("get_steps via GraphQL not yet implemented") - - def get_params(self, step_schema: StepSchema): - raise NotImplementedError("get_params via GraphQL not yet implemented") diff --git a/recap/adapter/http_transport.py b/recap/adapter/http_transport.py new file mode 100644 index 0000000..0a6ad58 --- /dev/null +++ b/recap/adapter/http_transport.py @@ -0,0 +1,143 @@ +"""Shared authenticated HTTP transport.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx2 as httpx +from pydantic import SecretStr + +from recap.exceptions import ( + RecapConnectionError, + RecapProtocolError, + error_from_code, +) + + +@dataclass(frozen=True, slots=True) +class HTTPResult: + body: Any + etag: str | None + request_id: str | None + + +class HTTPTransport: + def __init__(self, api_key: str | SecretStr | None, *, timeout: float = 30.0): + self._api_key = api_key if isinstance(api_key, SecretStr) else SecretStr(api_key or "") + self._client = httpx.Client(timeout=timeout) + self._closed = False + + def __repr__(self) -> str: + return f"{type(self).__name__}(api_key=SecretStr('**********'))" + + def _headers(self) -> dict[str, str]: + value = self._api_key.get_secret_value() + return {"Authorization": f"Apikey {value}"} if value else {} + + def redact(self, value: str) -> str: + secret = self._api_key.get_secret_value() + return value.replace(secret, "**********") if secret else value + + def request( + self, + method: str, + url: str, + *, + json: Any = None, + headers: dict[str, str] | None = None, + params: dict[str, str] | None = None, + ) -> HTTPResult: + request_headers = { + key: value + for key, value in (headers or {}).items() + if key.lower() != "authorization" + } + request_headers.update(self._headers()) + try: + request_kwargs = {"json": json, "headers": request_headers} + if params is not None: + request_kwargs["params"] = params + response = self._client.request(method, url, **request_kwargs) + except httpx.RequestError as exc: + raise RecapConnectionError( + url=self.redact(url), message=self.redact(str(exc)) + ) from None + + request_id = response.headers.get("X-Request-ID") + if request_id is not None: + request_id = self.redact(request_id) + payload: Any = None + if response.content: + try: + payload = response.json() + except (TypeError, ValueError): + if 200 <= response.status_code < 300: + raise RecapProtocolError( + "Malformed JSON response", + url=self.redact(url), + status_code=response.status_code, + request_id=request_id, + ) from None + payload = None + + if not 200 <= response.status_code < 300: + error = payload.get("error") if isinstance(payload, dict) else None + if not isinstance(error, dict): + raise error_from_code( + "request_error", + "Malformed error response", + url=self.redact(url), + status_code=response.status_code, + request_id=request_id, + ) + code = error.get("code") + message = error.get("message") + envelope_request_id = error.get("request_id") + if ( + not isinstance(code, str) + or not code + or not isinstance(message, str) + or not message + or (envelope_request_id is not None and not isinstance(envelope_request_id, str)) + ): + raise error_from_code( + "request_error", + "Malformed error response", + url=self.redact(url), + status_code=response.status_code, + request_id=request_id, + ) + raise error_from_code( + code, + self.redact(message), + url=self.redact(url), + status_code=response.status_code, + request_id=( + request_id + if request_id is not None + else ( + self.redact(envelope_request_id) + if envelope_request_id is not None + else None + ) + ), + ) + + return HTTPResult( + payload, + response.headers.get("ETag"), + request_id, + ) + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._client.close() + + def __enter__(self) -> HTTPTransport: + return self + + def __exit__(self, *args: object) -> None: + self.close() diff --git a/recap/adapter/local.py b/recap/adapter/local.py index 5bc854e..5574d90 100644 --- a/recap/adapter/local.py +++ b/recap/adapter/local.py @@ -1,5 +1,4 @@ import json -import warnings from collections.abc import Sequence from contextlib import contextmanager from typing import Any, Literal @@ -7,46 +6,68 @@ from pydantic import BaseModel, Field, TypeAdapter, create_model from pydantic import ValidationError as PydanticError -from sqlalchemy import Float, Integer, Select, String, cast, insert, select -from sqlalchemy.exc import NoResultFound +from sqlalchemy import ( + Float, + Integer, + Select, + String, + bindparam, + cast, + exists, + func, + select, + text, +) from sqlalchemy.orm import Session, aliased from sqlalchemy.sql import and_, or_ from sqlalchemy.sql.functions import count -from recap.adapter import Backend, UnitOfWork +from recap.adapter import ( + NamespaceCatalog, + NamespaceContextResolver, + NamespaceWriter, + ReadBackend, + WriteBackend, +) from recap.adapter.process_run_construct import ProcessRunSchemaHydrator from recap.adapter.query_loaders import ( preload_options, resolve_loader_options, ) from recap.adapter.resource_construct import ResourceSchemaHydrator +from recap.adapter.schema_registry import SCHEMA_REGISTRY +from recap.authorization.query import AuthorizedQuery +from recap.commands.models import ( + CommandContext, + CommandModel, + CreateNamespace, + UpdateNamespace, +) +from recap.commands.service import CommandService from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate, AttributeValue from recap.db.base import Base -from recap.db.campaign import Campaign -from recap.db.exceptions import ValidationError +from recap.db.namespace import Namespace, NamespaceRepository from recap.db.process import ( - Direction, ProcessRun, ProcessTemplate, ResourceAssignment, - ResourceSlot, ) from recap.db.resource import ( Property, Resource, ResourceTemplate, ResourceType, - resource_template_type_association, ) -from recap.db.step import Parameter, Step, StepTemplate, StepTemplateResourceSlotBinding +from recap.db.step import Parameter, Step from recap.dsl.query import FieldOrdering, FieldPredicate, QuerySpec, SchemaT -from recap.exceptions import ExistingResourceError, ExistingResourceWarning -from recap.schemas.attribute import ( - AttributeGroupRef, - AttributeTemplateSchema, +from recap.exceptions import ( + AuthorizationDenied, + RecapNotFoundError, + RecapPermissionDeniedError, ) +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext, NamespaceRef, NamespaceSchema from recap.schemas.process import ( - CampaignSchema, ProcessRunRef, ProcessRunSchema, ProcessTemplateRef, @@ -55,23 +76,21 @@ from recap.schemas.resource import ( ResourceRef, ResourceSchema, - ResourceSlotSchema, ResourceTemplateRef, ResourceTemplateSchema, - ResourceTypeSchema, ) from recap.schemas.step import ( StepSchema, - StepTemplateRef, - StepTemplateSchema, ) -from recap.utils.database import get_or_create, load_single +from recap.utils.database import load_single from recap.utils.dsl import AliasMixin, build_param_values_model, resolve_path from recap.utils.general import make_slug, to_json_compatible from recap.utils.loaders import chain_load +from recap.utils.namespace import namespace_ancestors SCHEMA_MODEL_MAPPING: dict[type[BaseModel], type[Base]] = { - CampaignSchema: Campaign, + NamespaceSchema: Namespace, + NamespaceRef: Namespace, ResourceTemplateSchema: ResourceTemplate, ResourceTemplateRef: ResourceTemplate, ProcessRunRef: ProcessRun, @@ -83,6 +102,30 @@ } +def _set_template_load_flags( + value: BaseModel, schema: type[BaseModel], spec: QuerySpec +) -> BaseModel: + relation_fields = { + ProcessTemplateSchema: {"step_templates", "resource_slots"}, + ResourceTemplateSchema: { + "types", + "parent", + "children", + "attribute_group_templates", + }, + }.get(schema) + if relation_fields is None: + return value + preloads = set(spec.preloads) + return value.set_loaded_relations( + { + relation: spec.load_mode == "eager" or relation in preloads + for relation in relation_fields + }, + on_unloaded=spec.on_unloaded or "warn", + ) + + def _coerce_field_value(field_name: str, column, value): try: python_type = column.property.columns[0].type.python_type @@ -157,129 +200,129 @@ def _apply_field_expressions(model, stmt, spec, joined_paths): return stmt -class SQLUnitOfWork(UnitOfWork): - def __init__(self, backend: "LocalBackend", session: Session, tx): - self._backend = backend - self._session = session - self._tx = tx +_SPECIAL_FILTERS = {"labels__contains", "types__names_in"} - def commit(self, clear_session=True): - self._tx.commit() - if clear_session: - self._backend._clear_session(self._session) - def end_session(self): - self._backend._clear_session(self._session) +def _validate_query_paths(schema: type[BaseModel], spec: QuerySpec) -> None: + try: + model = SCHEMA_REGISTRY.by_model(schema).orm_model + except KeyError: + model = SCHEMA_MODEL_MAPPING[schema] + stmt = select(model) + joined_paths: dict[tuple[str, ...], type] = {} - def rollback(self): - self._tx.rollback() - self._backend._clear_session(self._session) + for field in spec.filters: + if field in _SPECIAL_FILTERS: + continue + resolve_path(model, stmt, tuple(field.split("__")), joined_paths) + for predicate in spec.predicates: + if not isinstance(predicate, FieldPredicate): + raise ValueError("Query predicates must use Field(...)") + resolve_path(model, stmt, tuple(predicate.field.split(".")), joined_paths) -class LocalBackend(Backend): + for ordering in spec.orderings: + if not isinstance(ordering, FieldOrdering): + raise ValueError("Query orderings must use Field(...)") + resolve_path(model, stmt, tuple(ordering.field.split(".")), joined_paths) + + +class LocalBackend( + ReadBackend, + WriteBackend, + NamespaceCatalog, + NamespaceContextResolver, + NamespaceWriter, +): def __init__(self, session_factory): self._session_factory = session_factory - self._session: Session | None = None - - def _get_session(self) -> Session: - if self._session is None: - raise RuntimeError("No active session; call begin() first") - return self._session - @property - def session(self) -> Session: - return self._get_session() + def close(self): + """Retained for client lifecycle compatibility; sessions are short-lived.""" + return None - def _clear_session(self, session: Session): - if self._session is session: - session.close() - self._session = None + def validate_query(self, schema: type[SchemaT], spec: QuerySpec) -> None: + _validate_query_paths(schema, spec) - def close(self): - """Close any active session if it is still open.""" - if self._session is not None: - self._session.close() - self._session = None + def execute( + self, + command: CommandModel, + context: CommandContext, + *, + etag_override: str | None = None, + ) -> object: + try: + return CommandService(self._session_factory).execute(command, context) + except AuthorizationDenied as error: + if error.conceal: + raise RecapNotFoundError("Not found") from None + raise RecapPermissionDeniedError("Permission denied") from None @contextmanager def _session_scope(self): - """Yield a session, closing it if we had to create one.""" - if self._session is not None: - yield self._session - return + """Yield one short-lived read session.""" session = self._session_factory() try: yield session finally: session.close() - def begin(self) -> UnitOfWork: - if self._session is not None: - raise RuntimeError( - "An active session already exists; nested begin() calls are not supported" - ) - session = self._session_factory() - tx = session.begin() - self._session = session - return SQLUnitOfWork(self, session, tx) - - def create_campaign( + def create_namespace( self, - name: str, - proposal: str, - saf: str | None, - metadata: dict[str, Any] | None = None, - ) -> CampaignSchema: - self._campaign = Campaign( - name=name, - proposal=str(proposal), - saf=saf, - meta_data=metadata, + path: str, + metadata: dict[str, Any] | None, + context: CommandContext, + ) -> NamespaceContext: + result = self.execute(CreateNamespace(path=path, metadata=metadata), context) + return self._namespace_context(result) + + def update_namespace( + self, + namespace_id: UUID, + expected_revision: int, + metadata: dict[str, Any] | None, + status: LifecycleStatus | None, + context: CommandContext, + *, + etag: str | None = None, + ) -> NamespaceContext: + result = self.execute( + UpdateNamespace( + namespace_id=namespace_id, + expected_revision=expected_revision, + metadata=metadata, + status=status, + ), + context, ) - self.session.add(self._campaign) - self.session.flush() - return CampaignSchema.model_validate(self._campaign) - - def set_campaign(self, id: UUID) -> CampaignSchema: - # The backend never short-circuits: a REST backend's campaign endpoint - # always returns fresh data per request, so caching is a client-side - # concern. ``RecapClient.set_campaign`` owns the cache + ``force`` flag. - statement = select(Campaign).filter_by(id=id) - self._campaign = self.session.execute(statement).scalar_one_or_none() - if self._campaign is None: - raise ValueError(f"Campaign with ID {id} not found") - return CampaignSchema.model_validate(self._campaign) - - def update_campaign(self, campaign: CampaignSchema) -> CampaignSchema: - statement = select(Campaign).filter_by(id=campaign.id) - model = self.session.execute(statement).scalar_one_or_none() - if model is None: - raise ValueError(f"Campaign with ID {campaign.id} not found") - model.name = campaign.name - model.proposal = str(campaign.proposal) - model.saf = campaign.saf - model.meta_data = campaign.meta_data - self._campaign = model - self.session.flush() - return CampaignSchema.model_validate(model) - - def create_process_template( - self, name: str, version: str - ) -> ProcessTemplateRef | None: - template, created = get_or_create( - self.session, ProcessTemplate, {"name": name, "version": version}, {} + return self._namespace_context(result) + + @staticmethod + def _namespace_context( + namespace: NamespaceContext | NamespaceSchema, + ) -> NamespaceContext: + if isinstance(namespace, NamespaceContext): + return namespace + return NamespaceContext( + id=namespace.id, + path=namespace.path, + metadata=namespace.metadata, + status=namespace.status, + revision=namespace.revision, + etag=None, ) - if created: - return ProcessTemplateRef.model_validate(template) def get_process_template( self, + namespace_id: UUID, name: str | None, version: str | None, expand: bool = False, id: UUID | str | None = None, ) -> ProcessTemplateRef | ProcessTemplateSchema: - statement = select(ProcessTemplate) + statement = select(ProcessTemplate).where( + ProcessTemplate.namespace_id == namespace_id + ) if name: statement = statement.where(ProcessTemplate.name == name) if version: @@ -295,247 +338,24 @@ def get_process_template( chain_load(ProcessTemplate.step_templates), chain_load(ProcessTemplate.resource_slots), ) - process_template = load_single(self.session, statement, label="ProcessTemplate") - if expand: - return ProcessTemplateSchema.model_validate(process_template) - return ProcessTemplateRef.model_validate(process_template) - - def add_resource_slot( - self, - name: str, - resource_type: str, - direction: Direction, - process_template_ref: ProcessTemplateRef, - create_resource_type=False, - required: bool = True, - ) -> ResourceSlotSchema: - rt = self.session.execute( - select(ResourceType).filter_by(name=resource_type) - ).scalar_one_or_none() - if rt is None: - if not create_resource_type: - raise ValueError( - f"Could not find resource_type named {resource_type}. Use create_resource_type=True to create one" - ) - else: - rt = ResourceType(name=resource_type) - self.session.add(rt) - slot, _ = get_or_create( - self.session, - ResourceSlot, - where={"process_template_id": process_template_ref.id, "name": name}, - defaults={ - "resource_type": rt, - "direction": direction, - "required": required, - }, - ) - if slot.resource_type_id != rt.id or slot.direction != direction: - raise ValueError( - f"ResourceSlot {name} already exists with different type/direction" - ) - return ResourceSlotSchema.model_validate(slot) - - def add_step( - self, name: str, process_template_ref: ProcessTemplateRef - ) -> StepTemplateRef: - step_template, _ = get_or_create( - self.session, - StepTemplate, - where={"name": name, "process_template_id": process_template_ref.id}, - defaults={}, - ) - return StepTemplateRef.model_validate(step_template) - - def bind_slot( - self, - role: str, - slot_name: str, - process_template_ref: ProcessTemplateRef, - step_template_ref: StepTemplateRef, - ) -> ResourceSlotSchema | None: - slot = self.session.scalars( - select(ResourceSlot).where( - ResourceSlot.process_template_id == process_template_ref.id, - ResourceSlot.name == slot_name, - ) - ).one_or_none() - if slot is None: - warnings.warn( - f"Did not find ResourceSlot named {slot_name}. Nothing added", - stacklevel=2, - ) - return - step_template = self.session.scalars( - select(StepTemplate).where(StepTemplate.id == step_template_ref.id) - ).one() - step_template.resource_slots[role] = slot - return ResourceSlotSchema.model_validate(slot) - - def add_attr_group( - self, group_name: str, template_ref: StepTemplateRef | ResourceTemplateRef - ) -> AttributeGroupRef: - filter_params: dict[str, Any] = {"name": group_name} - if isinstance(template_ref, StepTemplateRef | StepTemplateSchema): - filter_params["step_template_id"] = template_ref.id - elif isinstance(template_ref, ResourceTemplateRef | ResourceTemplateSchema): - filter_params["resource_template_id"] = template_ref.id - results = list( - self.session.scalars( - select(AttributeGroupTemplate) - .filter_by(**filter_params) - .order_by(AttributeGroupTemplate.create_date) - ) - ) - if results: - if len(results) > 1: - warnings.warn( - f"Multiple attribute groups named {group_name!r} exist for template " - f"{template_ref}; using the first existing group.", - stacklevel=2, - ) - attr_group_template = results[0] - else: - attr_group_template = AttributeGroupTemplate(**filter_params) - self.session.add(attr_group_template) - self.session.flush() - return AttributeGroupRef.model_validate(attr_group_template) - - def add_attribute( - self, - name: str, - value_type: str, - unit: str, - default: Any, - attribute_group_ref: AttributeGroupRef, - metadata: dict[str, Any] | None = None, - ) -> AttributeTemplateSchema: - # The default_value column is Mapped[str | None]. Non-string values - # (e.g. MutableList from type="array") must be serialized to a JSON - # string so they can be stored and compared in filter_by(). - if isinstance(default, list): - serialized_default = json.dumps(default, default=str) - else: - serialized_default = default - - filter_params: dict[str, Any] = { - "name": name, - "value_type": value_type, - "unit": unit, - "default_value": serialized_default, - "attribute_group_template_id": attribute_group_ref.id, - "metadata_json": metadata or {}, - } - attribute_template = self.session.execute( - select(AttributeTemplate).filter_by(**filter_params) - ).scalar_one_or_none() - if attribute_template is None: - attribute_template = AttributeTemplate(**filter_params) - self.session.add(attribute_template) - self.session.flush() - - return AttributeTemplateSchema.model_validate(attribute_template) - - def remove_attribute(self, name: str, attribute_group: AttributeGroupRef): - attribute = self.session.execute( - select(AttributeTemplate).filter_by( - name=name, attribute_group_template_id=attribute_group.id - ) - ).scalar_one_or_none() - if attribute is None: - warnings.warn( - f"Property does not exist in group {attribute_group.name}: {name}", - stacklevel=2, - ) - self.session.delete(attribute) - self.session.flush() - - def add_resource_types(self, type_names: list[str]) -> list[ResourceTypeSchema]: - resource_type_schemas = [] - for type_name in type_names: - where = {"name": type_name} - resource_type, _ = get_or_create(self.session, ResourceType, where=where) - resource_type_schemas.append( - ResourceTypeSchema.model_validate(resource_type) - ) - - return resource_type_schemas - - def add_resource_template( - self, name: str, types: list[ResourceTypeSchema], version: str = "1.0" - ) -> ResourceTemplateRef: - template = ResourceTemplate( - name=name, - version=version, - # types=types, - ) - self.session.add(template) - self.session.flush() - type_ids = [type.id for type in types] - if type_ids: - self.session.execute( - insert(resource_template_type_association), - [ - {"resource_template_id": template.id, "resource_type_id": type_id} - for type_id in type_ids - ], - ) - return ResourceTemplateRef.model_validate(template) - - def add_child_resource_template( - self, - name: str, - resource_types: list[ResourceTypeSchema], - parent_resource_template: ResourceTemplateRef | ResourceTemplateSchema, - version: str = "1.0", - ) -> ResourceTemplateRef: - stmt = select(ResourceType).where( - ResourceType.id.in_([type.id for type in resource_types]) - ) - resource_type_results = self.session.scalars(stmt).all() - - template = ResourceTemplate( - name=name, - version=version, - types=resource_type_results, - ) - parent_template = self.session.get( - ResourceTemplate, parent_resource_template.id - ) - if parent_template is None: - raise NoResultFound( - f"Parent template: {parent_resource_template.name} with id {parent_resource_template.id} not found" - ) - parent_template.children[template.name] = template - self.session.add(template) - self.session.flush() - return ResourceTemplateRef.model_validate(template) - - def add_child_resources( - self, - parent_resource: ResourceSchema | ResourceRef, - child_resources: list[ResourceSchema | ResourceRef], - ): - parent = self.session.get(Resource, parent_resource.id) - children_stmt = select(Resource).where( - Resource.id.in_([r.id for r in child_resources]) - ) - child_resources_results = self.session.scalars(children_stmt).all() - if parent: - for c in child_resources_results: - parent.children[c.name] = c - self.session.add(parent) - self.session.flush() + with self._session_scope() as session: + process_template = load_single(session, statement, label="ProcessTemplate") + if expand: + return ProcessTemplateSchema.model_validate(process_template) + return ProcessTemplateRef.model_validate(process_template) def get_resource_template( self, + namespace_id: UUID, name: str | None, version: str | None = None, id: UUID | str | None = None, parent: ResourceTemplateRef | ResourceTemplate | None = None, expand: bool = False, ) -> ResourceTemplateRef | ResourceTemplateSchema: - statement = select(ResourceTemplate) + statement = select(ResourceTemplate).where( + ResourceTemplate.namespace_id == namespace_id + ) if name: statement = statement.where(ResourceTemplate.name == name) if version: @@ -555,14 +375,15 @@ def get_resource_template( chain_load(ResourceTemplate.children), chain_load(ResourceTemplate.attribute_group_templates), ) - template = load_single(self.session, statement, label="ResourceTemplate") - - if expand: - return ResourceTemplateSchema.model_validate(template) - return ResourceTemplateRef.model_validate(template) + with self._session_scope() as session: + template = load_single(session, statement, label="ResourceTemplate") + if expand: + return ResourceTemplateSchema.model_validate(template) + return ResourceTemplateRef.model_validate(template) def find_resources_by_identity( self, + namespace_id: UUID, name: str, parent_id: UUID | None, template_id: UUID, @@ -579,75 +400,18 @@ def find_resources_by_identity( select(Resource) .where( parent_clause, + Resource.namespace_id == namespace_id, Resource.name == name, Resource.resource_template_id == template_id, ) .order_by(Resource.create_date, Resource.id) ) - return list(self.session.scalars(stmt).all()) - - def create_resource( - self, - name: str, - resource_template: ResourceTemplateRef | ResourceTemplateSchema, - parent_resource: ResourceRef | ResourceSchema | None = None, - expand=False, - on_existing: Literal["create", "silent", "warn", "raise"] = "create", - ) -> ResourceRef | ResourceSchema: - parent_id = parent_resource.id if parent_resource else None - template_model = self.session.get(ResourceTemplate, resource_template.id) - - if on_existing != "create": - matches = self.find_resources_by_identity( - name, parent_id, resource_template.id - ) - if matches: - existing = matches[0] - if on_existing == "raise": - raise ExistingResourceError(f"Resource {name!r} already exists") - if on_existing == "warn": - warnings.warn( - f"Resource {name!r} already exists and will be reused; " - "no new resource will be created.", - ExistingResourceWarning, - stacklevel=2, - ) - if expand: - return ResourceSchema.model_validate(existing) - return ResourceRef.model_validate(existing) - - resource = Resource( - name=name, - resource_template_id=resource_template.id, - parent_id=parent_id, - template=template_model, - ) - - # Guard against the DB UNIQUE constraint on (parent_id, name). - # This is a broader check than find_resources_by_identity (which also - # filters by template_id) — two children under the same parent cannot - # share a name regardless of template. - if parent_id is not None: - dup = self.session.scalars( - select(Resource).where( - Resource.parent_id == parent_id, - Resource.name == name, - ) - ).first() - if dup is not None: - raise ValidationError( - "name", - f"Resource {name!r} already exists under parent {parent_id!r}", - ) - - self.session.add(resource) - self.session.flush() - if expand: - return ResourceSchema.model_validate(resource) - return ResourceRef.model_validate(resource) + with self._session_scope() as session: + return list(session.scalars(stmt).all()) def get_resource( self, + namespace_id: UUID, name: str, template_name: str, template_version: str | None = "1.0", @@ -658,9 +422,9 @@ def get_resource( .join(Resource.template) .where( Resource.name == name, + Resource.namespace_id == namespace_id, ResourceTemplate.name == template_name, ResourceTemplate.version == template_version, - Resource.active.is_(True), ) ) with self._session_scope() as session: @@ -687,124 +451,6 @@ def get_resource( return ResourceRef.model_validate(resource) - def create_process_run( - self, - name: str, - description: str, - process_template: ProcessTemplateRef | ProcessTemplateSchema, - campaign: CampaignSchema, - ) -> ProcessRunSchema: - statement = select(ProcessTemplate).where( - ProcessTemplate.id == process_template.id - ) - process_template_model = load_single( - self.session, statement, label="ProcessTemplate" - ) - process_run = ProcessRun( - name=name, - description=description, - template=process_template_model, - campaign_id=campaign.id, - ) - self.session.add(process_run) - self.session.flush() - return ProcessRunSchema.model_validate(process_run) - - def assign_resource( - self, - resource_slot: ResourceSlotSchema, - resource: ResourceRef | ResourceSchema, - process_run: ProcessRunSchema, - ) -> ProcessRunSchema: - resource_slot_model = load_single( - self.session, - select(ResourceSlot).where(ResourceSlot.id == resource_slot.id), - label="ResourceSlot", - ) - resource_model = load_single( - self.session, - select(Resource) - .where(Resource.id == resource.id) - .options(chain_load(Resource.template, ResourceTemplate.types)), - label="Resource", - ) - process_run_model = load_single( - self.session, - select(ProcessRun) - .where(ProcessRun.id == process_run.id) - .options( - chain_load(ProcessRun.assignments), - chain_load(ProcessRun.template), - ), - label="ProcessRun", - ) - - if ( - resource_slot_model.process_template_id - != process_run_model.process_template_id - ): - raise ValueError( - f"Resource slot {resource_slot_model.name!r} does not belong to " - f"process template for run {process_run_model.name!r}" - ) - - if resource_model.template is None: - raise ValueError( - f"Resource {resource_model.name!r} must have a template with types" - ) - - if not resource_model.active: - raise ValueError(f"Resource {resource_model.name!r} is inactive") - - template_type_ids = {rt.id for rt in resource_model.template.types} - if resource_slot_model.resource_type_id not in template_type_ids: - raise ValueError( - f"Resource {resource_model.name!r} does not match required type for " - f"slot {resource_slot_model.name!r}" - ) - - for existing in process_run_model.assignments.values(): - if existing.resource_slot_id == resource_slot_model.id: - raise ValueError( - f"Slot {resource_slot_model.name!r} is already assigned in " - f"run {process_run_model.name!r}" - ) - - try: - process_run_model.resources[resource_slot_model] = resource_model - except ValueError as exc: - raise ValueError( - f"Could not assign resource {resource_model.name!r} " - f"to slot {resource_slot_model.name!r}: {exc}" - ) from exc - - return ProcessRunSchema.model_validate(process_run_model) - - def check_resource_assignment( - self, - process_template: ProcessTemplateRef | ProcessTemplateSchema, - process_run: ProcessRunSchema, - ): - statement = select(ResourceSlot).where( - ResourceSlot.process_template_id == process_template.id, - ) - _resource_slots = self.session.scalars(statement).all() - # Only required slots must be assigned - required_ids = {slot.id for slot in _resource_slots if slot.required} - assigned_ids = {ar.slot.id for ar in process_run.assigned_resources.values()} - - missing_ids = required_ids - assigned_ids - if not missing_ids: - return - - missing_names = [ - slot.name for slot in _resource_slots if slot.id in missing_ids - ] - raise ValueError( - f"Process run {process_run.name} is missing resources for slots: " - f"{', '.join(missing_names)}" - ) - def get_steps(self, process_run: ProcessRunSchema) -> list[StepSchema]: statement = ( select(Step) @@ -818,214 +464,57 @@ def get_steps(self, process_run: ProcessRunSchema) -> list[StepSchema]: chain_load(Step.assignments, ResourceAssignment.resource_slot), ) ) - steps = self.session.scalars(statement).all() - return [StepSchema.model_validate(step) for step in steps] + with self._session_scope() as session: + steps = session.scalars(statement).all() + return [StepSchema.model_validate(step) for step in steps] def get_params(self, step_schema: StepSchema) -> type[BaseModel]: statement = select(Step).where( Step.id == step_schema.id, ) - step: Step | None = self.session.scalars(statement).one_or_none() - if step is None: - raise LookupError(f"Step not found: {step_schema.name}") - params: dict[str, tuple] = { - "step_name": ( - Literal[f"{step_schema.name}"], - Field(default=step_schema.name), - ), - "step_id": (UUID, Field(default=step.id)), - } - for _name, param in step.parameters.items(): - tmpl_key = tuple( - ( - vt.name, - vt.slug, - vt.value_type, - vt.metadata_json, - vt.unit, - ) - for vt in param.template.attribute_templates - ) - values_model = build_param_values_model(param.template.slug, tmpl_key) - raw_values = { - av.template.name: {"value": av.value, "unit": av.unit} - for av in param._values.values() - } - params[param.template.slug] = ( - values_model, - Field( - default_factory=lambda vm=values_model, - values=raw_values: vm.model_validate(values), - alias=param.template.name, - ), - ) - model = create_model( - f"{step_schema.name}", **params, __base__=(AliasMixin, BaseModel) - ) - return model() - - def set_params(self, filled_params: type[BaseModel]): - statement = select(Step).where(Step.id == filled_params.step_id) - step: Step | None = self.session.scalars(statement).one_or_none() - if step is None: - raise LookupError(f"Step not found in database: {filled_params.step_name}") - for param in step.parameters.values(): - filled_param = filled_params.get(param.template.name) - for value_name, av in param._values.items(): - entry = filled_param.get(value_name) - if isinstance(entry, dict): - av.set_value(entry.get("value")) - unit = entry.get("unit") - else: - av.set_value(getattr(entry, "value", entry)) - unit = getattr(entry, "unit", None) - av.unit = av.template.unit if unit is None else unit - # Flush so that _reload_process_run sees the updated values - self.session.flush() - - def add_child_step( # noqa - self, process_run: ProcessRunSchema, child_step: StepSchema - ) -> StepSchema: - pr_model = load_single( - self.session, - select(ProcessRun) - .where(ProcessRun.id == process_run.id) - .options( - chain_load(ProcessRun.steps, Step.parameters, Parameter._values), - chain_load( - ProcessRun.steps, - Step.assignments, - ResourceAssignment.resource, - Resource.template, - ), - chain_load( - ProcessRun.steps, Step.assignments, ResourceAssignment.resource_slot - ), - chain_load( - ProcessRun.assignments, - ResourceAssignment.resource, - Resource.children, + with self._session_scope() as session: + step: Step | None = session.scalars(statement).one_or_none() + if step is None: + raise RecapNotFoundError(f"Step not found: {step_schema.name}") + params: dict[str, tuple] = { + "step_name": ( + Literal[f"{step_schema.name}"], + Field(default=step_schema.name), ), - ), - label="ProcessRun", - ) - - parent_step = next( - (s for s in pr_model.steps.values() if s.id == child_step.parent_id), None - ) - if parent_step is None: - raise ValueError( - f"Parent step with id {child_step.parent_id} not found in run {pr_model.name}" - ) - - template = load_single( - self.session, - select(StepTemplate) - .where( - StepTemplate.process_template_id == pr_model.process_template_id, - StepTemplate.id == child_step.template.id, - ) - .options( - chain_load( - StepTemplate.bindings, StepTemplateResourceSlotBinding.resource_slot - ) - ), - label="StepTemplate", - ) - - step_name = child_step.name if child_step.name else template.name - if pr_model.steps: - idx = 1 - while step_name in pr_model.steps: - idx += 1 - step_name = f"{step_name} ({idx})" - - with self.session.no_autoflush: - step = Step(template=template, name=step_name) - # Add before setting parent/backrefs to avoid transient-child warnings. - self.session.add(step) - step.parent = parent_step - pr_model.steps[step.name] = step - - if child_step.parameters: - for group_name, params in child_step.parameters.items(): - if group_name not in step.parameters: - raise ValueError( - f"Step {step_name} has no parameter group {group_name}" + "step_id": (UUID, Field(default=step.id)), + } + for param in step.parameters.values(): + template_key = tuple( + ( + value_template.name, + value_template.slug, + value_template.value_type, + value_template.metadata_json, + value_template.unit, ) - param = step.parameters[group_name] - for key, value in params.items(): - if key not in param._values: - raise ValueError( - f"Parameter {key} not found in group {group_name}" - ) - av = param._values[key] - if isinstance(value, dict): - av.set_value(value.get("value")) - unit = value.get("unit") - else: - av.set_value(getattr(value, "value", value)) - unit = getattr(value, "unit", None) - av.unit = av.template.unit if unit is None else unit - - if child_step.resources: - self._assign_step_resources(step, pr_model, child_step.resources) - - self.session.flush() - return StepSchema.model_validate(step) - - def _resource_is_descendant_or_same(self, candidate: Resource, root: Resource): - current = candidate - while current is not None: - if current.id == root.id: - return True - current = current.parent - return False - - def _assign_step_resources( - self, - step: Step, - process_run_model: ProcessRun, - resources: dict[str, ResourceRef | ResourceSchema], - ): - slot_by_role = { - b.role: b.resource_slot for b in step.template.bindings.values() - } - for role, resource_ref in resources.items(): - if role not in slot_by_role: - raise ValueError( - f"Role {role} is not bound to a resource slot for step {step.template.name}" + for value_template in param.template.attribute_templates ) - resource_model = self._load_resource_model(resource_ref) - slot = slot_by_role[role] - root_resource = process_run_model.resources.get(slot) - if root_resource is None: - raise ValueError( - f"No resource assigned to slot {slot.name} for process run {process_run_model.name}" + values_model = build_param_values_model( + param.template.slug, template_key ) - if not self._resource_is_descendant_or_same(resource_model, root_resource): - raise ValueError( - f"Resource {resource_model.name} is not allowed for role {role}; " - f"must be the assigned resource {root_resource.name} or its child" + raw_values = { + value.template.name: { + "value": value.value, + "unit": value.unit, + } + for value in param._values.values() + } + params[param.template.slug] = ( + values_model, + Field( + default_factory=lambda vm=values_model, values=raw_values: vm.model_validate(values), + alias=param.template.name, + ), ) - with self.session.no_autoflush: - assignment = ResourceAssignment( - process_run=process_run_model, - resource_slot=slot, - resource_slot_id=slot.id, - step=step, - step_id=step.id, - ) - self.session.add(assignment) - assignment.resource = resource_model - step.assignments[slot.id] = assignment - - def _load_resource_model(self, ref: ResourceRef | ResourceSchema) -> Resource: - return load_single( - self.session, - select(Resource).where(Resource.id == ref.id), - label="Resource", - ) + model = create_model( + f"{step_schema.name}", **params, __base__=(AliasMixin, BaseModel) + ) + return model() def _coerce_value(self, pf, left_expr): # For int/float, SQLite stores JSON numbers as native numeric types, @@ -1238,17 +727,32 @@ def _load_resource_subtrees( """ if not root_ids: return [] - res_tbl = Resource.__table__ - base_cte = ( - select(res_tbl.c.id).where(res_tbl.c.id.in_(root_ids)).cte(recursive=True) - ) - children = select(res_tbl.c.id).where(res_tbl.c.parent_id == base_cte.c.id) - subtree_cte = base_cte.union_all(children) - - stmt = ( - select(Resource) - .join(subtree_cte, Resource.id == subtree_cte.c.id) - .options(*self._resource_subtree_loaders()) + subtree_ids = [ + value if isinstance(value, UUID) else UUID(str(value)) + for value in session.execute( + text( + """ + WITH RECURSIVE resource_subtree(id) AS ( + SELECT id FROM resource WHERE id IN :root_ids + UNION ALL + SELECT child.id + FROM resource AS child + JOIN resource_subtree AS parent ON child.parent_id = parent.id + ) + SELECT id FROM resource_subtree + """ + ).bindparams( + bindparam( + "root_ids", + expanding=True, + type_=Resource.__table__.c.id.type, + ) + ), + {"root_ids": root_ids}, + ).scalars().all() + ] + stmt = select(Resource).where(Resource.id.in_(subtree_ids)).options( + *self._resource_subtree_loaders() ) return list(session.scalars(stmt).unique()) @@ -1286,37 +790,147 @@ def _build_children_map( children_map.setdefault(parent_id, []).append(resource) return children_map - def _apply_campaign_scope(self, model, stmt, spec): - if spec.campaign_id is None: - return stmt + def _namespace_visibility(self, namespace_path: str) -> tuple[UUID, list[UUID]]: + paths = namespace_ancestors(namespace_path) + with self._session_scope() as session: + by_path = { + namespace.path: namespace.id + for namespace in session.scalars( + select(Namespace).where(Namespace.path.in_(paths)) + ) + } + try: + context_id = by_path[namespace_path] + except KeyError as exc: + raise RecapNotFoundError( + f"Namespace does not exist: {namespace_path}" + ) from exc + return context_id, [by_path[path] for path in paths if path in by_path] + + def get_namespace_path(self, namespace_id: UUID) -> str: + with self._session_scope() as session: + namespace = session.get(Namespace, namespace_id) + if namespace is None: + raise RecapNotFoundError(f"Namespace does not exist: {namespace_id}") + return namespace.path + + def get_namespace_context(self, path: str) -> NamespaceContext: + with self._session_scope() as session: + namespace = session.scalars( + select(Namespace).where(Namespace.path == path) + ).one_or_none() + if namespace is None: + raise RecapNotFoundError(f"Namespace does not exist: {path}") + return NamespaceContext.model_validate(namespace) + + def list_child_namespace_paths(self, parent_path: str) -> list[str]: + """Return full paths for direct children of a namespace path.""" + with self._session_scope() as session: + parent = session.scalars( + select(Namespace).where(Namespace.path == parent_path) + ).one_or_none() + if parent is None: + return [] + statement = select(Namespace).where(Namespace.parent_id == parent.id) + return [namespace.path for namespace in session.scalars(statement)] + + def list_child_namespaces(self, parent_path: str) -> list[str]: + prefix = f"{parent_path.strip('/')}/" if parent_path.strip("/") else "" + return [ + path.removeprefix(prefix) + for path in self.list_child_namespace_paths(parent_path) + ] + + def _apply_namespace_visibility( + self, model, stmt, spec: QuerySpec, namespace_path: str + ): + context_id, ancestor_ids = self._namespace_visibility(namespace_path) + statuses = [LifecycleStatus.ACTIVE] + if spec.include_mutable: + statuses.append(LifecycleStatus.MUTABLE) + if spec.include_archived: + statuses.append(LifecycleStatus.ARCHIVED) - campaign_id = spec.campaign_id if model is ProcessRun: - return stmt.where(ProcessRun.campaign_id == campaign_id) - if model is Resource: - assignment_alias = aliased( - ResourceAssignment, name="campaign_resource_assignment" + return stmt.where( + model.namespace_id == context_id, + model.status.in_(statuses), ) - pr_alias = aliased(ProcessRun, name="campaign_process_run") - stmt = stmt.join(assignment_alias, assignment_alias.resource_id == model.id) - stmt = stmt.join(pr_alias, pr_alias.id == assignment_alias.process_run_id) - stmt = stmt.where(pr_alias.campaign_id == campaign_id) - return stmt.distinct() + if model in {ProcessTemplate, ResourceTemplate, Resource}: + return stmt.where( + model.namespace_id.in_(ancestor_ids), + model.status.in_(statuses), + ) + if model is Namespace: + return stmt.where(model.id.in_(ancestor_ids), model.status.in_(statuses)) return stmt - def _build_select(self, schema: type[SchemaT], spec: QuerySpec) -> Select: - model = SCHEMA_MODEL_MAPPING[schema] + def _apply_namespace_metadata(self, stmt, spec: QuerySpec): + for key, value in spec.local_metadata_filters.items(): + stmt = stmt.where(Namespace.metadata_json[key].as_string() == value) + if spec.effective_metadata_filters: + with self._session_scope() as session: + repository = NamespaceRepository(session) + candidate_ids = list( + session.scalars(stmt.with_only_columns(Namespace.id)) + ) + matching_ids = [ + namespace_id + for namespace_id in candidate_ids + if all( + repository.effective_metadata(namespace_id).get(key) == value + for key, value in spec.effective_metadata_filters.items() + ) + ] + stmt = stmt.where(Namespace.id.in_(matching_ids)) + return stmt + + def _build_select( # noqa: C901 + self, + schema: type[SchemaT], + spec: QuerySpec, + namespace_path: str, + authorization: AuthorizedQuery | None = None, + ) -> Select: + try: + model = SCHEMA_REGISTRY.by_model(schema).orm_model + except KeyError: + model = SCHEMA_MODEL_MAPPING[schema] stmt = select(model) + if authorization is None: + stmt = self._apply_namespace_visibility(model, stmt, spec, namespace_path) + else: + context_id, ancestor_ids = self._namespace_visibility(namespace_path) + stmt = authorization.apply( + model, + stmt, + context_id=context_id, + ancestor_ids=ancestor_ids, + ) if model is ResourceTemplate and "types__names_in" in spec.filters: - type_names = spec.filters.pop("types__names_in") + type_names = spec.filters["types__names_in"] stmt = ( stmt.join(ResourceTemplate.types) .where(ResourceType.name.in_(type_names)) .group_by(ResourceTemplate.id) ) - filters = dict(spec.filters) + filters = { + key: value + for key, value in spec.filters.items() + if key != "types__names_in" + } + label = filters.pop("labels__contains", None) + if label is not None: + label_values = func.json_each(model.labels).table_valued("value") + stmt = stmt.where( + exists( + select(1) + .select_from(label_values) + .where(label_values.c.value == make_slug(label)) + ) + ) joined_paths: dict[tuple[str, ...], type] = {} simple_filters: dict[str, object] = {} @@ -1324,24 +938,57 @@ def _build_select(self, schema: type[SchemaT], spec: QuerySpec) -> Select: if "__" not in raw_key: simple_filters[raw_key] = value continue - parts = tuple(raw_key.split("__")) - stmt, attr = resolve_path(model, stmt, parts, joined_paths) - stmt = stmt.where(attr == value) if simple_filters: stmt = stmt.filter_by(**simple_filters) + for raw_key, value in filters.items(): + if "__" not in raw_key: + continue + parts = tuple(raw_key.split("__")) + stmt, attr = resolve_path(model, stmt, parts, joined_paths) + stmt = stmt.where(attr == value) + if model is Resource: stmt = self._build_select_resource(model, stmt, spec) if model is ProcessRun: stmt = self._build_select_process_run(model, stmt, spec) - stmt = self._apply_campaign_scope(model, stmt, spec) + if model is Namespace: + stmt = self._apply_namespace_metadata(stmt, spec) return _apply_field_expressions(model, stmt, spec, joined_paths) - def query(self, schema: type[SchemaT], spec: QuerySpec) -> list[SchemaT]: - stmt = self._build_select(schema, spec) + def query( + self, schema: type[SchemaT], spec: QuerySpec, *, namespace_path: str + ) -> list[SchemaT]: + return self._query(schema, spec, namespace_path, authorization=None) + + def query_authorized( + self, + schema: type[SchemaT], + spec: QuerySpec, + *, + authorization: AuthorizedQuery, + ) -> list[SchemaT]: + return self._query( + schema, + spec, + authorization.namespace_path, + authorization=authorization, + ) + + def _query( + self, + schema: type[SchemaT], + spec: QuerySpec, + namespace_path: str, + *, + authorization: AuthorizedQuery | None, + ) -> list[SchemaT]: + stmt = self._build_select( + schema, spec, namespace_path, authorization=authorization + ) # Resource trees with children go through the bulk recursive-CTE path # below; the root query then only needs ids, so skip the (one-level, @@ -1392,6 +1039,7 @@ def query(self, schema: type[SchemaT], spec: QuerySpec) -> list[SchemaT]: include_steps=include_steps, include_step_parameters=include_step_parameters, include_resources=include_resources, + include_template=spec.load_mode == "eager" or "template" in spec.preloads, full=spec.load_mode == "eager", on_unloaded=spec.on_unloaded or "warn", children_map=children_map, @@ -1431,14 +1079,65 @@ def query(self, schema: type[SchemaT], spec: QuerySpec) -> list[SchemaT]: full=spec.load_mode == "eager", on_unloaded=spec.on_unloaded or "warn", ) + if spec.load_mode != "eager" and not spec.preloads: + relation_fields = { + ProcessTemplateSchema: {"step_templates", "resource_slots"}, + ResourceTemplateSchema: { + "types", + "parent", + "children", + "attribute_group_templates", + }, + }.get(schema, set()) + return [ + _set_template_load_flags( + schema.model_construct( + **{ + name: value + for name, value in obj.__dict__.items() + if name in schema.model_fields and name not in relation_fields + } + ), + schema, + spec, + ) + for obj in list(session.scalars(stmt).unique()) + ] return [ - schema.model_validate(obj) + _set_template_load_flags(schema.model_validate(obj), schema, spec) for obj in list(session.scalars(stmt).unique()) ] - def count(self, schema: type[SchemaT], spec: QuerySpec) -> int: - # model = SCHEMA_MODEL_MAPPING[schema] - stmt = self._build_select(schema, spec) + def count( + self, schema: type[SchemaT], spec: QuerySpec, *, namespace_path: str + ) -> int: + return self._count(schema, spec, namespace_path, authorization=None) + + def count_authorized( + self, + schema: type[SchemaT], + spec: QuerySpec, + *, + authorization: AuthorizedQuery, + ) -> int: + return self._count( + schema, + spec, + authorization.namespace_path, + authorization=authorization, + ) + + def _count( + self, + schema: type[SchemaT], + spec: QuerySpec, + namespace_path: str, + *, + authorization: AuthorizedQuery | None, + ) -> int: + stmt = self._build_select( + schema, spec, namespace_path, authorization=authorization + ) with self._session_scope() as session: select_stmt = select(count()).select_from(stmt.subquery()) @@ -1451,82 +1150,3 @@ def _relationship_loaders( def get_opts_statements(self, schema, name): return preload_options(schema, name) - - def update_process_run(self, process_run: ProcessRunSchema) -> ProcessRunSchema: - with self._session_scope() as session: - owns_tx = not session.in_transaction() - tx = session.begin() if owns_tx else None - for step_schema in process_run.steps.values(): - step: Step = load_single( - session, - select(Step) - .where(Step.id == step_schema.id) - .options(chain_load(Step.parameters, Parameter._values)), - label="Step", - ) - for _, param_schema in step_schema.parameters.items(): - group_name = param_schema.template.name - if group_name not in step.parameters: - raise ValueError( - f"Step {step_schema.name} has no parameter group {group_name}" - ) - param = step.parameters[group_name] - - new_values = param_schema.values.model_dump(by_alias=True) - for key, value in new_values.items(): - if key not in param._values: - raise ValueError( - f"Parameter {key} not found in group {group_name}" - ) - av = param._values[key] - if isinstance(value, dict): - av.set_value(value.get("value")) - unit = value.get("unit") - else: - av.set_value(value) - unit = None - av.unit = av.template.unit if unit is None else unit - session.flush() - if tx: - tx.commit() - return process_run - - def update_resource(self, resource: ResourceSchema) -> ResourceSchema: - with self._session_scope() as session: - owns_tx = not session.in_transaction() - tx = session.begin() if owns_tx else None - res: Resource = load_single( - session, - select(Resource) - .where(Resource.id == resource.id) - .options( - chain_load(Resource.properties, Property._values), - chain_load(Resource.children), - ), - label="Resource", - ) - for _, prop_schema in resource.properties.items(): - tmpl_name = prop_schema.template.name - if tmpl_name not in res.properties: - raise ValueError( - f"Resource {resource.name} has no property group {tmpl_name}" - ) - prop = res.properties[tmpl_name] - new_values = prop_schema.values.model_dump(by_alias=True) - for key, value in new_values.items(): - if key not in prop._values: - raise ValueError( - f"Property {key} not found in group {tmpl_name}" - ) - av = prop._values[key] - if isinstance(value, dict): - av.set_value(value.get("value")) - unit = value.get("unit") - else: - av.set_value(value) - unit = None - av.unit = av.template.unit if unit is None else unit - session.flush() - if tx: - tx.commit() - return ResourceSchema.model_validate(res) diff --git a/recap/adapter/process_run_construct.py b/recap/adapter/process_run_construct.py index 8d73e18..c104abb 100644 --- a/recap/adapter/process_run_construct.py +++ b/recap/adapter/process_run_construct.py @@ -1,72 +1,23 @@ -from functools import lru_cache from typing import Any, Literal, TypeVar from pydantic import BaseModel -from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate +from recap.adapter.entity_hydration import EntityHydrationContext +from recap.adapter.resource_construct import ResourceSchemaHydrator from recap.db.process import ProcessRun, ProcessTemplate, ResourceSlot -from recap.db.resource import Property, Resource, ResourceTemplate, ResourceType +from recap.db.resource import Resource from recap.db.step import Parameter, StepTemplate -from recap.schemas.attribute import ( - AttributeGroupTemplateSchema, - AttributeTemplateSchema, - AttributeValueSchema, -) -from recap.schemas.common import SIMPLE_FIELD from recap.schemas.process import ProcessRunSchema, ProcessTemplateSchema -from recap.schemas.resource import ( - PropertySchema, - ResourceAssignmentSchema, - ResourceRef, - ResourceSchema, - ResourceSlotSchema, - ResourceTemplateRef, - ResourceTemplateSchema, - ResourceTypeSchema, -) +from recap.schemas.resource import ResourceAssignmentSchema, ResourceSchema from recap.schemas.step import ParameterSchema, StepSchema, StepTemplateSchema -from recap.utils.dsl import build_param_values_model SchemaT = TypeVar("SchemaT", bound=BaseModel) -@lru_cache(maxsize=128) -def _simple_field_mapping( - schema: type[BaseModel], -) -> tuple[tuple[str, str | None], ...]: - return tuple( - (field_name, field_info.alias) - for field_name, field_info in schema.model_fields.items() - if SIMPLE_FIELD in field_info.metadata - ) - - class ProcessRunSchemaHydrator: - def __init__(self): - self._process_template_cache: dict = {} - self._step_template_cache: dict = {} - self._resource_slot_cache: dict = {} - self._resource_type_cache: dict = {} - self._resource_template_cache: dict = {} - self._resource_template_ref_cache: dict = {} - self._resource_ref_cache: dict = {} - self._resource_cache: dict = {} - self._attr_group_cache: dict = {} - self._attr_template_cache: dict = {} - - def _simple_field_values( - self, - schema: type[SchemaT], - source: Any, - ) -> dict[str, Any]: - values: dict[str, Any] = {} - for field_name, alias in _simple_field_mapping(schema): - if hasattr(source, field_name): - values[field_name] = getattr(source, field_name) - continue - if alias and hasattr(source, alias): - values[field_name] = getattr(source, alias) - return values + def __init__(self, context: EntityHydrationContext | None = None): + self._context = context or EntityHydrationContext() + self._resource_hydrator = ResourceSchemaHydrator(self._context) def _construct_with_simple_fields( self, @@ -74,292 +25,73 @@ def _construct_with_simple_fields( source: Any, **overrides: Any, ) -> SchemaT: - values = self._simple_field_values(schema, source) - values.update(overrides) - return schema.model_construct(**values) - - def _build_param_values_model_from_template( - self, - template: AttributeGroupTemplateSchema, - ) -> type[BaseModel]: - tmpl_key = tuple( - ( - at.name, - at.slug, - at.value_type, - at.metadata, - at.unit, - ) - for at in template.attribute_templates - ) - return build_param_values_model(template.slug or template.name, tmpl_key) - - def _construct_attribute_template( - self, - attr_template: AttributeTemplate, - ) -> AttributeTemplateSchema: - cached = self._attr_template_cache.get(attr_template.id) - if cached is not None: - return cached - schema = AttributeTemplateSchema.model_validate( - attr_template, from_attributes=True - ) - self._attr_template_cache[attr_template.id] = schema - return schema - - def _construct_attribute_group_template( - self, - group_template: AttributeGroupTemplate, - ) -> AttributeGroupTemplateSchema: - cached = self._attr_group_cache.get(group_template.id) - if cached is not None: - return cached - schema = self._construct_with_simple_fields( - AttributeGroupTemplateSchema, - group_template, - attribute_templates=[], - ) - self._attr_group_cache[group_template.id] = schema - schema.attribute_templates = [ - self._construct_attribute_template(at) - for at in group_template.attribute_templates - ] - return schema - - def _construct_resource_type( - self, - resource_type: ResourceType, - ) -> ResourceTypeSchema: - cached = self._resource_type_cache.get(resource_type.id) - if cached is not None: - return cached - schema = ResourceTypeSchema.model_validate(resource_type, from_attributes=True) - self._resource_type_cache[resource_type.id] = schema - return schema - - def _construct_resource_template_ref( - self, - template: ResourceTemplate, - ) -> ResourceTemplateRef: - cached = self._resource_template_ref_cache.get(template.id) - if cached is not None: - return cached - schema = self._construct_with_simple_fields( - ResourceTemplateRef, - template, - parent=None, - types=[], - ) - self._resource_template_ref_cache[template.id] = schema - schema.types = [self._construct_resource_type(rt) for rt in template.types] - if template.parent is not None: - schema.parent = self._construct_resource_template_ref(template.parent) - return schema - - def _construct_resource_slot( - self, - slot: ResourceSlot, - ) -> ResourceSlotSchema: - cached = self._resource_slot_cache.get(slot.id) - if cached is not None: - return cached - schema = ResourceSlotSchema.model_validate(slot, from_attributes=True) - self._resource_slot_cache[slot.id] = schema - return schema + return self._context.construct_with_simple_fields(schema, source, **overrides) def _construct_step_template( self, template: StepTemplate, + *, + on_unloaded: Literal["silent", "warn", "raise"] = "warn", ) -> StepTemplateSchema: - cached = self._step_template_cache.get(template.id) - if cached is not None: - return cached - schema = self._construct_with_simple_fields( - StepTemplateSchema, - template, - attribute_group_templates=[], - resource_slots={}, + return self._context.construct_step_template( + template, include_relations=True, on_unloaded=on_unloaded ) - self._step_template_cache[template.id] = schema - schema.attribute_group_templates = [ - self._construct_attribute_group_template(ag) - for ag in template.attribute_group_templates - ] - schema.resource_slots = { - binding.role: self._construct_resource_slot(binding.resource_slot) - for binding in template.bindings.values() - } - return schema def _construct_step_template_minimal( self, template: StepTemplate, + *, + on_unloaded: Literal["silent", "warn", "raise"] = "warn", ) -> StepTemplateSchema: - return self._construct_with_simple_fields( - StepTemplateSchema, - template, - attribute_group_templates=[], - resource_slots={}, + return self._context.construct_step_template( + template, include_relations=False, on_unloaded=on_unloaded ) def _construct_process_template( self, template: ProcessTemplate, + *, + on_unloaded: Literal["silent", "warn", "raise"] = "warn", ) -> ProcessTemplateSchema: - cached = self._process_template_cache.get(template.id) - if cached is not None: - return cached - schema = self._construct_with_simple_fields( - ProcessTemplateSchema, - template, - step_templates={}, - resource_slots=[], + return self._context.construct_process_template( + template, include_relations=True, on_unloaded=on_unloaded ) - self._process_template_cache[template.id] = schema - schema.resource_slots = [ - self._construct_resource_slot(slot) for slot in template.resource_slots - ] - schema.step_templates = { - st.name: self._construct_step_template(st) - for st in template.step_templates.values() - } - return schema def _construct_process_template_minimal( self, template: ProcessTemplate, + *, + on_unloaded: Literal["silent", "warn", "raise"] = "warn", ) -> ProcessTemplateSchema: - return self._construct_with_simple_fields( - ProcessTemplateSchema, - template, - step_templates={}, - resource_slots=[], - ) - - def _construct_resource_template( - self, - template: ResourceTemplate, - ) -> ResourceTemplateSchema: - cached = self._resource_template_cache.get(template.id) - if cached is not None: - return cached - schema = self._construct_with_simple_fields( - ResourceTemplateSchema, - template, - types=[], - parent=None, - children={}, - attribute_group_templates=[], - ) - self._resource_template_cache[template.id] = schema - schema.types = [self._construct_resource_type(rt) for rt in template.types] - if template.parent is not None: - schema.parent = self._construct_resource_template_ref(template.parent) - schema.children = { - child.name: self._construct_resource_template(child) - for child in template.children.values() - } - schema.attribute_group_templates = [ - self._construct_attribute_group_template(ag) - for ag in template.attribute_group_templates - ] - return schema - - def _construct_resource_ref( - self, - resource: Resource, - ) -> ResourceRef: - cached = self._resource_ref_cache.get(resource.id) - if cached is not None: - return cached - schema = self._construct_with_simple_fields( - ResourceRef, - resource, - template=self._construct_resource_template_ref(resource.template), - ) - self._resource_ref_cache[resource.id] = schema - return schema - - def _construct_property_schema( - self, - prop: Property, - ) -> PropertySchema: - group_template = self._construct_attribute_group_template(prop.template) - values_model = self._build_param_values_model_from_template(group_template) - value_fields = { - at.slug: AttributeValueSchema.model_construct( - value=prop._values[at.name].value if at.name in prop._values else None, - unit=prop._values[at.name].unit if at.name in prop._values else at.unit, - ) - for at in group_template.attribute_templates - } - return self._construct_with_simple_fields( - PropertySchema, - prop, - template=group_template, - values=values_model.model_construct(**value_fields), + return self._context.construct_process_template( + template, include_relations=False, on_unloaded=on_unloaded ) def _construct_parameter_schema( self, param: Parameter, ) -> ParameterSchema: - group_template = self._construct_attribute_group_template(param.template) - values_model = self._build_param_values_model_from_template(group_template) - value_fields = { - at.slug: AttributeValueSchema.model_construct( - value=param._values[at.name].value - if at.name in param._values - else None, - unit=param._values[at.name].unit - if at.name in param._values - else at.unit, - ) - for at in group_template.attribute_templates - } - return self._construct_with_simple_fields( - ParameterSchema, - param, - template=group_template, - values=values_model.model_construct(**value_fields), - ) + return self._context.construct_parameter_schema(param) + + def _construct_resource_slot(self, slot: ResourceSlot): + return self._context.construct_resource_slot(slot) def _construct_resource_schema( self, resource: Resource, children_map: dict[Any, list[Resource]] | None = None, + *, + on_unloaded: Literal["silent", "warn", "raise"] = "warn", ) -> ResourceSchema: - cached = self._resource_cache.get(resource.id) - if cached is not None: - return cached - schema = self._construct_with_simple_fields( - ResourceSchema, + return self._resource_hydrator._construct_resource_schema( resource, - template=self._construct_resource_template(resource.template), - parent=None, - children={}, - properties={}, - ) - self._resource_cache[resource.id] = schema - if resource.parent is not None: - schema.parent = self._construct_resource_ref(resource.parent) - # When a pre-assembled ``children_map`` is supplied, build children - # from it instead of walking ``resource.children`` -- the latter - # re-triggers a lazy load per node (the N+1 this path fixes). - child_resources = ( - children_map.get(resource.id, []) - if children_map is not None - else resource.children.values() + include_template=True, + include_properties=True, + include_children=True, + full=True, + on_unloaded=on_unloaded, + children_map=children_map, ) - schema.children = { - child.name: self._construct_resource_schema(child, children_map) - for child in child_resources - } - schema.properties = { - prop.template.name: self._construct_property_schema(prop) - for prop in resource.properties.values() - } - return schema def _post_build_dynamic_models( # noqa self, @@ -402,14 +134,17 @@ def _construct_process_run_schema( include_steps: bool, include_step_parameters: bool, include_resources: bool, + include_template: bool, full: bool, on_unloaded: Literal["silent", "warn", "raise"], children_map: dict[Any, list[Resource]] | None = None, ) -> ProcessRunSchema: template = ( - self._construct_process_template(run.template) - if full - else self._construct_process_template_minimal(run.template) + self._construct_process_template(run.template, on_unloaded=on_unloaded) + if full or include_template + else self._construct_process_template_minimal( + run.template, on_unloaded=on_unloaded + ) ) steps: dict[str, StepSchema] = {} @@ -419,9 +154,13 @@ def _construct_process_run_schema( StepSchema, step, template=( - self._construct_step_template(step.template) + self._construct_step_template( + step.template, on_unloaded=on_unloaded + ) if full - else self._construct_step_template_minimal(step.template) + else self._construct_step_template_minimal( + step.template, on_unloaded=on_unloaded + ) ), parameters=( { @@ -434,13 +173,24 @@ def _construct_process_run_schema( children=[], resources=( { - role: self._construct_resource_schema(res, children_map) + role: self._construct_resource_schema( + res, children_map, on_unloaded=on_unloaded + ) for role, res in step.resources.items() } if include_resources else {} ), ) + step_schema.set_loaded_relations( + { + "template": full, + "parameters": include_step_parameters, + "children": include_steps, + "resources": include_resources, + }, + on_unloaded=on_unloaded, + ) steps[step.name] = step_schema if include_steps: @@ -460,7 +210,9 @@ def _construct_process_run_schema( ResourceAssignmentSchema.model_construct( slot=self._construct_resource_slot(assigned.slot), resource=self._construct_resource_schema( - assigned.resource, children_map + assigned.resource, + children_map, + on_unloaded=on_unloaded, ), step_id=None, ) @@ -475,6 +227,7 @@ def _construct_process_run_schema( ) process_run.set_loaded_relations( { + "template": full or include_template, "steps": include_steps, "assigned_resources": include_resources, }, @@ -493,6 +246,7 @@ def construct_many( include_steps: bool, include_step_parameters: bool, include_resources: bool, + include_template: bool, full: bool, on_unloaded: Literal["silent", "warn", "raise"], children_map: dict[Any, list[Resource]] | None = None, @@ -503,6 +257,7 @@ def construct_many( include_steps=include_steps, include_step_parameters=include_step_parameters, include_resources=include_resources, + include_template=include_template, full=full, on_unloaded=on_unloaded, children_map=children_map, diff --git a/recap/adapter/query_loaders.py b/recap/adapter/query_loaders.py index caf3e25..27f574a 100644 --- a/recap/adapter/query_loaders.py +++ b/recap/adapter/query_loaders.py @@ -1,7 +1,7 @@ from pydantic import BaseModel +from recap.adapter.schema_registry import SCHEMA_REGISTRY from recap.db.attribute import AttributeGroupTemplate, AttributeValue -from recap.db.campaign import Campaign from recap.db.process import ( ProcessRun, ProcessTemplate, @@ -11,7 +11,6 @@ from recap.db.resource import Property, Resource, ResourceTemplate from recap.db.step import Parameter, Step, StepTemplate, StepTemplateResourceSlotBinding from recap.schemas.process import ( - CampaignSchema, ProcessRunRef, ProcessRunSchema, ProcessTemplateSchema, @@ -31,6 +30,22 @@ def _dedupe_preserve_order(values: list[str]) -> list[str]: PRELOAD_STATEMENTS = { + (ProcessRunSchema, "template"): [ + chain_load(ProcessRun.template, ProcessTemplate.resource_slots), + chain_load( + ProcessRun.template, + ProcessTemplate.step_templates, + StepTemplate.attribute_group_templates, + AttributeGroupTemplate.attribute_templates, + ), + chain_load( + ProcessRun.template, + ProcessTemplate.step_templates, + StepTemplate.bindings, + StepTemplateResourceSlotBinding.resource_slot, + ResourceSlot.resource_type, + ), + ], (ProcessRunSchema, "steps"): [ chain_load(ProcessRun.steps, Step.children), chain_load(ProcessRun.steps, Step.parameters), @@ -84,7 +99,6 @@ def _dedupe_preserve_order(values: list[str]) -> list[str]: AttributeGroupTemplate.attribute_templates, ), ], - (CampaignSchema, "process_run"): [chain_load(Campaign.process_runs)], (ProcessTemplateSchema, "step_templates"): [ chain_load(ProcessTemplate.step_templates) ], @@ -163,7 +177,10 @@ def _dedupe_preserve_order(values: list[str]) -> list[str]: def preload_options(schema: type[BaseModel], name: str) -> list: - return PRELOAD_STATEMENTS[(schema, name)] + try: + return PRELOAD_STATEMENTS[(schema, name)] + except KeyError as exc: + raise ValueError(f"Unsupported preload '{name}' for {schema.__name__}") from exc def resolve_loader_options( @@ -179,5 +196,12 @@ def resolve_loader_options( opts.extend(EXTRA_FULL_LOADERS.get(schema, [])) for preload in _dedupe_preserve_order(requested_preloads): + canonical = { + ResourceRef: ResourceSchema, + ProcessRunRef: ProcessRunSchema, + }.get(schema, schema) + registration = SCHEMA_REGISTRY.by_model(canonical) + if preload not in registration.loader_capabilities: + raise ValueError(f"Unsupported preload '{preload}' for {schema.__name__}") opts.extend(preload_options(schema, preload)) return opts diff --git a/recap/adapter/resource_construct.py b/recap/adapter/resource_construct.py index 0b09c49..40b26ed 100644 --- a/recap/adapter/resource_construct.py +++ b/recap/adapter/resource_construct.py @@ -3,19 +3,17 @@ from pydantic import BaseModel +from recap.adapter.entity_hydration import EntityHydrationContext from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate from recap.db.resource import Property, Resource, ResourceTemplate, ResourceType from recap.schemas.attribute import ( AttributeGroupTemplateSchema, AttributeTemplateSchema, - AttributeValueSchema, ) from recap.schemas.common import SIMPLE_FIELD from recap.schemas.resource import ( PropertySchema, - ResourceRef, ResourceSchema, - ResourceTemplateRef, ResourceTemplateSchema, ResourceTypeSchema, ) @@ -36,14 +34,13 @@ def _simple_field_mapping( class ResourceSchemaHydrator: - def __init__(self): - self._resource_type_cache: dict = {} - self._resource_template_cache: dict = {} - self._resource_template_ref_cache: dict = {} - self._resource_ref_cache: dict = {} - self._resource_cache: dict = {} - self._attr_group_cache: dict = {} - self._attr_template_cache: dict = {} + def __init__(self, context: EntityHydrationContext | None = None): + self._context = context or EntityHydrationContext() + self._resource_type_cache = self._context.resource_type_cache + self._resource_template_cache = self._context.resource_template_cache + self._resource_cache: dict = self._context.resource_cache + self._attr_group_cache = self._context.attr_group_cache + self._attr_template_cache = self._context.attr_template_cache def _simple_field_values( self, @@ -128,101 +125,160 @@ def _construct_resource_type( self._resource_type_cache[resource_type.id] = schema return schema + def _ensure_resource_template( + self, + template: ResourceTemplate, + *, + include_parent: bool, + include_types: bool, + include_children: bool, + include_attribute_group_templates: bool, + on_unloaded: Literal["silent", "warn", "raise"], + ) -> ResourceTemplateSchema: + schema = self._resource_template_cache.get(template.id) + if schema is None: + schema = self._construct_with_simple_fields( + ResourceTemplateSchema, + template, + parent=None, + types=[], + children={}, + attribute_group_templates=[], + ) + schema.set_loaded_relations( + { + "parent": False, + "types": False, + "children": False, + "attribute_group_templates": False, + }, + on_unloaded=on_unloaded, + ) + self._resource_template_cache[template.id] = schema + + if self._context.in_progress("resource_template", template.id): + return schema + + requested = { + "parent": include_parent, + "types": include_types, + "children": include_children, + "attribute_group_templates": include_attribute_group_templates, + } + self._context.begin("resource_template", template.id) + loaded_now: dict[str, bool] = {} + try: + if requested["types"] and not schema.is_loaded("types"): + schema.types = [ + self._construct_resource_type(item) for item in template.types + ] + loaded_now["types"] = True + + if requested["parent"] and not schema.is_loaded("parent"): + schema.parent = ( + self._construct_resource_template_ref( + template.parent, + on_unloaded=on_unloaded, + ) + if template.parent is not None + else None + ) + loaded_now["parent"] = True + + if requested["children"] and not schema.is_loaded("children"): + schema.children = { + child.name: self._construct_resource_template( + child, + on_unloaded=on_unloaded, + ) + for child in template.children.values() + } + loaded_now["children"] = True + + if ( + requested["attribute_group_templates"] + and not schema.is_loaded("attribute_group_templates") + ): + schema.attribute_group_templates = [ + self._construct_attribute_group_template(group) + for group in template.attribute_group_templates + ] + loaded_now["attribute_group_templates"] = True + + self._context.merge_load_state( + schema, + loaded_now, + on_unloaded=on_unloaded, + ) + return schema + finally: + self._context.finish("resource_template", template.id) + def _construct_resource_template_ref( self, template: ResourceTemplate, - ) -> ResourceTemplateRef: - cached = self._resource_template_ref_cache.get(template.id) - if cached is not None: - return cached - schema = self._construct_with_simple_fields( - ResourceTemplateRef, + *, + on_unloaded: Literal["silent", "warn", "raise"], + ) -> ResourceTemplateSchema: + return self._ensure_resource_template( template, - parent=None, - types=[], + include_parent=True, + include_types=True, + include_children=False, + include_attribute_group_templates=False, + on_unloaded=on_unloaded, ) - self._resource_template_ref_cache[template.id] = schema - schema.types = [self._construct_resource_type(rt) for rt in template.types] - if template.parent is not None: - schema.parent = self._construct_resource_template_ref(template.parent) - return schema - def _construct_resource_template( + def _construct_resource_template_minimal( self, template: ResourceTemplate, + *, + on_unloaded: Literal["silent", "warn", "raise"], ) -> ResourceTemplateSchema: - cached = self._resource_template_cache.get(template.id) - if cached is not None: - return cached - schema = self._construct_with_simple_fields( - ResourceTemplateSchema, + return self._ensure_resource_template( template, - types=[], - parent=None, - children={}, - attribute_group_templates=[], + include_parent=False, + include_types=False, + include_children=False, + include_attribute_group_templates=False, + on_unloaded=on_unloaded, ) - self._resource_template_cache[template.id] = schema - schema.types = [self._construct_resource_type(rt) for rt in template.types] - if template.parent is not None: - schema.parent = self._construct_resource_template_ref(template.parent) - schema.children = { - child.name: self._construct_resource_template(child) - for child in template.children.values() - } - schema.attribute_group_templates = [ - self._construct_attribute_group_template(ag) - for ag in template.attribute_group_templates - ] - return schema - def _construct_resource_template_minimal( + def _construct_resource_template( self, template: ResourceTemplate, + *, + on_unloaded: Literal["silent", "warn", "raise"], ) -> ResourceTemplateSchema: - return self._construct_with_simple_fields( - ResourceTemplateSchema, + return self._ensure_resource_template( template, - types=[], - parent=None, - children={}, - attribute_group_templates=[], + include_parent=True, + include_types=True, + include_children=True, + include_attribute_group_templates=True, + on_unloaded=on_unloaded, ) def _construct_resource_ref( self, resource: Resource, - ) -> ResourceRef: - cached = self._resource_ref_cache.get(resource.id) - if cached is not None: - return cached - schema = self._construct_with_simple_fields( - ResourceRef, + *, + on_unloaded: Literal["silent", "warn", "raise"], + ) -> ResourceSchema: + return self._construct_resource_schema( resource, - template=self._construct_resource_template_ref(resource.template), + include_template=True, + include_properties=False, + include_children=False, + full=False, + on_unloaded=on_unloaded, ) - self._resource_ref_cache[resource.id] = schema - return schema def _construct_property_schema( self, prop: Property, ) -> PropertySchema: - group_template = self._construct_attribute_group_template(prop.template) - values_model = self._build_param_values_model_from_template(group_template) - value_fields = { - at.slug: AttributeValueSchema.model_construct( - value=prop._values[at.name].value if at.name in prop._values else None, - unit=prop._values[at.name].unit if at.name in prop._values else at.unit, - ) - for at in group_template.attribute_templates - } - return self._construct_with_simple_fields( - PropertySchema, - prop, - template=group_template, - values=values_model.model_construct(**value_fields), - ) + return self._context.construct_property_schema(prop) def _construct_resource_schema( self, @@ -235,64 +291,95 @@ def _construct_resource_schema( on_unloaded: Literal["silent", "warn", "raise"], children_map: dict[Any, list[Resource]] | None = None, ) -> ResourceSchema: - cached = self._resource_cache.get(resource.id) - if cached is not None: - return cached - schema = self._construct_with_simple_fields( - ResourceSchema, - resource, - template=( - self._construct_resource_template(resource.template) - if full or include_template - else self._construct_resource_template_minimal(resource.template) - ), - parent=None, - children={}, - properties={}, - ) - schema.set_loaded_relations( - { - "properties": include_properties, - "children": include_children, - }, - on_unloaded=on_unloaded, - ) - self._resource_cache[resource.id] = schema - if resource.parent is not None: - schema.parent = self._construct_resource_ref(resource.parent) - # When a pre-assembled ``children_map`` is supplied (flat-list - # hydration), build children from it instead of walking - # ``resource.children`` — the latter re-triggers a lazy load per node - # (the N+1 this path fixes). - if include_children: - child_resources = ( - children_map.get(resource.id, []) - if children_map is not None - else resource.children.values() + schema = self._resource_cache.get(resource.id) + if schema is None: + schema = self._construct_with_simple_fields( + ResourceSchema, + resource, + template=self._construct_resource_template_minimal( + resource.template, + on_unloaded=on_unloaded, + ), + parent=None, + children={}, + properties={}, ) - schema.children = { - child.name: self._construct_resource_schema( - child, - include_template=include_template, - include_properties=include_properties, - include_children=include_children, - full=full, + schema.set_loaded_relations( + { + "template": False, + "parent": False, + "children": False, + "properties": False, + }, + on_unloaded=on_unloaded, + ) + self._resource_cache[resource.id] = schema + + if self._context.in_progress("resource", resource.id): + return schema + + requested = { + "template": full or include_template, + "parent": full, + "children": include_children, + "properties": include_properties, + } + self._context.begin("resource", resource.id) + loaded_now: dict[str, bool] = {} + try: + if requested["template"] and not schema.is_loaded("template"): + schema.template = self._construct_resource_template( + resource.template, on_unloaded=on_unloaded, - children_map=children_map, ) - for child in child_resources - } - else: - schema.children = {} - schema.properties = ( - { - prop.template.name: self._construct_property_schema(prop) - for prop in resource.properties.values() - } - if include_properties - else {} - ) - return schema + loaded_now["template"] = True + + if requested["parent"] and not schema.is_loaded("parent"): + schema.parent = ( + self._construct_resource_ref( + resource.parent, + on_unloaded=on_unloaded, + ) + if resource.parent is not None + else None + ) + loaded_now["parent"] = True + + if requested["children"] and not schema.is_loaded("children"): + child_resources = ( + children_map.get(resource.id, []) + if children_map is not None + else resource.children.values() + ) + schema.children = { + child.name: self._construct_resource_schema( + child, + include_template=include_template, + include_properties=include_properties, + include_children=include_children, + full=full, + on_unloaded=on_unloaded, + children_map=children_map, + ) + for child in child_resources + } + loaded_now["children"] = True + + if requested["properties"] and not schema.is_loaded("properties"): + schema.properties = { + prop.template.name: self._construct_property_schema(prop) + for prop in resource.properties.values() + } + loaded_now["properties"] = True + + self._context.merge_load_state( + schema, + loaded_now, + on_unloaded=on_unloaded, + ) + return schema + finally: + self._context.finish("resource", resource.id) def _post_build_dynamic_models( self, diff --git a/recap/adapter/rest.py b/recap/adapter/rest.py new file mode 100644 index 0000000..4d247a7 --- /dev/null +++ b/recap/adapter/rest.py @@ -0,0 +1,259 @@ +"""Authenticated REST command adapter.""" + +from __future__ import annotations + +from dataclasses import dataclass +from secrets import token_urlsafe +from typing import Any +from urllib.parse import quote +from uuid import UUID + +from pydantic import SecretStr, ValidationError + +from recap.adapter.http_transport import HTTPResult, HTTPTransport +from recap.adapter.transport import QueryRequest, QueryResult, hydrate_result +from recap.client.permissions import ActorPermissions +from recap.commands.models import ( + CommandContext, + CommandModel, + CreateNamespace, + UpdateNamespace, +) +from recap.commands.registry import COMMAND_REGISTRY +from recap.dsl.query import QuerySpec, SchemaT +from recap.exceptions import RecapProtocolError +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext + + +@dataclass(frozen=True, slots=True) +class RESTResult: + entity: Any + etag: str | None + request_id: str | None + + +class RESTAdapter: + """Authenticated HTTP adapter for Recap command endpoints.""" + + def __init__( + self, + base_url: str, + api_key: str | SecretStr | None = None, + *, + timeout: float = 30.0, + _transport: HTTPTransport | None = None, + ) -> None: + if _transport is None and api_key is None: + raise TypeError("api_key is required when transport is not provided") + self._base_url = base_url.rstrip("/") + self._transport = ( + _transport if _transport is not None else HTTPTransport(api_key, timeout=timeout) + ) + + def __repr__(self) -> str: + return f"RESTAdapter(base_url={self._base_url!r}, transport={self._transport!r})" + + def close(self) -> None: + self._transport.close() + + def __enter__(self) -> RESTAdapter: + return self + + def __exit__(self, *args: object) -> None: + self.close() + + def _request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + etag: str | None = None, + idempotency_key: str | None = None, + idempotent: bool = True, + params: dict[str, str] | None = None, + ) -> RESTResult: + headers: dict[str, str] = {} + if etag is not None: + headers["If-Match"] = etag + if idempotent and (idempotency_key is not None or method in {"PUT", "POST", "PATCH"}): + headers["Idempotency-Key"] = idempotency_key or token_urlsafe(18) + url = f"{self._base_url}{path}" + request_kwargs = {"json": body, "headers": headers} + if params is not None: + request_kwargs["params"] = params + response: HTTPResult = self._transport.request(method, url, **request_kwargs) + return RESTResult( + response.body, + response.etag, + response.request_id, + ) + + def create_namespace( + self, + path: str, + metadata: dict[str, Any] | None, + context: CommandContext, + ) -> NamespaceContext: + return self.execute(CreateNamespace(path=path, metadata=metadata), context) + + def update_namespace( + self, + namespace_id: UUID, + expected_revision: int, + metadata: dict[str, Any] | None, + status: LifecycleStatus | None, + context: CommandContext, + *, + etag: str | None = None, + ) -> NamespaceContext: + command = UpdateNamespace( + namespace_id=namespace_id, + expected_revision=expected_revision, + metadata=metadata, + status=status, + ) + if etag is None: + return self.execute(command, context) + return self.execute(command, context, etag_override=etag) + + def list_child_namespaces(self, parent_path: str) -> list[str]: + path = parent_path.strip("/") + route = "/api/v1/namespaces/children" + if path: + route += f"/{path}" + result = self._request("GET", route) + return list(result.entity or []) + + def query( + self, schema: type[SchemaT], spec: QuerySpec, *, namespace_path: str + ) -> list[SchemaT]: + request = QueryRequest.from_query(schema, spec, namespace_path=namespace_path) + response = self._request( + "POST", "/api/v1/query", body=request.model_dump(mode="json"), idempotent=False + ) + try: + result = QueryResult.model_validate(response.entity) + return hydrate_result(schema, result) + except RecapProtocolError: + raise + except (KeyError, TypeError, ValueError, ValidationError): + raise RecapProtocolError( + "Malformed REST query response", url=f"{self._base_url}/api/v1/query", + request_id=response.request_id, + ) from None + + def count( + self, schema: type[SchemaT], spec: QuerySpec, *, namespace_path: str + ) -> int: + request = QueryRequest.from_query(schema, spec, namespace_path=namespace_path) + response = self._request( + "POST", "/api/v1/query/count", body=request.model_dump(mode="json"), idempotent=False + ) + if not isinstance(response.entity, int) or isinstance(response.entity, bool): + raise RecapProtocolError( + "Malformed REST count response", url=f"{self._base_url}/api/v1/query/count", + request_id=response.request_id, + ) + return response.entity + + def permissions(self, namespace_path: str) -> ActorPermissions: + response = self._request( + "GET", "/api/v1/permissions", params={"namespace_path": namespace_path}, + idempotent=False, + ) + try: + return ActorPermissions.model_validate(response.entity) + except (TypeError, ValueError, ValidationError): + raise RecapProtocolError( + "Malformed REST permissions response", url=f"{self._base_url}/api/v1/permissions", + request_id=response.request_id, + ) from None + + def get_namespace_context(self, path: str) -> NamespaceContext: + encoded = "/".join(quote(segment, safe="") for segment in path.strip("/").split("/")) + route = f"/api/v1/namespaces/context/{encoded}" + response = self._request("GET", route, idempotent=False) + try: + context = NamespaceContext.model_validate(response.entity) + if response.etag is not None: + context = context.model_copy(update={"etag": response.etag}) + return context + except (TypeError, ValueError, ValidationError): + raise RecapProtocolError( + "Malformed REST namespace context response", url=f"{self._base_url}{route}", + request_id=response.request_id, + ) from None + + def create( + self, + resource: str, + namespace_path: str, + body: dict[str, Any], + *, + idempotency_key=None, + ): + route = f"/api/v1/{resource}/{namespace_path.strip('/')}" + return self._request( + "POST", + route, + body=body, + idempotency_key=idempotency_key, + ) + + def update( + self, + resource: str, + entity_id: UUID, + body: dict[str, Any], + *, + etag: str, + idempotency_key=None, + ): + return self._request( + "PATCH", + f"/api/v1/{resource}/{entity_id}", + body=body, + etag=etag, + idempotency_key=idempotency_key, + ) + + def copy_resource( + self, + source_resource_id: UUID, + destination_namespace_path: str, + *, + changes=None, + idempotency_key=None, + ): + return self._request( + "POST", + f"/api/v1/resources/{source_resource_id}/copies", + body={ + "destination_namespace": destination_namespace_path.strip("/"), + **(changes or {}), + }, + idempotency_key=idempotency_key, + ) + + def _execute_registered( + self, command: CommandModel, context, *, etag_override: str | None = None + ) -> RESTResult: + registration = COMMAND_REGISTRY.by_command(command) + encoded = registration.encode_request(command) + return self._request( + encoded.method, + encoded.path, + body=encoded.body, + etag=encoded.etag if etag_override is None else etag_override, + idempotency_key=getattr(context, "idempotency_key", None), + ) + + def execute( + self, command: CommandModel, context, *, etag_override: str | None = None + ) -> Any: + """Submit command DTO using its canonical REST route.""" + registration = COMMAND_REGISTRY.by_command(command) + result = self._execute_registered(command, context, etag_override=etag_override) + return registration.decode_response(result.entity, result.etag, command=command) diff --git a/recap/adapter/schema_registry.py b/recap/adapter/schema_registry.py new file mode 100644 index 0000000..6c8fe7b --- /dev/null +++ b/recap/adapter/schema_registry.py @@ -0,0 +1,166 @@ + +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel + +from recap.adapter.process_run_construct import ProcessRunSchemaHydrator +from recap.adapter.resource_construct import ResourceSchemaHydrator +from recap.db.base import Base +from recap.db.namespace import Namespace +from recap.db.process import ProcessRun, ProcessTemplate +from recap.db.resource import Resource, ResourceTemplate +from recap.schemas.attribute import AttributeGroupTemplateSchema +from recap.schemas.namespace import NamespaceSchema +from recap.schemas.process import ( + ProcessRunSchema, + ProcessTemplateSchema, +) +from recap.schemas.resource import ( + ResourceSchema, + ResourceTemplateSchema, +) + +SCHEMA_QUERY_KEYS: dict[type[BaseModel], tuple[str, str]] = { + NamespaceSchema: ("namespace", "full"), + ResourceTemplateSchema: ("resource_template", "full"), + ResourceSchema: ("resource", "full"), + ProcessTemplateSchema: ("process_template", "full"), + ProcessRunSchema: ("process_run", "full"), +} + +QUERY_SCHEMA_KEYS: dict[tuple[str, str], type[BaseModel]] = { + (entity, projection): schema + for schema, (entity, projection) in SCHEMA_QUERY_KEYS.items() +} +for entity, schema in ( + ("namespace", NamespaceSchema), + ("resource_template", ResourceTemplateSchema), + ("resource", ResourceSchema), + ("process_template", ProcessTemplateSchema), + ("process_run", ProcessRunSchema), +): + QUERY_SCHEMA_KEYS[(entity, "ref")] = schema + +QUERY_ENTITY_KEYS = {schema: entity for schema, (entity, _) in SCHEMA_QUERY_KEYS.items()} + +QUERY_PROJECTION_SCHEMAS: dict[str, dict[str, type[BaseModel]]] = { + "namespace": {"full": NamespaceSchema, "ref": NamespaceSchema}, + "resource_template": {"full": ResourceTemplateSchema, "ref": ResourceTemplateSchema}, + "resource": {"full": ResourceSchema, "ref": ResourceSchema}, + "process_template": {"full": ProcessTemplateSchema, "ref": ProcessTemplateSchema}, + "process_run": {"full": ProcessRunSchema, "ref": ProcessRunSchema}, +} + +SCHEMA_ENTITY_KEYS = QUERY_ENTITY_KEYS +SCHEMA_PROJECTIONS = {schema: projection for schema, (_, projection) in SCHEMA_QUERY_KEYS.items()} +SCHEMAS_BY_NAME = {schema.__name__: schema for schema in SCHEMA_QUERY_KEYS} +LEGACY_SCHEMAS_BY_NAME = { + AttributeGroupTemplateSchema.__name__: AttributeGroupTemplateSchema, + "AttributeGroupTemplate": AttributeGroupTemplateSchema, + "AttributeGroupTemplateRef": AttributeGroupTemplateSchema, + "NamespaceRef": NamespaceSchema, + "ResourceTemplateRef": ResourceTemplateSchema, + "ResourceRef": ResourceSchema, + "ProcessTemplateRef": ProcessTemplateSchema, + "ProcessRunRef": ProcessRunSchema, +} +LEGACY_SCHEMA_KEYS = { + name: (SCHEMA_ENTITY_KEYS[schema], "ref" if name.endswith("Ref") else "full") + for name, schema in LEGACY_SCHEMAS_BY_NAME.items() + if schema in SCHEMA_ENTITY_KEYS +} + + +def _validate_model(model: type[BaseModel], value: Any) -> BaseModel: + return model.model_validate(value) + + +@dataclass(frozen=True) +class SchemaRegistration: + key: str + model: type[BaseModel] + orm_model: type[Base] + hydrator: Callable[..., Any] + loader_capabilities: tuple[str, ...] + projections: tuple[str, ...] = ("full", "ref") + + +class SchemaRegistry: + def __init__(self, registrations: Iterable[SchemaRegistration]): + self._by_key: dict[str, SchemaRegistration] = {} + self._by_model: dict[type[BaseModel], SchemaRegistration] = {} + for registration in registrations: + if registration.key in self._by_key: + raise ValueError(f"duplicate schema key: {registration.key}") + if registration.model in self._by_model: + raise ValueError( + f"duplicate schema model: {registration.model.__name__}" + ) + self._by_key[registration.key] = registration + self._by_model[registration.model] = registration + + def keys(self): + return self._by_key.keys() + + def by_key(self, key: str) -> SchemaRegistration: + try: + return self._by_key[key] + except KeyError as exc: + raise KeyError(f"unknown query schema key: {key}") from exc + + def by_model(self, model: type[BaseModel]) -> SchemaRegistration: + try: + return self._by_model[model] + except KeyError as exc: + raise KeyError(f"unknown query schema model: {model.__name__}") from exc + + def validate_complete(self) -> None: + from recap.adapter.query_loaders import PRELOAD_STATEMENTS + + for registration in self._by_key.values(): + declared = set(registration.loader_capabilities) + actual = { + relation + for (schema, relation), statements in PRELOAD_STATEMENTS.items() + if schema is registration.model and statements + } + if actual != declared: + raise ValueError( + f"incomplete loader capabilities for {registration.key}: " + f"declared {sorted(declared)}, actual {sorted(actual)}" + ) + + +SCHEMA_REGISTRY = SchemaRegistry( + ( + SchemaRegistration("namespace", NamespaceSchema, Namespace, _validate_model, ()), + SchemaRegistration( + "resource_template", ResourceTemplateSchema, ResourceTemplate, + _validate_model, ("types", "children", "attribute_group_templates"), + ), + SchemaRegistration( + "resource", ResourceSchema, Resource, ResourceSchemaHydrator, + ("template", "properties", "children"), + ), + SchemaRegistration( + "process_template", ProcessTemplateSchema, ProcessTemplate, + _validate_model, ("step_templates", "resource_slots"), + ), + SchemaRegistration( + "process_run", ProcessRunSchema, ProcessRun, ProcessRunSchemaHydrator, + ("template", "steps", "steps.parameters", "resources"), + ), + ) +) + + +def schema_for(entity: str, projection: str) -> type[BaseModel]: + try: + registration = SCHEMA_REGISTRY.by_key(entity) + if projection not in registration.projections: + raise KeyError(projection) + return registration.model + except KeyError as exc: + raise ValueError(f"Unknown query entity/projection: {entity}/{projection}") from exc diff --git a/recap/adapter/transport.py b/recap/adapter/transport.py index c4c92a7..678ab29 100644 --- a/recap/adapter/transport.py +++ b/recap/adapter/transport.py @@ -4,49 +4,152 @@ from copy import deepcopy from datetime import date, datetime from enum import Enum -from typing import Any, get_args +from typing import Any, Literal, get_args from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field, model_validator +from recap.adapter.schema_registry import ( + LEGACY_SCHEMA_KEYS, + LEGACY_SCHEMAS_BY_NAME, + SCHEMA_ENTITY_KEYS, + SCHEMA_PROJECTIONS, + SCHEMAS_BY_NAME, + schema_for, +) from recap.dsl.query import FieldOrdering, FieldPredicate, QuerySpec +from recap.exceptions import RecapProtocolError from recap.schemas.attribute import AttributeGroupTemplateSchema -from recap.schemas.process import ProcessRunSchema -from recap.schemas.resource import ResourceSchema +from recap.schemas.common import LoadAware _SCALAR_TAG = "__recap_transport_scalar_v1__" class QueryRequest(BaseModel): - schema_name: str + model_config = ConfigDict(extra="forbid") + + entity: str + projection: Literal["full", "ref"] + namespace_path: str spec: dict[str, Any] + schema_name: str | None = Field(default=None, exclude=True) + + @model_validator(mode="before") + @classmethod + def normalize_legacy_schema_name(cls, value: Any) -> Any: + if isinstance(value, Mapping) and "entity" not in value and "schema_name" in value: + schema_name = value["schema_name"] + schema = SCHEMAS_BY_NAME.get(schema_name) or LEGACY_SCHEMAS_BY_NAME.get(schema_name) + if schema is not None: + value = dict(value) + if schema in SCHEMA_ENTITY_KEYS: + value["entity"] = SCHEMA_ENTITY_KEYS[schema] + value["projection"] = LEGACY_SCHEMA_KEYS.get( + schema_name, (None, SCHEMA_PROJECTIONS[schema]) + )[1] + else: + value["entity"] = schema_name + value["projection"] = "full" + return value @classmethod - def from_query(cls, schema: type[BaseModel], spec: QuerySpec) -> QueryRequest: + def from_query( + cls, schema: type[BaseModel], spec: QuerySpec, *, namespace_path: str + ) -> QueryRequest: if not all(isinstance(item, FieldPredicate) for item in spec.predicates): raise TypeError("Remote query predicates must use Field(...)") if not all(isinstance(item, FieldOrdering) for item in spec.orderings): raise TypeError("Remote query orderings must use Field(...)") - return cls(schema_name=schema.__name__, spec=spec.model_dump(mode="json")) + serialized_spec = spec.model_dump(mode="json") + if not spec.include_mutable: + serialized_spec.pop("include_mutable", None) + return cls( + entity=SCHEMA_ENTITY_KEYS[schema], + projection=SCHEMA_PROJECTIONS[schema], + namespace_path=namespace_path, + spec=serialized_spec, + schema_name=schema.__name__, + ) + + @property + def wire_schema(self) -> type[BaseModel]: + return schema_for(self.entity, self.projection) + + @model_validator(mode="after") + def validate_entity(self) -> QueryRequest: + schema_for(self.entity, self.projection) + return self class QueryResult(BaseModel): - schema_name: str + model_config = ConfigDict(extra="forbid") + + entity: str + projection: Literal["full", "ref"] items: list[dict[str, Any]] + schema_name: str | None = Field(default=None, exclude=True) + @model_validator(mode="before") + @classmethod + def normalize_legacy_schema_name(cls, value: Any) -> Any: + if isinstance(value, Mapping) and "entity" not in value and "schema_name" in value: + schema_name = value["schema_name"] + schema = SCHEMAS_BY_NAME.get(schema_name) or LEGACY_SCHEMAS_BY_NAME.get(schema_name) + if schema is not None: + value = dict(value) + if schema in SCHEMA_ENTITY_KEYS: + value["entity"] = SCHEMA_ENTITY_KEYS[schema] + value["projection"] = LEGACY_SCHEMA_KEYS.get( + schema_name, (None, SCHEMA_PROJECTIONS[schema]) + )[1] + else: + value["entity"] = schema_name + value["projection"] = "full" + elif value["schema_name"] in LEGACY_SCHEMA_KEYS: + value = dict(value) + value["entity"], value["projection"] = LEGACY_SCHEMA_KEYS[value["schema_name"]] + elif value["schema_name"] in LEGACY_SCHEMAS_BY_NAME: + value = dict(value) + value["entity"] = value["schema_name"] + value["projection"] = "full" + return value + + @model_validator(mode="after") + def validate_entity(self) -> QueryResult: + try: + schema_for(self.entity, self.projection) + except ValueError: + if self.schema_name in LEGACY_SCHEMAS_BY_NAME and self.entity == self.schema_name and self.projection == "full": + return self + raise + return self -def serialize_model(model: BaseModel) -> dict[str, Any]: + +def serialize_model( + model: BaseModel, *, _seen: set[int] | None = None +) -> dict[str, Any]: + if _seen is None: + _seen = set() + model_key = id(model) + repeated = model_key in _seen + if not repeated: + _seen.add(model_key) payload: dict[str, Any] = {} values = model.__dict__ - for name, field in type(model).model_fields.items(): - if name not in values: - continue - key = field.serialization_alias or field.alias or name - payload[key] = _serialize_value( - values[name], tag_scalars=_contains_any(field.annotation) - ) + try: + relation_fields = getattr(type(model), "_relation_fields", frozenset()) + for name, field in type(model).model_fields.items(): + if name not in values or (repeated and name in relation_fields): + continue + key = field.serialization_alias or field.alias or name + payload[key] = _serialize_value( + values[name], tag_scalars=_contains_any(field.annotation), _seen=_seen + ) + finally: + if not repeated: + _seen.remove(model_key) - if isinstance(model, ResourceSchema | ProcessRunSchema): + if isinstance(model, LoadAware): private = model.__pydantic_private__ or {} payload["__recap__"] = { "loaded_relations": dict(private.get("_loaded_relations", {})), @@ -59,16 +162,18 @@ def _contains_any(annotation: Any) -> bool: return annotation is Any or any(_contains_any(arg) for arg in get_args(annotation)) -def _serialize_value(value: Any, *, tag_scalars: bool = False) -> Any: +def _serialize_value( + value: Any, *, tag_scalars: bool = False, _seen: set[int] | None = None +) -> Any: if isinstance(value, BaseModel): - return serialize_model(value) + return serialize_model(value, _seen=_seen) if isinstance(value, Mapping): return { - str(key): _serialize_value(item, tag_scalars=tag_scalars) + str(key): _serialize_value(item, tag_scalars=tag_scalars, _seen=_seen) for key, item in value.items() } if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): - return [_serialize_value(item, tag_scalars=tag_scalars) for item in value] + return [_serialize_value(item, tag_scalars=tag_scalars, _seen=_seen) for item in value] if isinstance(value, UUID): return str(value) if isinstance(value, datetime): @@ -83,6 +188,17 @@ def _serialize_value(value: Any, *, tag_scalars: bool = False) -> Any: def hydrate_result(schema: type[BaseModel], result: QueryResult) -> list[BaseModel]: + try: + result_schema = schema_for(result.entity, result.projection) + except ValueError: + result_schema = None + legacy_only_schema = ( + result.schema_name == schema.__name__ + and result.entity == result.schema_name + and result.projection == "full" + ) + if result_schema is not schema and not legacy_only_schema: + raise RecapProtocolError("Query result schema does not match requested schema") hydrated: list[BaseModel] = [] for item in result.items: payload = deepcopy(item) @@ -144,13 +260,13 @@ def _restore_metadata(value: Any, payload: Any) -> None: def _restore_model_metadata(value: BaseModel, payload: Mapping[str, Any]) -> None: metadata = payload.get("__recap__") - if isinstance(value, ResourceSchema | ProcessRunSchema) and isinstance( + if isinstance(value, LoadAware) and isinstance( metadata, Mapping ): - value.set_loaded_relations( - dict(metadata.get("loaded_relations", {})), - on_unloaded=metadata.get("on_unloaded", "warn"), - ) + loaded_relations = dict(metadata.get("loaded_relations", {})) + on_unloaded = metadata.get("on_unloaded", "warn") + if loaded_relations or on_unloaded != "warn": + value.set_loaded_relations(loaded_relations, on_unloaded=on_unloaded) values = value.__dict__ for name, field in type(value).model_fields.items(): diff --git a/recap/authentication/__init__.py b/recap/authentication/__init__.py new file mode 100644 index 0000000..d51acb9 --- /dev/null +++ b/recap/authentication/__init__.py @@ -0,0 +1,19 @@ +from .errors import AuthenticationError, InvalidCredentialsError +from .models import ActorKind, IdentityEvidence, ProviderIdentity, RequestActor +from .protocols import ( + ExternalAuthenticator, + InternalAuthenticator, + RequestAuthenticator, +) + +__all__ = [ + "ActorKind", + "AuthenticationError", + "ExternalAuthenticator", + "IdentityEvidence", + "InternalAuthenticator", + "InvalidCredentialsError", + "ProviderIdentity", + "RequestActor", + "RequestAuthenticator", +] diff --git a/recap/authentication/actors.py b/recap/authentication/actors.py new file mode 100644 index 0000000..789edcc --- /dev/null +++ b/recap/authentication/actors.py @@ -0,0 +1,20 @@ +from recap.authentication.models import ActorKind, ProviderIdentity, RequestActor +from recap.authorization.scopes import Scope + + +def single_user_actor( + *, credential_fingerprint: str, provider: str = "single-user" +) -> RequestActor: + return RequestActor( + actor_id="single-user", + kind=ActorKind.USER, + identities=( + ProviderIdentity(provider=provider, subject="single-user"), + ), + credential_scopes=frozenset(Scope), + namespace_restrictions=None, + credential_fingerprint=credential_fingerprint, + ) + + +__all__ = ["single_user_actor"] diff --git a/recap/authentication/api_key.py b/recap/authentication/api_key.py new file mode 100644 index 0000000..77300b2 --- /dev/null +++ b/recap/authentication/api_key.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import hashlib +import secrets + +from pydantic import SecretStr + +from recap.authentication.actors import single_user_actor +from recap.authentication.errors import InvalidCredentialsError +from recap.authentication.models import RequestActor + + +class ApiKeyRequestAuthenticator: + """Authenticate one configured API key without exposing it.""" + + def __init__(self, api_key: str | SecretStr) -> None: + self._api_key = ( + api_key if isinstance(api_key, SecretStr) else SecretStr(api_key) + ) + secret = self._api_key.get_secret_value() + self._actor = single_user_actor( + credential_fingerprint=hashlib.sha256(secret.encode()).hexdigest(), + provider="api-key", + ) + + async def authenticate(self, credential: object) -> RequestActor: + if not isinstance(credential, str) or not secrets.compare_digest( + credential, self._api_key.get_secret_value() + ): + raise InvalidCredentialsError("Invalid API key") + return self._actor + + def __repr__(self) -> str: + return f"{type(self).__name__}(api_key=SecretStr('**********'))" diff --git a/recap/authentication/errors.py b/recap/authentication/errors.py new file mode 100644 index 0000000..15e04a4 --- /dev/null +++ b/recap/authentication/errors.py @@ -0,0 +1,6 @@ +class AuthenticationError(Exception): + """Base error for failed authentication.""" + + +class InvalidCredentialsError(AuthenticationError): + """Raised when supplied credentials cannot be authenticated.""" diff --git a/recap/authentication/models.py b/recap/authentication/models.py new file mode 100644 index 0000000..4a0dc87 --- /dev/null +++ b/recap/authentication/models.py @@ -0,0 +1,34 @@ +from enum import Enum + +from pydantic import BaseModel, ConfigDict + +from recap.authorization.scopes import Scope + + +class ActorKind(str, Enum): + USER = "user" + SERVICE = "service" + + +class ProviderIdentity(BaseModel): + model_config = ConfigDict(frozen=True) + + provider: str + subject: str + + +class IdentityEvidence(BaseModel): + model_config = ConfigDict(frozen=True) + + identity: ProviderIdentity + + +class RequestActor(BaseModel): + model_config = ConfigDict(frozen=True) + + actor_id: str + kind: ActorKind + identities: tuple[ProviderIdentity, ...] + credential_scopes: frozenset[Scope] + namespace_restrictions: frozenset[str] | None + credential_fingerprint: str diff --git a/recap/authentication/protocols.py b/recap/authentication/protocols.py new file mode 100644 index 0000000..5241368 --- /dev/null +++ b/recap/authentication/protocols.py @@ -0,0 +1,15 @@ +from typing import Protocol + +from .models import IdentityEvidence, RequestActor + + +class InternalAuthenticator(Protocol): + async def authenticate(self, credential: object) -> IdentityEvidence: ... + + +class ExternalAuthenticator(Protocol): + async def authenticate(self, credential: object) -> IdentityEvidence: ... + + +class RequestAuthenticator(Protocol): + async def authenticate(self, credential: object) -> RequestActor: ... diff --git a/recap/authorization/__init__.py b/recap/authorization/__init__.py new file mode 100644 index 0000000..9d87738 --- /dev/null +++ b/recap/authorization/__init__.py @@ -0,0 +1,3 @@ +from .scopes import DEFAULT_SCOPE_REGISTRY, Scope, ScopeRegistry + +__all__ = ["DEFAULT_SCOPE_REGISTRY", "Scope", "ScopeRegistry"] diff --git a/recap/authorization/compiler.py b/recap/authorization/compiler.py new file mode 100644 index 0000000..5e68b6c --- /dev/null +++ b/recap/authorization/compiler.py @@ -0,0 +1,174 @@ +import argparse +import contextlib +import os +import sqlite3 +import tempfile +from pathlib import Path + +from recap.authorization.source import ( + AuthorizationSourceConfig, + load_authorization_source, +) + +_SCHEMA = """ +PRAGMA foreign_keys = ON; + +CREATE TABLE snapshot_metadata ( + format_version INTEGER NOT NULL CHECK (format_version = 1), + source_revision TEXT NOT NULL +); + +CREATE TABLE identities ( + id INTEGER PRIMARY KEY, + provider TEXT NOT NULL, + subject TEXT NOT NULL, + UNIQUE (provider, subject) +); + +CREATE TABLE namespace_paths ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE +); + +CREATE TABLE scopes ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE grants ( + identity_id INTEGER NOT NULL REFERENCES identities(id), + namespace_path_id INTEGER NOT NULL REFERENCES namespace_paths(id), + scope_id INTEGER NOT NULL REFERENCES scopes(id), + group_name TEXT NOT NULL, + role_name TEXT NOT NULL, + UNIQUE (identity_id, namespace_path_id, scope_id, group_name, role_name) +); +""" + + +def _populate_snapshot( + connection: sqlite3.Connection, source: AuthorizationSourceConfig +) -> None: + connection.executescript(_SCHEMA) + connection.execute( + "INSERT INTO snapshot_metadata (format_version, source_revision) VALUES (1, ?)", + (source.source_revision,), + ) + + identities = sorted( + { + (identity.provider, identity.subject) + for group in source.groups.values() + for identity in group.identities + } + ) + paths = sorted(source.namespaces) + scopes = sorted( + { + scope.value + for namespace in source.namespaces.values() + for binding in namespace.groups + for scope in source.roles[binding.role].scopes + } + ) + connection.executemany( + "INSERT INTO identities (provider, subject) VALUES (?, ?)", identities + ) + connection.executemany( + "INSERT INTO namespace_paths (path) VALUES (?)", ((path,) for path in paths) + ) + connection.executemany( + "INSERT INTO scopes (name) VALUES (?)", ((scope,) for scope in scopes) + ) + + identity_ids = { + (provider, subject): identity_id + for identity_id, provider, subject in connection.execute( + "SELECT id, provider, subject FROM identities" + ) + } + path_ids = dict(connection.execute("SELECT path, id FROM namespace_paths")) + scope_ids = dict(connection.execute("SELECT name, id FROM scopes")) + + grants = [] + for path, namespace in source.namespaces.items(): + for binding in namespace.groups: + role = source.roles[binding.role] + for identity in source.groups[binding.name].identities: + for scope in role.scopes: + grants.append( + ( + identity_ids[(identity.provider, identity.subject)], + path_ids[path], + scope_ids[scope.value], + binding.name, + binding.role, + ) + ) + connection.executemany( + """ + INSERT INTO grants ( + identity_id, namespace_path_id, scope_id, group_name, role_name + ) VALUES (?, ?, ?, ?, ?) + """, + grants, + ) + + +def _fsync_file(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def compile_authorization_snapshot( + source_path: str | Path, snapshot_path: str | Path +) -> None: + source = load_authorization_source(source_path) + destination = Path(snapshot_path) + descriptor, temporary_name = tempfile.mkstemp( + dir=destination.parent, + prefix=f".{destination.name}.", + suffix=".tmp", + ) + os.close(descriptor) + temporary = Path(temporary_name) + + try: + with sqlite3.connect(temporary) as connection: + _populate_snapshot(connection, source) + connection.commit() + integrity = connection.execute("PRAGMA integrity_check").fetchall() + if integrity != [("ok",)]: + raise RuntimeError(f"SQLite integrity check failed: {integrity!r}") + _fsync_file(temporary) + os.replace(temporary, destination) + _fsync_directory(destination.parent) + finally: + for artifact in (temporary, Path(f"{temporary}-journal")): + with contextlib.suppress(FileNotFoundError): + artifact.unlink() + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Compile a YAML authorization source into a SQLite snapshot." + ) + parser.add_argument("source", type=Path) + parser.add_argument("snapshot", type=Path) + arguments = parser.parse_args() + compile_authorization_snapshot(arguments.source, arguments.snapshot) + + +if __name__ == "__main__": + main() diff --git a/recap/authorization/policy.py b/recap/authorization/policy.py new file mode 100644 index 0000000..1671ad2 --- /dev/null +++ b/recap/authorization/policy.py @@ -0,0 +1,263 @@ +from recap.authentication.models import RequestActor +from recap.authorization.scopes import Scope +from recap.authorization.snapshot import AuthorizationSnapshot, GrantProvenance +from recap.client.permissions import ( + DenialCode, + EffectivePermissions, + PermissionDecision, +) +from recap.utils.namespace import ( + canonicalize_namespace_path, + is_namespace_ancestor, + namespace_ancestors, + parent_namespace_path, +) + + +def _grant_key(grant: GrantProvenance) -> tuple[str, str, str, str, str, str]: + return ( + grant.namespace_path, + grant.scope.value, + grant.identity.provider, + grant.identity.subject, + grant.group, + grant.role, + ) + + +def _denied(code: DenialCode) -> PermissionDecision: + return PermissionDecision(allowed=False, denial_code=code) + + +class SnapshotNamespacePolicy: + def __init__(self, snapshot: AuthorizationSnapshot) -> None: + self._snapshot = snapshot + + def permissions_for( + self, actor: RequestActor, namespace_path: str + ) -> EffectivePermissions: + path = canonicalize_namespace_path(namespace_path) + restrictions = ( + None + if actor.namespace_restrictions is None + else tuple( + canonicalize_namespace_path(item) + for item in actor.namespace_restrictions + ) + ) + ancestors = namespace_ancestors(path) + + if restrictions is not None and not any( + is_namespace_ancestor(restriction, path) for restriction in restrictions + ): + matched_grants: tuple[GrantProvenance, ...] = () + else: + identities = set(actor.identities) + matched_grants = tuple( + sorted( + ( + grant + for grant in self._snapshot.grants + if grant.namespace_path in ancestors + and grant.identity in identities + and grant.scope in actor.credential_scopes + ), + key=_grant_key, + ) + ) + + matched_paths = tuple( + ancestor + for ancestor in ancestors + if any(grant.namespace_path == ancestor for grant in matched_grants) + ) + return EffectivePermissions( + identities=actor.identities, + snapshot_generation=self._snapshot.metadata.source_revision, + effective_scopes=frozenset(grant.scope for grant in matched_grants), + matched_namespace_paths=matched_paths, + grants=matched_grants, + ) + + def authorize( + self, actor: RequestActor, scope: Scope, namespace_path: str + ) -> PermissionDecision: + permissions = self.permissions_for(actor, namespace_path) + if scope not in permissions.effective_scopes: + return _denied(DenialCode.INSUFFICIENT_SCOPE) + return PermissionDecision(allowed=True, permissions=permissions) + + def can_discover_namespace( + self, actor: RequestActor, namespace_path: str + ) -> bool: + """Return whether actor may see namespace path during navigation.""" + path = canonicalize_namespace_path(namespace_path) + if Scope.NAMESPACE_READ in self.permissions_for(actor, path).effective_scopes: + return True + + restrictions = ( + None + if actor.namespace_restrictions is None + else tuple( + canonicalize_namespace_path(item) + for item in actor.namespace_restrictions + ) + ) + identities = set(actor.identities) + return any( + grant.scope is Scope.NAMESPACE_READ + and grant.identity in identities + and grant.scope in actor.credential_scopes + and ( + restrictions is None + or any( + is_namespace_ancestor(restriction, grant.namespace_path) + for restriction in restrictions + ) + ) + and is_namespace_ancestor(path, grant.namespace_path) + for grant in self._snapshot.grants + ) + + def authorize_process_run( + self, + actor: RequestActor, + scope: Scope, + *, + context_namespace_path: str, + target_namespace_path: str, + ) -> PermissionDecision: + context = canonicalize_namespace_path(context_namespace_path) + target = canonicalize_namespace_path(target_namespace_path) + decision = self.authorize(actor, scope, context) + if not decision.allowed: + return decision + if target != context: + return _denied(DenialCode.INVALID_TARGET_RELATIONSHIP) + return decision + + def authorize_template( + self, + actor: RequestActor, + scope: Scope, + *, + context_namespace_path: str, + target_namespace_path: str, + ) -> PermissionDecision: + return self._authorize_ancestor_visible( + actor, + scope, + context_namespace_path=context_namespace_path, + target_namespace_path=target_namespace_path, + ) + + def authorize_resource( + self, + actor: RequestActor, + scope: Scope, + *, + context_namespace_path: str, + target_namespace_path: str, + ) -> PermissionDecision: + return self._authorize_ancestor_visible( + actor, + scope, + context_namespace_path=context_namespace_path, + target_namespace_path=target_namespace_path, + ) + + def _authorize_ancestor_visible( + self, + actor: RequestActor, + scope: Scope, + *, + context_namespace_path: str, + target_namespace_path: str, + ) -> PermissionDecision: + context = canonicalize_namespace_path(context_namespace_path) + target = canonicalize_namespace_path(target_namespace_path) + decision = self.authorize(actor, scope, context) + if not decision.allowed: + return decision + is_read = scope.value.endswith(":read") + valid_target = ( + is_namespace_ancestor(target, context) if is_read else target == context + ) + if not valid_target: + return _denied(DenialCode.INVALID_TARGET_RELATIONSHIP) + return decision + + def authorize_namespace_create( + self, actor: RequestActor, namespace_path: str + ) -> PermissionDecision: + path = canonicalize_namespace_path(namespace_path) + return self.authorize(actor, Scope.NAMESPACE_WRITE, parent_namespace_path(path)) + + def authorize_copy( + self, + actor: RequestActor, + *, + source_namespace_path: str, + destination_namespace_path: str, + ) -> PermissionDecision: + source = canonicalize_namespace_path(source_namespace_path) + destination = canonicalize_namespace_path(destination_namespace_path) + source_decision = self.authorize(actor, Scope.RESOURCE_READ, source) + if not source_decision.allowed: + return _denied(DenialCode.SOURCE_READ_REQUIRED) + destination_decision = self.authorize(actor, Scope.RESOURCE_WRITE, destination) + if not destination_decision.allowed: + return _denied(DenialCode.DESTINATION_WRITE_REQUIRED) + + source_permissions = source_decision.permissions + destination_permissions = destination_decision.permissions + assert source_permissions is not None + assert destination_permissions is not None + grants = tuple( + sorted( + set(source_permissions.grants) | set(destination_permissions.grants), + key=_grant_key, + ) + ) + permissions = EffectivePermissions( + identities=actor.identities, + snapshot_generation=source_permissions.snapshot_generation, + effective_scopes=( + source_permissions.effective_scopes + | destination_permissions.effective_scopes + ), + matched_namespace_paths=tuple( + dict.fromkeys( + source_permissions.matched_namespace_paths + + destination_permissions.matched_namespace_paths + ) + ), + grants=grants, + ) + return PermissionDecision(allowed=True, permissions=permissions) + + +class UnrestrictedNamespacePolicy(SnapshotNamespacePolicy): + def __init__(self) -> None: + pass + + def permissions_for( + self, actor: RequestActor, namespace_path: str + ) -> EffectivePermissions: + canonicalize_namespace_path(namespace_path) + return EffectivePermissions( + identities=actor.identities, + snapshot_generation=None, + effective_scopes=frozenset(Scope), + matched_namespace_paths=(), + grants=(), + ) + + +__all__ = [ + "DenialCode", + "EffectivePermissions", + "PermissionDecision", + "SnapshotNamespacePolicy", + "UnrestrictedNamespacePolicy", +] diff --git a/recap/authorization/query.py b/recap/authorization/query.py new file mode 100644 index 0000000..0a134ec --- /dev/null +++ b/recap/authorization/query.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol + +from sqlalchemy import false +from sqlalchemy.sql import and_, or_ + +from recap.authentication.models import RequestActor +from recap.authorization.scopes import Scope +from recap.db.namespace import Namespace +from recap.db.process import ProcessRun, ProcessTemplate +from recap.db.resource import Resource, ResourceTemplate +from recap.lifecycle import LifecycleStatus + +if TYPE_CHECKING: + from recap.client.permissions import EffectivePermissions + + +class NamespacePolicy(Protocol): + def permissions_for( + self, actor: RequestActor, namespace_path: str + ) -> EffectivePermissions: ... + + +_MODEL_SCOPES = { + Namespace: (Scope.NAMESPACE_READ, Scope.NAMESPACE_WRITE), + ProcessRun: (Scope.PROCESS_RUN_READ, Scope.PROCESS_RUN_WRITE), + ProcessTemplate: ( + Scope.PROCESS_TEMPLATE_READ, + Scope.PROCESS_TEMPLATE_WRITE, + ), + ResourceTemplate: ( + Scope.RESOURCE_TEMPLATE_READ, + Scope.RESOURCE_TEMPLATE_WRITE, + ), + Resource: (Scope.RESOURCE_READ, Scope.RESOURCE_WRITE), +} + + +@dataclass(frozen=True, slots=True) +class AuthorizedQuery: + namespace_path: str + permissions: EffectivePermissions + + @classmethod + def from_policy( + cls, + policy: NamespacePolicy, + actor: RequestActor, + *, + namespace_path: str, + ) -> AuthorizedQuery: + return cls( + namespace_path=namespace_path, + permissions=policy.permissions_for(actor, namespace_path), + ) + + def apply(self, model, stmt, *, context_id, ancestor_ids): + """Add actor visibility before caller-controlled query operations.""" + read_scope, write_scope = _MODEL_SCOPES[model] + scopes = self.permissions.effective_scopes + namespace_column = model.id if model is Namespace else model.namespace_id + active_ids = [context_id] if model is ProcessRun else ancestor_ids + predicates = [] + if read_scope in scopes: + predicates.append( + and_( + namespace_column.in_(active_ids), + model.status == LifecycleStatus.ACTIVE, + ) + ) + if write_scope in scopes: + predicates.append( + and_( + namespace_column == context_id, + model.status == LifecycleStatus.MUTABLE, + ) + ) + return stmt.where(or_(*predicates) if predicates else false()) + + def for_read(self) -> AuthorizedQuery: + """Return authorization limited to read scopes for read endpoints.""" + read_scopes = {read for read, _ in _MODEL_SCOPES.values()} + return AuthorizedQuery( + namespace_path=self.namespace_path, + permissions=self.permissions.model_copy( + update={ + "effective_scopes": self.permissions.effective_scopes + & read_scopes + } + ), + ) + + +__all__ = ["AuthorizedQuery", "NamespacePolicy"] diff --git a/recap/authorization/scopes.py b/recap/authorization/scopes.py new file mode 100644 index 0000000..806832b --- /dev/null +++ b/recap/authorization/scopes.py @@ -0,0 +1,28 @@ +from enum import Enum + +from pydantic import BaseModel, ConfigDict + + +class Scope(str, Enum): + NAMESPACE_READ = "namespace:read" + NAMESPACE_WRITE = "namespace:write" + PROCESS_TEMPLATE_READ = "process-template:read" + PROCESS_TEMPLATE_WRITE = "process-template:write" + RESOURCE_TEMPLATE_READ = "resource-template:read" + RESOURCE_TEMPLATE_WRITE = "resource-template:write" + RESOURCE_READ = "resource:read" + RESOURCE_WRITE = "resource:write" + PROCESS_RUN_READ = "process-run:read" + PROCESS_RUN_WRITE = "process-run:write" + + +class ScopeRegistry(BaseModel): + model_config = ConfigDict(frozen=True) + + scopes: frozenset[Scope] + + def allows(self, granted: frozenset[Scope], required: Scope) -> bool: + return required in granted + + +DEFAULT_SCOPE_REGISTRY = ScopeRegistry(scopes=frozenset(Scope)) diff --git a/recap/authorization/snapshot.py b/recap/authorization/snapshot.py new file mode 100644 index 0000000..40d3a3b --- /dev/null +++ b/recap/authorization/snapshot.py @@ -0,0 +1,153 @@ +import sqlite3 +import threading +import time +from collections.abc import Callable +from datetime import datetime, timedelta +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, ValidationError + +from recap.authentication.models import ProviderIdentity +from recap.authorization.scopes import Scope +from recap.exceptions import RecapServiceUnavailableError + + +class SnapshotUnavailable(RecapServiceUnavailableError): + pass + + +class SnapshotMetadata(BaseModel): + model_config = ConfigDict(frozen=True) + + format_version: int + source_revision: str + + +class GrantProvenance(BaseModel): + model_config = ConfigDict(frozen=True) + + identity: ProviderIdentity + namespace_path: str + scope: Scope + group: str + role: str + + +class AuthorizationSnapshot(BaseModel): + model_config = ConfigDict(frozen=True) + + metadata: SnapshotMetadata + grants: frozenset[GrantProvenance] + + +def _seconds(value: float | datetime | timedelta) -> float: + if isinstance(value, datetime): + return value.timestamp() + if isinstance(value, timedelta): + return value.total_seconds() + return float(value) + + +def load_authorization_snapshot( + path: str | Path, + *, + max_age: float | timedelta, + now: float | datetime | None = None, +) -> AuthorizationSnapshot: + snapshot_path = Path(path) + try: + modified = snapshot_path.stat().st_mtime + except OSError as error: + raise SnapshotUnavailable("Authorization snapshot is unavailable") from error + + current_time = time.time() if now is None else _seconds(now) + if current_time - modified > _seconds(max_age): + raise SnapshotUnavailable("Authorization snapshot is stale") + + try: + with sqlite3.connect(f"file:{snapshot_path}?mode=ro", uri=True) as connection: + integrity = connection.execute("PRAGMA integrity_check").fetchall() + if integrity != [("ok",)]: + raise SnapshotUnavailable( + "Authorization snapshot failed integrity check" + ) + + metadata_rows = connection.execute( + "SELECT format_version, source_revision FROM snapshot_metadata" + ).fetchall() + if len(metadata_rows) != 1: + raise SnapshotUnavailable("Authorization snapshot metadata is invalid") + format_version, source_revision = metadata_rows[0] + if format_version != 1: + raise SnapshotUnavailable( + f"Unsupported authorization snapshot format version: {format_version}" + ) + + grant_rows = connection.execute( + """ + SELECT i.provider, i.subject, n.path, s.name, + g.group_name, g.role_name + FROM grants AS g + JOIN identities AS i ON i.id = g.identity_id + JOIN namespace_paths AS n ON n.id = g.namespace_path_id + JOIN scopes AS s ON s.id = g.scope_id + """ + ).fetchall() + + return AuthorizationSnapshot( + metadata=SnapshotMetadata( + format_version=format_version, + source_revision=source_revision, + ), + grants=frozenset( + GrantProvenance( + identity=ProviderIdentity(provider=provider, subject=subject), + namespace_path=namespace_path, + scope=scope, + group=group, + role=role, + ) + for provider, subject, namespace_path, scope, group, role in grant_rows + ), + ) + except SnapshotUnavailable: + raise + except (OSError, sqlite3.Error, TypeError, ValidationError, ValueError) as error: + raise SnapshotUnavailable("Authorization snapshot is invalid") from error + + +class SnapshotProvider: + def __init__( + self, + path: str | Path, + *, + max_age: float | timedelta, + clock: Callable[[], float | datetime] = time.time, + ) -> None: + self._path = Path(path) + self._max_age = max_age + self._clock = clock + self._lock = threading.Lock() + self._file_version: tuple[int, int, int] | None = None + self._snapshot: AuthorizationSnapshot | None = None + + def acquire(self) -> AuthorizationSnapshot: + with self._lock: + try: + stat = self._path.stat() + except OSError as error: + raise SnapshotUnavailable( + "Authorization snapshot is unavailable" + ) from error + version = (stat.st_ino, stat.st_size, stat.st_mtime_ns) + if version != self._file_version: + snapshot = load_authorization_snapshot( + self._path, + max_age=self._max_age, + now=self._clock(), + ) + self._snapshot = snapshot + self._file_version = version + if self._snapshot is None: + raise SnapshotUnavailable("Authorization snapshot is unavailable") + return self._snapshot diff --git a/recap/authorization/source.py b/recap/authorization/source.py new file mode 100644 index 0000000..a08fba6 --- /dev/null +++ b/recap/authorization/source.py @@ -0,0 +1,166 @@ +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import yaml +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +from recap.authentication.models import ProviderIdentity +from recap.authorization.scopes import Scope +from recap.utils.namespace import canonicalize_namespace_path + + +class _UniqueKeyLoader(yaml.SafeLoader): + pass + + +def _construct_unique_mapping( + loader: _UniqueKeyLoader, node: yaml.MappingNode, deep: bool = False +) -> dict[Any, Any]: + loader.flatten_mapping(node) + mapping: dict[Any, Any] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in mapping: + raise ValueError(f"Duplicate YAML key: {key!r}") + mapping[key] = loader.construct_object(value_node, deep=deep) + return mapping + + +_UniqueKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping +) + + +class _SourceModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class RoleConfig(_SourceModel): + scopes: tuple[Scope, ...] + + @field_validator("scopes") + @classmethod + def validate_scopes(cls, scopes: tuple[Scope, ...]) -> tuple[Scope, ...]: + if not scopes: + raise ValueError("Role scopes must not be empty") + if len(scopes) != len(set(scopes)): + raise ValueError("Role scopes must not contain duplicates") + return scopes + + +class IdentityGroupConfig(_SourceModel): + identities: tuple[ProviderIdentity, ...] + + @model_validator(mode="before") + @classmethod + def validate_identity_input(cls, value: Any) -> Any: + if not isinstance(value, Mapping): + return value + identities = value.get("identities") + if not isinstance(identities, list | tuple): + return value + for identity in identities: + if not isinstance(identity, Mapping): + continue + extra = set(identity) - {"provider", "subject"} + if extra: + raise ValueError(f"Unknown identity fields: {sorted(extra)!r}") + for field in ("provider", "subject"): + if not isinstance(identity.get(field), str) or not identity[field]: + raise ValueError(f"Identity {field} must be a non-empty string") + return value + + @field_validator("identities") + @classmethod + def validate_identities( + cls, identities: tuple[ProviderIdentity, ...] + ) -> tuple[ProviderIdentity, ...]: + if not identities: + raise ValueError("Identity group must not be empty") + if len(identities) != len(set(identities)): + raise ValueError("Duplicate identity in group") + return identities + + +class NamespaceGroupBinding(_SourceModel): + name: str + role: str + + @field_validator("name", "role") + @classmethod + def validate_name(cls, value: str) -> str: + if not value: + raise ValueError("Binding name and role must not be empty") + return value + + +class NamespaceBindingConfig(_SourceModel): + groups: tuple[NamespaceGroupBinding, ...] + + @field_validator("groups") + @classmethod + def validate_groups( + cls, groups: tuple[NamespaceGroupBinding, ...] + ) -> tuple[NamespaceGroupBinding, ...]: + if not groups: + raise ValueError("Namespace groups must not be empty") + bindings = {(binding.name, binding.role) for binding in groups} + if len(bindings) != len(groups): + raise ValueError("Duplicate binding") + return groups + + +class AuthorizationSourceConfig(_SourceModel): + source_revision: str + roles: dict[str, RoleConfig] + groups: dict[str, IdentityGroupConfig] + namespaces: dict[str, NamespaceBindingConfig] + + @field_validator("source_revision") + @classmethod + def validate_source_revision(cls, value: str) -> str: + if not value: + raise ValueError("source_revision must not be empty") + return value + + @field_validator("roles", "groups", "namespaces") + @classmethod + def validate_mapping_names(cls, value: dict[str, Any]) -> dict[str, Any]: + if any(not isinstance(name, str) or not name for name in value): + raise ValueError("Configuration names must be non-empty strings") + return value + + @field_validator("namespaces") + @classmethod + def validate_namespace_paths( + cls, namespaces: dict[str, NamespaceBindingConfig] + ) -> dict[str, NamespaceBindingConfig]: + for path in namespaces: + canonicalize_namespace_path(path) + return namespaces + + @model_validator(mode="after") + def validate_references_and_duplicates(self) -> "AuthorizationSourceConfig": + seen_identities: set[ProviderIdentity] = set() + for group in self.groups.values(): + for identity in group.identities: + if identity in seen_identities: + raise ValueError( + f"Duplicate identity: {identity.provider}:{identity.subject}" + ) + seen_identities.add(identity) + + for namespace in self.namespaces.values(): + for binding in namespace.groups: + if binding.name not in self.groups: + raise ValueError(f"Unknown group: {binding.name}") + if binding.role not in self.roles: + raise ValueError(f"Unknown role: {binding.role}") + return self + + +def load_authorization_source(path: str | Path) -> AuthorizationSourceConfig: + with Path(path).open(encoding="utf-8") as stream: + source = yaml.load(stream, Loader=_UniqueKeyLoader) + return AuthorizationSourceConfig.model_validate(source) diff --git a/recap/client/__init__.py b/recap/client/__init__.py index 68d4825..58efa32 100644 --- a/recap/client/__init__.py +++ b/recap/client/__init__.py @@ -1,3 +1,15 @@ from .base_client import RecapClient +from .permissions import ( + ActorPermissions, + DenialCode, + EffectivePermissions, + PermissionDecision, +) -__all__ = ["RecapClient"] +__all__ = [ + "ActorPermissions", + "DenialCode", + "EffectivePermissions", + "PermissionDecision", + "RecapClient", +] diff --git a/recap/client/backend.py b/recap/client/backend.py new file mode 100644 index 0000000..c4b132d --- /dev/null +++ b/recap/client/backend.py @@ -0,0 +1,169 @@ +from dataclasses import dataclass, field +from threading import RLock +from typing import Any +from uuid import UUID + +from pydantic import BaseModel + +from recap.adapter import ( + NamespaceCatalog, + NamespaceContextResolver, + NamespaceWriter, + PermissionsBackend, + ReadBackend, + WriteBackend, +) +from recap.client.identity import IdentityMap +from recap.commands.models import CommandContext, CommandModel +from recap.dsl.query import QuerySpec, SchemaT +from recap.lifecycle import LifecycleStatus + + +@dataclass(frozen=True, slots=True) +class ClientBackend: + reader: ReadBackend + writer: WriteBackend + namespaces: NamespaceCatalog + namespace_writer: NamespaceWriter + context_resolver: NamespaceContextResolver + permissions: PermissionsBackend | None = None + identity_map: IdentityMap = field(default_factory=IdentityMap) + _close_lock: RLock = field( + default_factory=RLock, init=False, repr=False, compare=False + ) + _closed: bool = field(default=False, init=False, repr=False, compare=False) + _identity_cleared: bool = field( + default=False, init=False, repr=False, compare=False + ) + _closed_capabilities: set[int] = field( + default_factory=set, init=False, repr=False, compare=False + ) + + def __post_init__(self) -> None: + require_capability(self.reader, ReadBackend, "reader") + require_capability(self.writer, WriteBackend, "writer") + require_capability(self.namespaces, NamespaceCatalog, "namespaces") + require_capability(self.namespace_writer, NamespaceWriter, "namespace_writer") + if self.context_resolver is not None: + require_capability( + self.context_resolver, NamespaceContextResolver, "context_resolver" + ) + if self.permissions is not None: + require_capability(self.permissions, PermissionsBackend, "permissions") + + def query( + self, schema: type[SchemaT], spec: QuerySpec, *, namespace_path: str + ) -> list[SchemaT]: + models = self.reader.query(schema, spec, namespace_path=namespace_path) + result = [] + for item in models: + result.append(self.identity_map.intern(item)) + return result + + def count( + self, schema: type[SchemaT], spec: QuerySpec, *, namespace_path: str + ) -> int: + return self.reader.count(schema, spec, namespace_path=namespace_path) + + def list_child_namespaces(self, parent_path: str) -> list[str]: + return self.namespaces.list_child_namespaces(parent_path) + + def create_namespace( + self, path: str, metadata: dict[str, Any] | None, context: CommandContext + ): + return self.namespace_writer.create_namespace(path, metadata, context) + + def update_namespace( + self, + namespace_id: UUID, + expected_revision: int, + metadata: dict[str, Any] | None, + status: LifecycleStatus | None, + context: CommandContext, + *, + etag: str | None = None, + ): + return self.namespace_writer.update_namespace( + namespace_id, + expected_revision, + metadata, + status, + context, + etag=etag, + ) + + def _execute( + self, + command: CommandModel, + context: CommandContext, + *, + etag_override: str | None = None, + ) -> object: + if etag_override is None: + result = self.writer.execute(command, context) + else: + result = self.writer.execute(command, context, etag_override=etag_override) + return ( + self.identity_map.intern(result, authoritative=True) + if isinstance(result, BaseModel) + else result + ) + + def close(self, *, clear_identity: bool = True) -> None: + with self._close_lock: + if self._closed: + return + first_error = self._clear_identity(clear_identity) + capability_error = self._close_capabilities() + if first_error is None: + first_error = capability_error + if first_error is not None: + raise first_error + object.__setattr__(self, "_closed", True) + + def _clear_identity(self, clear_identity: bool) -> Exception | None: + if not clear_identity or getattr(self, "_identity_cleared", False): + return None + try: + self.identity_map.clear() + except Exception as error: + return error + object.__setattr__(self, "_identity_cleared", True) + return None + + def _close_capabilities(self) -> Exception | None: + closed_capabilities = getattr(self, "_closed_capabilities", None) + if closed_capabilities is None: + closed_capabilities = set() + object.__setattr__(self, "_closed_capabilities", closed_capabilities) + attempted: set[int] = set() + first_error: Exception | None = None + for capability in ( + self.reader, + self.writer, + self.namespaces, + self.namespace_writer, + getattr(self, "context_resolver", None), + getattr(self, "permissions", None), + ): + if capability is None: + continue + identity = id(capability) + if identity in closed_capabilities or identity in attempted: + continue + attempted.add(identity) + close = getattr(capability, "close", None) + if close is not None: + try: + close() + except Exception as error: + if first_error is None: + first_error = error + continue + closed_capabilities.add(identity) + return first_error + + +def require_capability(value: Any, protocol: type, name: str) -> None: + if not isinstance(value, protocol): + raise TypeError(f"{name} must satisfy {protocol.__name__}") diff --git a/recap/client/base_client.py b/recap/client/base_client.py index ddeb9e1..5c8cd4f 100644 --- a/recap/client/base_client.py +++ b/recap/client/base_client.py @@ -1,20 +1,31 @@ -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from pathlib import Path from tempfile import gettempdir from typing import Any, Literal, overload -from urllib.parse import urlparse from uuid import UUID, uuid4 from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from recap.adapter import Backend -from recap.adapter.local import LocalBackend +from recap.client.backend import ClientBackend +from recap.client.connection_state import ConnectionState +from recap.client.permissions import ActorPermissions +from recap.commands.context import build_local_command_context +from recap.commands.errors import CommandValidationError +from recap.commands.models import ( + CopyProcessRun, + CopyResource, + CreateNamespace, + UpdateNamespace, +) from recap.dsl.process_builder import ProcessRunBuilder, ProcessTemplateBuilder from recap.dsl.query import QueryDSL from recap.dsl.resource_builder import ResourceBuilder, ResourceTemplateBuilder -from recap.schemas.process import CampaignSchema -from recap.schemas.resource import ResourceRef, ResourceSchema +from recap.exceptions import RecapNotFoundError +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext +from recap.schemas.process import ProcessRunCopyOptions, ProcessRunSchema +from recap.schemas.resource import ResourceCopyOptions, ResourceRef, ResourceSchema from recap.utils.migrations import apply_migrations @@ -22,63 +33,61 @@ class RecapClient: """Primary entry point for interacting with a RECAP provenance database. ``RecapClient`` wraps a SQLAlchemy session and exposes factory methods for - creating and loading the core domain objects — campaigns, resources, + creating and loading the core domain objects: namespaces, resources, resource templates, process templates, and process runs. - Prefer the :meth:`from_sqlite` class method over constructing an instance - directly; it handles database creation and schema migrations automatically. + Use :meth:`from_sqlite` for local SQLite databases and :meth:`from_url` for + remote recap servers. These are the canonical initialization methods. The client can be used as a context manager, which closes the underlying - engine on exit:: + engine on exit: with RecapClient.from_sqlite() as client: - client.create_campaign("my campaign", "proposal-42") + client.create_namespace("projects") + client.create_namespace("projects/my-project") - Attributes: - database_path: Filesystem path to the SQLite database file, or - ``None`` when a non-file URL is used. - backend: The storage backend used to persist domain objects. """ + @overload + def __init__(self, connection_state: ConnectionState, *, namespace_path: str): ... + + @overload + def __init__( + self, connection_state: ConnectionState, *, namespace_context: NamespaceContext + ): ... + def __init__( self, - url: str | None = None, - echo: bool = False, + connection_state: ConnectionState, + *, + namespace_path: str | None = None, + namespace_context: NamespaceContext | None = None, ): - """Initialise a client from a database URL. + """Initialise common empty client state. - In most cases you should use :meth:`from_sqlite` instead, which also - creates the database file and runs pending migrations. + Use :meth:`from_sqlite` for local clients and :meth:`from_url` for + remote clients. These are the canonical initialization methods. + """ + namespace_path = namespace_path if namespace_path is not None else "" + self.connection_state: ConnectionState = connection_state + self.connection_state.acquire() + self._namespace_context: NamespaceContext = ( + namespace_context + if namespace_context is not None + else self._resolve_namespace_context(namespace_path) + ) + self._closed = False - Args: - url: A SQLAlchemy-compatible connection string. Only - ``sqlite:///`` URLs are currently supported. Pass ``None`` - to create an uninitialised client (useful for testing). - echo: When ``True`` the SQLAlchemy engine will log every SQL - statement it executes. Defaults to ``False``. + def __repr__(self) -> str: + return f"RecapClient({self.namespace_path=})" - Raises: - NotImplementedError: If an ``http://`` or ``https://`` URL is - supplied (REST backend is not yet implemented). - ValueError: If the URL scheme is not recognised. - """ - self._campaign: CampaignSchema | None = None - self.database_path: Path | None = None - self.backend: Backend | None = None - if url is not None: - parsed = urlparse(url) - if parsed.scheme in ("http", "https"): - raise NotImplementedError("Rest api via HTTP(S) is not yet implemented") - elif "sqlite" in parsed.scheme: - if parsed.path and parsed.path != "/:memory:": - self.database_path = Path(parsed.path) - self.engine = create_engine(url, echo=echo) - self._sessionmaker = sessionmaker( - bind=self.engine, expire_on_commit=False, future=True - ) - self.backend = LocalBackend(self._sessionmaker) - else: - raise ValueError(f"Unknown scheme: {parsed.scheme}") + @staticmethod + def _normalize_namespace(namespace: str | None) -> str: + return (namespace or "").strip("/") + + @property + def namespace_path(self): + return self._namespace_context.path def close(self): """Close the underlying session and engine to release SQLite locks. @@ -86,20 +95,10 @@ def close(self): Safe to call multiple times. After calling this method the client should no longer be used. """ - backend = getattr(self, "backend", None) - if backend and hasattr(backend, "close"): - backend.close() - # Close read_backend separately when it differs from backend (e.g. GraphQLAdapter) - read_backend = getattr(self, "_read_backend", None) - if ( - read_backend - and read_backend is not backend - and hasattr(read_backend, "close") - ): - read_backend.close() - engine = getattr(self, "engine", None) - if engine: - engine.dispose() + if self._closed: + return + self.connection_state.release() + self._closed = True def __enter__(self): """Return the client itself when used as a context manager.""" @@ -112,93 +111,139 @@ def __exit__(self, exc_type, exc, tb): @classmethod def _from_backends( cls, - read_backend: "Backend", - write_backend: "Backend", + backend: ClientBackend, + *, + namespace: str, + engine: Any = None, + sessionmaker_: Any = None, + database_path: Path | None = None, ) -> "RecapClient": - """Construct a RecapClient with split read/write backends. - - Internal classmethod used by :meth:`from_url` and :meth:`from_sqlite`. - The ``backend`` attribute is set to ``write_backend`` for backward - compatibility with builder methods that reference ``self.backend``. - ``read_backend`` is stored separately and used by :meth:`query_maker`. - """ - instance = cls.__new__(cls) - instance._campaign = None - instance.database_path = None - instance.backend = write_backend - instance._read_backend = read_backend + """Construct a RecapClient with composed backend capabilities.""" + state = ConnectionState( + backend=backend, + engine=engine, + sessionmaker=sessionmaker_, + database_path=database_path, + ) + instance = cls(connection_state=state, namespace_path=namespace) return instance @classmethod - def from_url(cls, url: str) -> "RecapClient": - """Connect to a recap GraphQL server. - - Fetches ``/db_path`` from the server to obtain the SQLite file path, - then uses :class:`~recap.adapter.graphql.GraphQLAdapter` for reads and - :class:`~recap.adapter.local.LocalBackend` for direct writes. - - Phase 1 constraint: requires a shared filesystem between client and - server — the server's ``db_path`` must be accessible from the client - machine. This constraint is removed in Phase 2 when writes route - through REST. - - Args: - url: Base URL of the recap server, e.g. ``"http://localhost:8000"``. - - Returns: - A fully initialised :class:`RecapClient`. - - Raises: - RecapConnectionError: If the server is unreachable or returns an - HTTP error response. + def from_url( + cls, + url: str, + *, + api_key: str, + timeout: float = 30.0, + namespace: str | None = None, + unscoped: bool = False, + ) -> "RecapClient": + """Connect to a recap webserver. + + Uses :class:`~recap.adapter.rest.RESTAdapter` for reads and writes. The client does + not require access to the server's database filesystem. + + Parameters + ---------- + url : str + Base URL of the recap server, e.g. ``"http://localhost:8000"``. + api_key : str + API key used to authenticate requests. + timeout : float, default=30.0 + HTTP request timeout in seconds. + namespace : str, default=None + Optional namespace to initialize client with + + Returns + ------- + RecapClient + Fully initialized client with REST reads and writes. + + Raises + ------ + RecapConnectionError + If the server is unreachable. + RecapProtocolError + If the server returns a malformed response. + RecapRequestError + If the server returns an API error response. """ - import httpx2 + from recap.adapter.http_transport import HTTPTransport + from recap.adapter.rest import RESTAdapter - from recap.adapter.graphql import GraphQLAdapter - from recap.exceptions import RecapConnectionError + if unscoped: + raise ValueError("Remote clients do not support unscoped=True") base = url.rstrip("/") - try: - response = httpx2.get(f"{base}/db_path") - response.raise_for_status() - except httpx2.ConnectError as exc: - raise RecapConnectionError(url, message=str(exc)) from exc - except httpx2.TimeoutException as exc: - raise RecapConnectionError(url, message=str(exc)) from exc - except httpx2.HTTPStatusError as exc: - raise RecapConnectionError( - url, status_code=exc.response.status_code - ) from exc - - db_path = response.json()["db_path"] - - from recap.utils.migrations import apply_migrations - - db_url = f"sqlite:///{db_path}" - apply_migrations(db_url) + transport = HTTPTransport(api_key, timeout=timeout) + rest = RESTAdapter(base_url=base, _transport=transport) + client = cls._from_backends( + backend=ClientBackend( + reader=rest, + writer=rest, + namespaces=rest, + namespace_writer=rest, + context_resolver=rest, + permissions=rest, + ), + namespace=namespace, + ) + return client - from sqlalchemy import create_engine - from sqlalchemy.orm import sessionmaker + def permissions(self) -> ActorPermissions: + """Return typed effective permissions for this client's namespace.""" + if self.connection_state.backend.permissions is None: + raise RuntimeError("Permissions API requires a remote read backend") + return self.connection_state.backend.permissions.permissions( + self.namespace_path + ) - engine = create_engine(db_url, echo=False) - sm = sessionmaker(bind=engine, expire_on_commit=False, future=True) - write_backend = LocalBackend(sm) - read_backend = GraphQLAdapter(graphql_url=f"{base}/graphql") + def namespace(self, path: str) -> "RecapClient": + """Return a view scoped to an additive namespace path.""" + if not isinstance(path, str): + raise TypeError("namespace path must be a string") + child_path = self._normalize_namespace(path) + namespace_path = "/".join( + part for part in (self.namespace_path, child_path) if part + ) + child_namespace_context = self._resolve_namespace_context(namespace_path) + view = self.__class__( + connection_state=self.connection_state, + namespace_context=child_namespace_context, + ) + return view - instance = cls._from_backends( - read_backend=read_backend, write_backend=write_backend + def __getitem__(self, namespace: str) -> "RecapClient": + return self.namespace(namespace) + + def _resolve_namespace_context( + self, namespace_path: str | None + ) -> NamespaceContext: + namespace_path = ( + namespace_path if namespace_path is not None else self.namespace_path ) - instance.database_path = None # server-side path, not local - instance.engine = engine - return instance + try: + return self.connection_state.backend.context_resolver.get_namespace_context( + namespace_path + ) + except LookupError: + raise + + @staticmethod + def _command_context(): + return build_local_command_context() @classmethod def from_sqlite( - cls, path: str | Path | None = None, echo: bool = False + cls, + path: str | Path | None = None, + echo: bool = False, + *, + namespace: str | None = None, ) -> "RecapClient": """Create or upgrade a local SQLite database and return a connected client. - This is the recommended way to create a :class:`RecapClient`. The + This is the canonical way to create a :class:`RecapClient`. The method creates the database file (and any missing parent directories) if it does not already exist, then runs any pending Alembic migrations so the schema is always up to date. @@ -239,8 +284,24 @@ def from_sqlite( db_url = f"sqlite:///{target_path}" apply_migrations(db_url) - client = cls(url=db_url, echo=echo) - client.database_path = target_path + engine = create_engine(db_url, echo=echo) + sessionmaker_ = sessionmaker(bind=engine, expire_on_commit=False, future=True) + from recap.adapter.local import LocalBackend + + backend = LocalBackend(sessionmaker_) + client = cls._from_backends( + backend=ClientBackend( + reader=backend, + writer=backend, + namespaces=backend, + namespace_writer=backend, + context_resolver=backend, + ), + namespace=namespace, + engine=engine, + sessionmaker_=sessionmaker_, + database_path=target_path, + ) return client @overload @@ -260,7 +321,8 @@ def build_process_template( on_existing: Literal["silent", "warn", "raise"] = "warn", **kwargs, ) -> ProcessTemplateBuilder: - """Open a builder for a :class:`~recap.dsl.process_builder.ProcessTemplateBuilder`. + """Open a builder for a + :class:`~recap.dsl.process_builder.ProcessTemplateBuilder`. Call this method in two mutually exclusive ways: @@ -295,18 +357,18 @@ def build_process_template( RuntimeError: If the backend has not been initialised. TypeError: On invalid argument combinations. """ - if self.backend is None: - raise RuntimeError("Backend not initialized") - if process_template_id is not None: if args or kwargs: raise TypeError( - "Pass either an existing process_template_id or name/version, not both" + "Pass either an existing process_template_id or " + "name/version, not both" ) return ProcessTemplateBuilder( name=None, version=None, - backend=self.backend, + backend=self.connection_state.backend, + command_context=self._command_context(), + namespace_context=self.namespace_context, process_template_id=process_template_id, on_existing=on_existing, ) @@ -327,7 +389,9 @@ def build_process_template( return ProcessTemplateBuilder( name=name, version=version, - backend=self.backend, + backend=self.connection_state.backend, + command_context=self._command_context(), + namespace_context=self.namespace_context, on_existing=on_existing, ) @@ -348,9 +412,8 @@ def build_process_run( ) -> ProcessRunBuilder: """Open a builder for a :class:`~recap.dsl.process_builder.ProcessRunBuilder`. - A :class:`~recap.schemas.process.CampaignSchema` must be active (set - via :meth:`create_campaign` or :meth:`set_campaign`) before calling - this method with new run arguments. + Namespace context must be active before calling this method with new + run arguments. Call this method in two mutually exclusive ways: @@ -383,23 +446,22 @@ def build_process_run( Raises: RuntimeError: If the backend has not been initialised. - ValueError: If no campaign is set when creating a new run. + ValueError: If no namespace context is set when creating a new run. TypeError: On invalid argument combinations. """ - if self.backend is None: - raise RuntimeError("Backend not initialized") - if process_run_id is not None: if args or kwargs: raise TypeError( - "Pass either an existing process_run_id or name/description/template_name/version, not both" + "Pass either an existing process_run_id or " + "name/description/template_name/version, not both" ) return ProcessRunBuilder( name=None, description=None, template_name=None, - campaign=self._campaign, - backend=self.backend, + backend=self.connection_state.backend, + namespace_context=self.namespace_context, + command_context=self._command_context(), version=None, process_run_id=process_run_id, on_existing=on_existing, @@ -408,7 +470,8 @@ def build_process_run( if args: if len(args) != 4: raise TypeError( - "Provide exactly four positional arguments: name, description, template_name, version" + "Provide exactly four positional arguments: name, description, " + "template_name, version" ) name, description, template_name, version = args else: @@ -424,17 +487,13 @@ def build_process_run( if kwargs: raise TypeError(f"Unexpected keyword arguments: {', '.join(kwargs)}") - if self._campaign is None: - raise ValueError( - "Campaign not set, cannot create process run. Use create_campaign() or set_campaign() first" - ) - return ProcessRunBuilder( name=name, description=description, template_name=template_name, - campaign=self._campaign, - backend=self.backend, + namespace_context=self.namespace_context, + backend=self.connection_state.backend, + command_context=self._command_context(), version=version, on_existing=on_existing, ) @@ -458,7 +517,8 @@ def build_resource_template( resource_template_id: UUID | None = None, on_existing: Literal["silent", "warn", "raise"] = "warn", ): - """Open a builder for a :class:`~recap.dsl.resource_builder.ResourceTemplateBuilder`. + """Open a builder for a + :class:`~recap.dsl.resource_builder.ResourceTemplateBuilder`. A :class:`~recap.schemas.resource.ResourceTemplateSchema` is the blueprint for a :class:`~recap.schemas.resource.ResourceSchema`. @@ -470,7 +530,9 @@ def build_resource_template( name="Library Plate", type_names=["container", "plate", "library_plate"], ) as tb: - tb.add_properties({"dimensions": [{"name": "rows", "type": "int", "default": 8}]}) + tb.add_properties( + {"dimensions": [{"name": "rows", "type": "int", "default": 8}]} + ) **Load an existing template by ID**:: @@ -498,19 +560,19 @@ def build_resource_template( TypeError: If *type_names* is a string, contains non-string items, or if conflicting arguments are provided. """ - if self.backend is None: - raise RuntimeError("Backend not initialized") - if resource_template_id is not None: if name is not None or type_names is not None: raise TypeError( - "Pass either an existing resource_template_id or name/type_names, not both" + "Pass either an existing resource_template_id or " + "name/type_names, not both" ) return ResourceTemplateBuilder( name=None, type_names=None, version=version, - backend=self.backend, + backend=self.connection_state.backend, + command_context=self._command_context(), + namespace_context=self.namespace_context, resource_template_id=resource_template_id, on_existing=on_existing, ) @@ -526,7 +588,9 @@ def build_resource_template( name=name, type_names=type_names, version=version, - backend=self.backend, + backend=self.connection_state.backend, + command_context=self._command_context(), + namespace_context=self.namespace_context, on_existing=on_existing, ) @@ -596,13 +660,11 @@ def build_resource( RuntimeError: If the backend has not been initialised. TypeError: On invalid argument combinations. """ - if self.backend is None: - raise RuntimeError("Backend not initialized") - if resource_id is not None: if args or kwargs: raise TypeError( - "Pass either an existing resource_id or name/template_name, not both" + "Pass either an existing resource_id or " + "name/template_name, not both" ) if parent is not None: raise TypeError( @@ -613,25 +675,31 @@ def build_resource( name=None, template_name=None, template_version="1.0", - backend=self.backend, + backend=self.connection_state.backend, + command_context=self._command_context(), + namespace_context=self.namespace_context, resource_id=resource_id, on_existing=on_existing, ) - resolved_parent = self._resolve_parent(parent) + resolved_parent = self._resolve_parent(parent, self.namespace_context) name, template_name, template_version = self._parse_resource_args(args, kwargs) return ResourceBuilder( name=name, template_name=template_name, template_version=template_version, - backend=self.backend, + backend=self.connection_state.backend, + command_context=self._command_context(), + namespace_context=self.namespace_context, on_existing=on_existing, parent=resolved_parent, ) def _resolve_parent( - self, parent: "ResourceSchema | UUID | None" + self, + parent: "ResourceSchema | UUID | None", + namespace_context: NamespaceContext | None = None, ) -> "ResourceSchema | None": """Resolve a parent argument to a ResourceSchema (or None).""" if parent is None: @@ -639,15 +707,19 @@ def _resolve_parent( if isinstance(parent, UUID): from recap.dsl.query import QuerySpec - results = self.backend.query( + results = self.connection_state.backend.query( ResourceSchema, QuerySpec( filters={"id": parent}, preloads=["children", "properties"], + include_mutable=True, ), + namespace_path=(namespace_context or self._namespace_context).path, ) if not results: - raise ValueError(f"Parent resource with id {parent!r} not found") + raise RecapNotFoundError( + f"Parent resource with id {parent!r} not found" + ) return results[0] return parent @@ -706,7 +778,7 @@ def create_resource( - ``"create"`` (default): always create a new resource. Resource names are NOT globally unique — multiple resources with the same name can coexist (e.g., for different - campaigns). + namespaces). - ``"silent"``: reuse the existing resource silently. - ``"warn"``: reuse the existing resource and emit a warning. - ``"raise"``: raise :class:`ExistingResourceError`. @@ -715,17 +787,58 @@ def create_resource( A :class:`~recap.schemas.resource.ResourceSchema` representing the persisted resource, including any auto-created children. """ - if self.backend is None: - raise RuntimeError("Backend not initialized") return ResourceBuilder.create( name=name, template_name=template_name, template_version=template_version, - backend=self.backend, + backend=self.connection_state.backend, + namespace_context=self.namespace_context, + command_context=self._command_context(), parent=parent, on_existing=on_existing, ) + def copy_resource( + self, + source_resource_id: UUID, + options: ResourceCopyOptions | None = None, + ) -> ResourceSchema: + """Copy resource across namespaces and commit or roll back atomically. + + Destination namespace comes from this client's scope. Returns persisted + full schema and propagates backend validation or authorization errors. + """ + copy_options = options or ResourceCopyOptions() + try: + return self.connection_state.backend._execute( + CopyResource( + source_resource_id=source_resource_id, + destination_namespace_path=self.namespace_context.path, + options=copy_options, + ), + self._command_context(), + ) + except CommandValidationError as error: + raise ValueError(str(error)) from error + + def copy_process_run( + self, + source_process_run_id: UUID, + options: ProcessRunCopyOptions | None = None, + ) -> ProcessRunSchema: + """Copy process run into current namespace with fresh aggregate identity.""" + if self.connection_state.backend is None: + raise RuntimeError("Backend not initialized") + namespace_context = self._resolve_namespace_context() + return self.connection_state.backend._execute( + CopyProcessRun( + source_process_run_id=source_process_run_id, + destination_namespace_path=namespace_context.path, + options=options or ProcessRunCopyOptions(), + ), + self._command_context(), + ) + @overload def get_resource( self, @@ -783,272 +896,103 @@ def get_resource( :class:`~recap.schemas.resource.ResourceSchema` when ``expand=True``. """ - if self.backend is None: - raise RuntimeError("Backend not initialized") - return self.backend.get_resource( - name, - template_name, - template_version, - expand=expand, + from recap.dsl.query import QuerySpec + + schema = ResourceSchema if expand else ResourceRef + results = self.connection_state.backend.query( + schema, + QuerySpec( + filters={ + "name": name, + "template__name": template_name, + "template__version": template_version, + }, + preloads=("template", "children", "properties") if expand else (), + load_mode="eager" if expand else None, + include_mutable=True, + ), + namespace_path=self.namespace_context.path, ) - - def create_campaign( - self, - name: str, - proposal: str, - saf: str | None = None, - metadata: dict[str, Any] | None = None, - ) -> CampaignSchema: - """Create a new campaign and make it the active campaign for this client. - - A :class:`~recap.schemas.process.CampaignSchema` is a top-level - grouping object that all :class:`ProcessRun` instances belong to. You - must create or set a campaign before calling :meth:`build_process_run` - with new run arguments. - - Example:: - - client.create_campaign( - name="MX Beamtime April 2026", - proposal="MX-2026-001", - saf="SAF-42", + if not results: + raise RecapNotFoundError(f"Resource {name!r} not found") + if len(results) > 1: + raise ValueError( + f"Multiple resources named {name!r} matched the requested template" ) + return results[0] + + def create_namespace( + self, path: str, metadata: dict[str, Any] | None = None, as_current=False + ) -> NamespaceContext: + """Create a namespace and make it active for subsequent writes.""" + result = self.connection_state.backend._execute( + CreateNamespace(path=path, metadata=metadata), self._command_context() + ) + if as_current: + self._namespace_context = self._as_namespace_context(result) + return self._namespace_context - Args: - name: Human-readable name for the campaign. - proposal: Proposal or project identifier associated with this - campaign. - saf: Safety Approval Form (SAF) or equivalent authorization - reference. Optional. - metadata: Arbitrary JSON-serialisable key/value pairs to store - alongside the campaign record. Optional. - - Returns: - The created :class:`~recap.schemas.process.CampaignSchema`, which - is also stored as the client's active campaign (accessible via the - :attr:`campaign` property). - """ - if self.backend is None: - raise RuntimeError("Backend not initialized") - uow = self.backend.begin() - try: - self._campaign = self.backend.create_campaign(name, proposal, saf, metadata) - uow.commit() - except Exception: - uow.rollback() - raise - return self._campaign - - @property - def campaign(self) -> CampaignSchema | None: - """The client's currently active campaign, or ``None`` if unset. + def get_namespace(self, path: str) -> NamespaceContext: + """Get existing namespace information""" + return self._resolve_namespace_context(path) - Read-only. Use :meth:`create_campaign`/:meth:`set_campaign` to change - the active campaign and :meth:`update_campaign` to persist edits. - """ - return self._campaign - - def set_campaign( + def update_namespace( self, - id: UUID | None = None, - campaign: CampaignSchema | None = None, + namespace_id: UUID | None = None, *, - force: bool = False, - ) -> CampaignSchema: - """Load an existing campaign by ID and make it the active campaign. - - Use this to resume work against a campaign that was created in a - previous session or by another client instance. - - The active campaign is cached client-side: re-activating the campaign - that is already active is a no-op that skips the database round-trip. - There is **no automatic staleness detection** — the client cannot tell - whether another process has edited the campaign without re-querying. - Pass ``force=True`` to discard the cache and re-read from the backend - when you suspect the campaign was changed out of band. - - Example:: - - client.set_campaign(existing_campaign_id) - # later, after an external edit: - client.set_campaign(existing_campaign_id, force=True) - - Args: - id: The UUID of the campaign to activate. - campaign: Alternatively, the campaign to activate, as a schema. - force: When ``True``, always re-query the backend even if the - requested campaign is already active. - - Returns: - The activated :class:`~recap.schemas.process.CampaignSchema`. - """ - if self.backend is None: + expected_revision: int | None = None, + metadata: Mapping[str, Any] | None = None, + status: LifecycleStatus | None = None, + ) -> NamespaceContext: + """Apply namespace metadata/status update and make result active.""" + if self.connection_state.backend is None: raise RuntimeError("Backend not initialized") - if isinstance(id, UUID): - target_id = id - elif isinstance(campaign, CampaignSchema): - target_id = campaign.id - else: - raise TypeError( - f"id should be of type UUID or campaign should be of type CampaignSchema, found type, id: {type(id)} campaign: {type(campaign)}" - ) - # Short-circuit: the requested campaign is already active. Avoid the - # transaction + SELECT round-trip unless the caller forces a reload. - if not force and self._campaign is not None and self._campaign.id == target_id: - return self._campaign - uow = self.backend.begin() - try: - self._campaign = self.backend.set_campaign(target_id) - uow.commit() - except Exception: - uow.rollback() - raise - return self._campaign - - def update_campaign( - self, campaign: CampaignSchema | None = None, **fields: Any - ) -> CampaignSchema: - """Persist edits to a campaign and refresh the active-campaign cache. - - Edit a campaign either by passing field overrides as keyword arguments - or by passing an explicit (already-mutated) schema. When no *campaign* - is supplied the client's active campaign is updated. Keyword overrides, - when given, are applied on top of the target schema. - - Only the writable fields ``name``, ``proposal``, ``saf`` and - ``meta_data`` may be set; all four are written (full overwrite). - - Example:: - - # via keyword overrides on the active campaign - client.update_campaign(name="MX Beamtime May 2026", saf="SAF-43") - - # via an explicit, mutated schema (GET -> mutate -> PUT) - camp = client.campaign - camp.proposal = "MX-2026-002" - client.update_campaign(camp) + context = self._namespace_context + if namespace_id is None: + if context is None: + raise ValueError("An active namespace context is required") + namespace_id = context.id + if expected_revision is None: + if context is None or context.revision is None: + raise ValueError("Expected namespace revision is required") + expected_revision = context.revision + + result = self.connection_state.backend._execute( + UpdateNamespace( + namespace_id=namespace_id, + expected_revision=expected_revision, + metadata=None if metadata is None else dict(metadata), + status=status, + ), + self._command_context(), + etag_override=None if context is None else context.etag, + ) + self._namespace_context = self._as_namespace_context(result) + return self._namespace_context - Args: - campaign: The campaign to update. Defaults to the active campaign. - **fields: Field overrides (``name``, ``proposal``, ``saf``, - ``meta_data``). + @staticmethod + def _as_namespace_context(result) -> NamespaceContext: + if isinstance(result, NamespaceContext): + return result + return NamespaceContext.model_validate(result) - Returns: - The updated :class:`~recap.schemas.process.CampaignSchema`, which - also becomes the client's active campaign. + @property + def namespace_context(self) -> NamespaceContext: + return self._namespace_context - Raises: - ValueError: If no campaign is supplied and none is active. - TypeError: If an unknown field name is passed. - """ - if self.backend is None: - raise RuntimeError("Backend not initialized") - base = campaign if campaign is not None else self._campaign - if base is None: - raise ValueError( - "No campaign to update. Pass a campaign or set one with " - "create_campaign()/set_campaign() first" - ) - if fields: - allowed = {"name", "proposal", "saf", "meta_data"} - unknown = set(fields) - allowed - if unknown: - raise TypeError( - f"Unknown campaign field(s): {', '.join(sorted(unknown))}. " - f"Allowed: {', '.join(sorted(allowed))}" - ) - target = base.model_copy(update=fields) - else: - target = base - uow = self.backend.begin() - try: - self._campaign = self.backend.update_campaign(target) - uow.commit() - except Exception: - uow.rollback() - raise - return self._campaign + def list_namespaces(self) -> list[str]: + """Return relative names of direct child namespaces.""" + return self.connection_state.backend.list_child_namespaces(self.namespace_path) def query_maker( self, *, - campaign=None, - unscoped: bool = False, on_unloaded: str = "warn", ): - """Return a :class:`~recap.dsl.query.QueryDSL` scoped to a campaign. - - The returned object exposes a fluent query API for retrieving - resources, process runs, and their relationships from the database. - - If *campaign* is omitted the client's currently active campaign is - used. Pass an explicit campaign (or its UUID) to query a different - one without changing the client's active campaign. - - Pass ``unscoped=True`` to disable campaign scoping entirely and - query across all campaigns. This is mutually exclusive with an - explicit *campaign* argument. - - ``on_unloaded`` controls behavior when accessing relationship fields - that were not included in the originating query. + """Return a query DSL scoped to this client's namespace.""" - .. note:: Campaign scoping for resources - - Resources do **not** carry a ``campaign_id`` column directly. - Campaign scoping for resource queries is achieved by joining - through ``ResourceAssignment`` -> ``ProcessRun`` and filtering - on ``ProcessRun.campaign_id``. A consequence is that resources - which have never been assigned to any process run will be - invisible to campaign-scoped queries. Use ``unscoped=True`` to - include all resources regardless of assignment status. - - Example:: - - qm = client.query_maker() - resources = qm.resources().of_type("library_plate").all() - - # Cross-campaign query - qm_all = client.query_maker(unscoped=True) - - Args: - campaign: A :class:`~recap.schemas.process.CampaignSchema` instance - or its UUID to scope the query. When ``None`` the active - campaign is used (if one is set). - unscoped: When ``True``, ignore the active campaign and return - results across all campaigns. Cannot be combined with - an explicit *campaign*. - on_unloaded: One of ``"silent"``, ``"warn"``, or ``"raise"``. - Defaults to ``"warn"``. - - Returns: - A :class:`~recap.dsl.query.QueryDSL` instance. - - Raises: - RuntimeError: If the backend has not been initialised. - ValueError: If both *campaign* and ``unscoped=True`` are given. - """ - if self.backend is None: - raise RuntimeError("Backend not initialized") - - if unscoped and campaign is not None: - raise ValueError( - "Cannot combine campaign with unscoped=True — " - "pass one or the other, not both" - ) - - campaign_id = None - if unscoped: - campaign_id = None - elif campaign is not None: - campaign_id = getattr(campaign, "id", campaign) - elif self._campaign is not None: - campaign_id = self._campaign.id - - read_backend = getattr(self, "_read_backend", self.backend) or self.backend - if read_backend is None: - raise RuntimeError("No read backend available") return QueryDSL( - read_backend, - campaign_id=campaign_id, + self.connection_state.backend, + context=self.namespace_context, on_unloaded=on_unloaded, ) diff --git a/recap/client/connection_state.py b/recap/client/connection_state.py new file mode 100644 index 0000000..034b589 --- /dev/null +++ b/recap/client/connection_state.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass, field +from pathlib import Path +from threading import RLock +from typing import Any + +from recap.client.backend import ClientBackend + + +@dataclass +class ConnectionState: + backend: ClientBackend + engine: Any = None + sessionmaker: Any = None + _active_views: int = 0 + closed: bool = False + _engine_disposed: bool = field(default=False, init=False, repr=False) + _lifecycle_lock: RLock = field(default_factory=RLock, init=False, repr=False) + database_path: Path | None = None + + def acquire(self) -> None: + with self._lifecycle_lock: + if self.closed: + raise RuntimeError("Connection state is closed") + self._active_views += 1 + + def release(self) -> None: + with self._lifecycle_lock: + if self.closed: + return + if self._active_views: + self._active_views -= 1 + if self._active_views == 0: + self.close() + + def close(self) -> None: + with self._lifecycle_lock: + if self.closed: + return + self.backend.close() + if self.engine is not None and not self._engine_disposed: + self.engine.dispose() + self._engine_disposed = True + self.closed = True diff --git a/recap/client/identity.py b/recap/client/identity.py new file mode 100644 index 0000000..99bbb32 --- /dev/null +++ b/recap/client/identity.py @@ -0,0 +1,637 @@ +from __future__ import annotations + +from datetime import datetime +from threading import RLock +from typing import Any +from uuid import UUID + +from pydantic import BaseModel + +from recap.lifecycle import LifecycleStatus +from recap.schemas.attribute import AttributeGroupTemplateSchema +from recap.schemas.common import LoadAware +from recap.schemas.namespace import NamespaceContext, NamespaceRef, NamespaceSchema +from recap.schemas.process import ( + ProcessRunRef, + ProcessRunSchema, + ProcessTemplateRef, + ProcessTemplateSchema, +) +from recap.schemas.resource import ( + ResourceRef, + ResourceSchema, + ResourceTemplateRef, + ResourceTemplateSchema, +) +from recap.schemas.step import ( + StepSchema, + StepTemplateRef, + StepTemplateSchema, +) + +IdentityKey = tuple[str, UUID] + + +def _loaded_relations(model: BaseModel) -> dict[str, bool]: + private = getattr(model, "__pydantic_private__", None) or {} + value = private.get("_loaded_relations", {}) + return value if isinstance(value, dict) else {} + + +class IdentityMergeConflict(RuntimeError): + """Raised when equal revisions contain conflicting scalar state.""" + + +_ENTITY_FAMILIES: tuple[tuple[type[BaseModel], str], ...] = ( + (NamespaceSchema, "namespace"), + (AttributeGroupTemplateSchema, "attribute_group_template"), + (NamespaceRef, "namespace"), + (ResourceTemplateRef, "resource_template"), + (ResourceTemplateSchema, "resource_template"), + (ResourceRef, "resource"), + (ResourceSchema, "resource"), + (ProcessTemplateRef, "process_template"), + (ProcessTemplateSchema, "process_template"), + (ProcessRunRef, "process_run"), + (ProcessRunSchema, "process_run"), + (StepTemplateRef, "step_template"), + (StepTemplateSchema, "step_template"), + (StepSchema, "step"), +) + + +class IdentityMap: + def __init__(self) -> None: + self._models: dict[IdentityKey, BaseModel] = {} + self._lock = RLock() + self._canonical_merge_pairs: set[tuple[int, int]] = set() + + def get(self, key: IdentityKey) -> BaseModel | None: + with self._lock: + return self._models.get(key) + + def canonical(self, model: BaseModel) -> BaseModel | None: + family = self._family(model) + model_id = getattr(model, "id", None) + if family is None or not isinstance(model_id, UUID): + return None + return self.get((family, model_id)) + + def intern(self, model: BaseModel, *, authoritative: bool = False) -> BaseModel: + with self._lock: + canonical = self._intern(model, authoritative=authoritative) + self._canonicalize_relations(canonical) + return canonical + + def clear(self) -> None: + with self._lock: + self._models.clear() + self._canonical_merge_pairs.clear() + + def _intern(self, model: BaseModel, *, authoritative: bool = False) -> BaseModel: + family = self._family(model) + if family is None: + return model + model_id = getattr(model, "id", None) + if not isinstance(model_id, UUID): + return model + + key = (family, model_id) + current = self._models.get(key) + if current is None: + self._models[key] = model + self._canonicalize_relations(model) + return model + if current is model: + return current + self._merge(current, model, authoritative=authoritative) + return current + + def _merge( + self, + current: BaseModel, + incoming: BaseModel, + *, + authoritative: bool = False, + ) -> None: + if ( + isinstance(current, NamespaceRef) + and not isinstance(current, NamespaceSchema) + and isinstance(incoming, NamespaceSchema) + ): + self._promote_namespace(current, incoming) + return + + current_revision = getattr(current, "revision", None) + incoming_revision = getattr(incoming, "revision", None) + if current_revision is not None and incoming_revision is not None: + if incoming_revision < current_revision: + return + if incoming_revision == current_revision: + if authoritative: + self._canonicalize_relations(incoming) + self._merge_loaded_relations(current, incoming) + self._copy_fields(current, incoming) + self._canonicalize_relations(current) + return + if self._equivalent_with_repeated_array_values(current, incoming): + self._copy_fields(current, incoming) + self._canonicalize_relations(current) + return + self._raise_on_scalar_conflict(current, incoming) + self._canonicalize_relations(incoming) + self._merge_loaded_relations(current, incoming) + return + self._canonicalize_relations(incoming) + if authoritative: + self._merge_authoritative_relations(current, incoming) + else: + self._replace_loaded_relations(current, incoming) + self._merge_relation_flags(current, incoming) + self._copy_fields(current, incoming) + self._canonicalize_relations(current) + return + + self._canonicalize_relations(incoming) + current_date = getattr(current, "modified_date", None) + incoming_date = getattr(incoming, "modified_date", None) + if ( + isinstance(current_date, datetime) + and isinstance(incoming_date, datetime) + and incoming_date > current_date + ): + self._merge_loaded_relations(current, incoming) + self._copy_fields(current, incoming) + + @staticmethod + def _promote_namespace(current: NamespaceRef, incoming: NamespaceSchema) -> None: + """Upgrade ref payload in place so existing graph references stay canonical.""" + current.__class__ = NamespaceSchema + current.__dict__.update(incoming.__dict__) + current.__pydantic_fields_set__ = set(incoming.__pydantic_fields_set__) + + @staticmethod + def _copy_fields(current: BaseModel, incoming: BaseModel) -> None: + for name in incoming.model_fields_set: + if name in type(current).model_fields: + if name in getattr(type(current), "_relation_fields", frozenset()): + continue + setattr(current, name, getattr(incoming, name)) + + @staticmethod + def _replace_loaded_relations(current: BaseModel, incoming: BaseModel) -> None: + incoming_flags = _loaded_relations(incoming) + relation_fields = getattr(type(current), "_relation_fields", frozenset()) + for name in relation_fields: + if ( + incoming_flags.get(name) is True or name in incoming.model_fields_set + ) and name in type(current).model_fields: + setattr(current, name, incoming.__dict__.get(name)) + + @staticmethod + def _merge_relation_flags(current: BaseModel, incoming: BaseModel) -> None: + current_flags = _loaded_relations(current) + incoming_flags = _loaded_relations(incoming) + if isinstance(current, LoadAware): + current.set_loaded_relations( + { + name: current_flags.get(name, False) or loaded + for name, loaded in {**current_flags, **incoming_flags}.items() + }, + on_unloaded=(getattr(current, "__pydantic_private__", {}) or {}).get( + "_on_unloaded", "warn" + ), + ) + + @classmethod + def _raise_on_scalar_conflict(cls, current: BaseModel, incoming: BaseModel) -> None: # noqa: C901 + relation_fields = getattr(type(current), "_relation_fields", frozenset()) + for name in sorted(relation_fields): + if not ( + _loaded_relations(current).get(name) is True + and _loaded_relations(incoming).get(name) is True + ): + continue + current_value = current.__dict__.get(name) + incoming_value = incoming.__dict__.get(name) + if ( + not cls._empty_relation_value(current_value) + and not cls._empty_relation_value(incoming_value) + and cls._relation_conflicts(current_value, incoming_value) + ): + raise IdentityMergeConflict( + f"Conflicting {type(current).__name__} identity {current.id} " + f"at equal revision: {name}" + ) + + for name in incoming.model_fields_set & current.model_fields_set: + if name in {"create_date", "modified_date"}: + continue + if name in relation_fields: + continue + if ( + _loaded_relations(current).get(name) is False + or _loaded_relations(incoming).get(name) is False + ): + continue + current_value = current.__dict__.get(name) + incoming_value = incoming.__dict__.get(name) + if ( + name == "status" + and isinstance(current_value, LifecycleStatus) + and isinstance(incoming_value, LifecycleStatus) + ): + continue + if isinstance(current_value, BaseModel | list | dict) or isinstance( + incoming_value, BaseModel | list | dict + ): + current_loaded = _loaded_relations(current).get(name) is True + incoming_loaded = _loaded_relations(incoming).get(name) is True + if ( + current_loaded + and incoming_loaded + and cls._relation_signature(current_value) + != cls._relation_signature(incoming_value) + ): + raise IdentityMergeConflict( + f"Conflicting {type(current).__name__} identity {current.id} " + f"at equal revision: {name}" + ) + continue + if current_value != incoming_value: + raise IdentityMergeConflict( + f"Conflicting {type(current).__name__} identity {current.id} " + f"at equal revision: {name}" + ) + + @classmethod + def _relation_signature( + cls, value: Any, _seen: set[tuple[str, UUID]] | None = None + ) -> Any: + if _seen is None: + _seen = set() + if isinstance(value, BaseModel): + family = cls._family(value) + model_id = getattr(value, "id", None) + if family is not None and isinstance(model_id, UUID): + identity = (family, model_id) + if identity in _seen: + return ("cycle", identity) + _seen.add(identity) + relation_fields = getattr(type(value), "_relation_fields", frozenset()) + scalar_state = tuple( + (name, cls._relation_signature(value.__dict__.get(name), _seen)) + for name in sorted(value.model_fields_set - relation_fields) + if name not in {"create_date", "modified_date"} + ) + relation_state = tuple( + (name, cls._relation_signature(value.__dict__.get(name), _seen)) + for name in sorted(relation_fields) + if _loaded_relations(value).get(name) is True + ) + _seen.remove(identity) + return family, model_id, scalar_state, relation_state + return tuple( + (name, cls._relation_signature(getattr(value, name), _seen)) + for name in value.model_fields_set + ) + if isinstance(value, dict): + return tuple( + sorted( + (key, cls._relation_signature(item, _seen)) + for key, item in value.items() + ) + ) + if isinstance(value, list): + return tuple(cls._relation_signature(item, _seen) for item in value) + return value + + @classmethod + def _relation_conflicts( # noqa: C901 + cls, + current: Any, + incoming: Any, + _seen: set[tuple[int, int]] | None = None, + ) -> bool: # noqa: C901 + """Compare only common loaded relation data, not projection shape.""" + if _seen is None: + _seen = set() + if isinstance(current, BaseModel) and isinstance(incoming, BaseModel): + current_id = getattr(current, "id", None) + incoming_id = getattr(incoming, "id", None) + if current_id != incoming_id: + return current != incoming + current_revision = getattr(current, "revision", None) + incoming_revision = getattr(incoming, "revision", None) + if ( + current_revision is not None + and incoming_revision is not None + and current_revision != incoming_revision + ): + return False + pair = (id(current), id(incoming)) + if pair in _seen: + return False + _seen.add(pair) + relation_fields = getattr(type(current), "_relation_fields", frozenset()) + for name in ( + current.model_fields_set & incoming.model_fields_set - relation_fields + ): + if name in {"create_date", "modified_date", "status"}: + continue + left = current.__dict__.get(name) + right = incoming.__dict__.get(name) + if isinstance(left, BaseModel | list | dict) or isinstance( + right, BaseModel | list | dict + ): + if cls._relation_conflicts(left, right, _seen): + return True + elif left != right: + return True + for name in relation_fields: + if ( + _loaded_relations(current).get(name) is True + and _loaded_relations(incoming).get(name) is True + and cls._relation_conflicts( + current.__dict__.get(name), incoming.__dict__.get(name), _seen + ) + ): + return True + return False + if isinstance(current, dict) and isinstance(incoming, dict): + if not (set(current).issubset(incoming) or set(incoming).issubset(current)): + return True + return any( + key in incoming + and key in current + and cls._relation_conflicts(current[key], incoming[key], _seen) + for key in current.keys() & incoming.keys() + ) + if isinstance(current, list) and isinstance(incoming, list): + if not current or not incoming: + return False + current_by_key = {cls._value_key(item): item for item in current} + incoming_by_key = {cls._value_key(item): item for item in incoming} + if not ( + set(current_by_key).issubset(incoming_by_key) + or set(incoming_by_key).issubset(current_by_key) + ): + return True + return any( + cls._relation_conflicts( + current_by_key[key], incoming_by_key[key], _seen + ) + for key in current_by_key.keys() & incoming_by_key.keys() + ) + return current != incoming + + @classmethod + def _value_key(cls, value: Any) -> Any: + if isinstance(value, BaseModel): + model_id = getattr(value, "id", None) + if isinstance(model_id, UUID): + return cls._family(value), model_id + return cls._relation_signature(value) + + @classmethod + def _equivalent_with_repeated_array_values( + cls, current: Any, incoming: Any + ) -> bool: + """Recognize stale hydrated array wrappers without hiding real conflicts.""" + left = cls._relation_signature(current) + right = cls._relation_signature(incoming) + if left == right: + return True + return cls._dedupe_repeated_sequences(left) == cls._dedupe_repeated_sequences( + right + ) + + @classmethod + def _dedupe_repeated_sequences(cls, value: Any) -> Any: + if isinstance(value, tuple): + items = tuple(cls._dedupe_repeated_sequences(item) for item in value) + for size in range(1, len(items) // 2 + 1): + if len(items) % size == 0 and items == items[:size] * ( + len(items) // size + ): + return items[:size] + return items + return value + + @staticmethod + def _empty_relation_value(value: Any) -> bool: + return value is None or value == {} or value == [] + + @classmethod + def _relation_is_extension(cls, base: Any, candidate: Any) -> bool: + if isinstance(base, BaseModel) and isinstance(candidate, BaseModel): + return cls._model_is_compatible_extension(base, candidate) + if isinstance(base, dict) and isinstance(candidate, dict): + if not set(base).issubset(candidate): + return False + return all( + cls._model_is_compatible_extension(base[key], candidate[key]) + for key in base + ) + if isinstance(base, list) and isinstance(candidate, list): + candidate_by_id = { + (cls._family(item), getattr(item, "id", None)): item + for item in candidate + if isinstance(item, BaseModel) + } + for item in base: + if not isinstance(item, BaseModel): + if item not in candidate: + return False + continue + match = candidate_by_id.get( + (cls._family(item), getattr(item, "id", None)) + ) + if match is None or not cls._model_is_compatible_extension(item, match): + return False + return True + return False + + @classmethod + def _model_is_compatible_extension(cls, base: Any, candidate: Any) -> bool: # noqa: C901 + if not isinstance(base, BaseModel) or not isinstance(candidate, BaseModel): + return base == candidate + if type(base) is not type(candidate): + return False + relation_fields = getattr(type(base), "_relation_fields", frozenset()) + if cls._family(base) is None: + for name in base.model_fields_set & candidate.model_fields_set: + left = base.__dict__.get(name) + right = candidate.__dict__.get(name) + if isinstance(left, BaseModel) and isinstance(right, BaseModel): + if not ( + cls._model_is_compatible_extension(left, right) + or cls._model_is_compatible_extension(right, left) + ): + return False + elif isinstance(left, list | dict) or isinstance(right, list | dict): + if not ( + cls._relation_is_extension(left, right) + or cls._relation_is_extension(right, left) + ): + return False + elif left != right: + return False + return True + if getattr(base, "id", None) != getattr(candidate, "id", None): + return False + for name in ( + base.model_fields_set & candidate.model_fields_set - relation_fields + ): + if name in {"create_date", "modified_date"}: + continue + left = base.__dict__.get(name) + right = candidate.__dict__.get(name) + if isinstance(left, BaseModel | list | dict) or isinstance( + right, BaseModel | list | dict + ): + if cls._relation_signature(left) != cls._relation_signature(right): + return False + elif name == "status": + continue + elif left != right: + return False + for name in relation_fields: + if ( + _loaded_relations(base).get(name) is True + and _loaded_relations(candidate).get(name) is True + ): + left = base.__dict__.get(name) + right = candidate.__dict__.get(name) + if ( + not cls._empty_relation_value(left) + and not cls._empty_relation_value(right) + and not cls._relation_is_extension(left, right) + and not cls._relation_is_extension(right, left) + ): + return False + return True + + def _canonicalize_relations(self, model: BaseModel) -> None: + if not isinstance(model, BaseModel): + return + for name in model.model_fields_set: + if _loaded_relations(model).get(name) is False: + continue + value = model.__dict__.get(name) + canonical = self._canonicalize_value(value) + if canonical is not value: + setattr(model, name, canonical) + + def _merge_loaded_relations(self, current: BaseModel, incoming: BaseModel) -> None: + current_flags = _loaded_relations(current) + incoming_flags = _loaded_relations(incoming) + if not current_flags and not incoming_flags: + return + merged_flags = dict(current_flags) + for relation, loaded in incoming_flags.items(): + merged_flags[relation] = merged_flags.get(relation, False) or loaded + if loaded and relation in type(current).model_fields: + value = self._canonicalize_value(incoming.__dict__.get(relation)) + existing = current.__dict__.get(relation) + if ( + value is not None or existing is None + ) and not self._merge_container(existing, value): + setattr(current, relation, value) + if isinstance(current, LoadAware): + current.set_loaded_relations( + merged_flags, + on_unloaded=(getattr(current, "__pydantic_private__", {}) or {}).get( + "_on_unloaded", "warn" + ), + ) + + def _merge_authoritative_relations( + self, current: BaseModel, incoming: BaseModel + ) -> None: + """Apply command responses even when transport omits load metadata.""" + self._merge_loaded_relations(current, incoming) + for name in getattr(type(current), "_relation_fields", frozenset()): + if name in incoming.model_fields_set and name in type(current).model_fields: + setattr( + current, name, self._canonicalize_value(incoming.__dict__.get(name)) + ) + + def _merge_container( # noqa: C901 + self, + current: Any, + incoming: Any, + *, + _seen: set[tuple[int, int]] | None = None, + ) -> bool: + if _seen is None: + _seen = set() + if isinstance(current, BaseModel | list | dict) and isinstance( + incoming, BaseModel | list | dict + ): + pair = (id(current), id(incoming)) + if pair in _seen: + return True + _seen.add(pair) + if isinstance(current, dict) and isinstance(incoming, dict): + for key, value in incoming.items(): + existing = current.get(key) + if not self._merge_container(existing, value, _seen=_seen): + current[key] = value + return True + if isinstance(current, list) and isinstance(incoming, list): + existing = {self._value_key(item): item for item in current} + for item in incoming: + key = self._value_key(item) + if key not in existing: + current.append(item) + existing[key] = item + else: + self._merge_container(existing[key], item, _seen=_seen) + return True + if isinstance(current, BaseModel) and isinstance(incoming, BaseModel): + relation_fields = getattr(type(current), "_relation_fields", frozenset()) + for name in incoming.model_fields_set: + if name not in type(current).model_fields: + continue + value = incoming.__dict__.get(name) + if name in relation_fields: + existing = current.__dict__.get(name) + if not self._merge_container(existing, value, _seen=_seen): + setattr(current, name, self._canonicalize_value(value)) + else: + setattr(current, name, self._canonicalize_value(value)) + return True + return False + + def _canonicalize_value(self, value: Any) -> Any: + if isinstance(value, BaseModel): + family = self._family(value) + model_id = getattr(value, "id", None) + if family is not None and isinstance(model_id, UUID): + existing = self._models.get((family, model_id)) + if existing is not None: + pair = (id(existing), id(value)) + if pair not in self._canonical_merge_pairs: + self._canonical_merge_pairs.add(pair) + try: + self._merge_loaded_relations(existing, value) + finally: + self._canonical_merge_pairs.remove(pair) + return existing + self._canonicalize_relations(value) + return self._intern(value) + if isinstance(value, list): + return [self._canonicalize_value(item) for item in value] + if isinstance(value, dict): + return {key: self._canonicalize_value(item) for key, item in value.items()} + return value + + @staticmethod + def _family(model: BaseModel) -> str | None: + if isinstance(model, NamespaceContext): + return None + for model_type, family in _ENTITY_FAMILIES: + if isinstance(model, model_type): + return family + return None diff --git a/recap/client/permissions.py b/recap/client/permissions.py new file mode 100644 index 0000000..7cff988 --- /dev/null +++ b/recap/client/permissions.py @@ -0,0 +1,45 @@ +from enum import Enum + +from pydantic import BaseModel, ConfigDict + +from recap.authentication.models import ProviderIdentity +from recap.authorization.scopes import Scope +from recap.authorization.snapshot import GrantProvenance + + +class DenialCode(str, Enum): + INSUFFICIENT_SCOPE = "insufficient_scope" + INVALID_TARGET_RELATIONSHIP = "invalid_target_relationship" + SOURCE_READ_REQUIRED = "source_read_required" + DESTINATION_WRITE_REQUIRED = "destination_write_required" + + +class EffectivePermissions(BaseModel): + model_config = ConfigDict(frozen=True) + + identities: tuple[ProviderIdentity, ...] + snapshot_generation: str | None + effective_scopes: frozenset[Scope] + matched_namespace_paths: tuple[str, ...] + grants: tuple[GrantProvenance, ...] + + +class ActorPermissions(BaseModel): + """Current actor's effective permissions returned by remote APIs.""" + + model_config = ConfigDict(frozen=True) + + identities: tuple[ProviderIdentity, ...] + snapshot_generation: str | None + effective_scopes: frozenset[Scope] + matched_namespace_paths: tuple[str, ...] + groups: tuple[str, ...] + roles: tuple[str, ...] + + +class PermissionDecision(BaseModel): + model_config = ConfigDict(frozen=True) + + allowed: bool + denial_code: DenialCode | None = None + permissions: EffectivePermissions | None = None diff --git a/recap/commands/__init__.py b/recap/commands/__init__.py new file mode 100644 index 0000000..f5624dd --- /dev/null +++ b/recap/commands/__init__.py @@ -0,0 +1,17 @@ +from recap.commands.errors import ( + CommandConflictError, + CommandError, + CommandNotFoundError, + CommandValidationError, +) +from recap.commands.models import CommandContext, CommandModel, CreateResource + +__all__ = [ + "CommandConflictError", + "CommandContext", + "CommandError", + "CommandModel", + "CommandNotFoundError", + "CommandValidationError", + "CreateResource", +] diff --git a/recap/commands/audit.py b/recap/commands/audit.py new file mode 100644 index 0000000..9db41b8 --- /dev/null +++ b/recap/commands/audit.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from sqlalchemy.orm import sessionmaker + +from recap.db.audit import MutationAuditRepository +from recap.server.audit import AuditRecord + + +class DurableAuditSink: + def __init__(self, repository: MutationAuditRepository) -> None: + self._repository = repository + + def emit(self, record: AuditRecord) -> None: + self._repository.add(record) + + +def record_failure_after_rollback( + session_factory: sessionmaker, record: AuditRecord +) -> None: + """Persist failure metadata after caller's mutation transaction rolled back.""" + with session_factory.begin() as session: + DurableAuditSink(MutationAuditRepository(session)).emit(record) diff --git a/recap/commands/context.py b/recap/commands/context.py new file mode 100644 index 0000000..2c51e90 --- /dev/null +++ b/recap/commands/context.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from uuid import uuid4 + +from recap.authentication.actors import single_user_actor +from recap.authorization.policy import UnrestrictedNamespacePolicy +from recap.commands.models import CommandContext +from recap.server.audit import AuditRecord, AuditSink + + +class DiscardAuditSink: + def emit(self, record: AuditRecord) -> None: + pass + + +def build_local_command_context( + *, + audit_sink: AuditSink | None = None, + request_id: str | None = None, + idempotency_key: str | None = None, +) -> CommandContext: + return CommandContext( + actor=single_user_actor(credential_fingerprint="local-single-user"), + request_id=request_id or str(uuid4()), + policy=UnrestrictedNamespacePolicy(), + audit_sink=audit_sink or DiscardAuditSink(), + authorization_generation=None, + idempotency_key=idempotency_key, + ) + + +__all__ = ["DiscardAuditSink", "build_local_command_context"] diff --git a/recap/commands/errors.py b/recap/commands/errors.py new file mode 100644 index 0000000..8534f19 --- /dev/null +++ b/recap/commands/errors.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import ClassVar + +from recap.exceptions import ( + RecapConflictError, + RecapNotFoundError, + RecapRequestError, + RecapValidationError, +) + + +class CommandError(RecapRequestError): + """Base class for safe, transport-independent command failures.""" + + code: ClassVar[str] + + +class CommandNotFoundError(RecapNotFoundError, CommandError): + code = "not_found" + + +class CommandConflictError(RecapConflictError, CommandError): + code = "conflict" + + +class CommandValidationError(RecapValidationError, CommandError): + code = "validation_error" + + def __init__(self, message: str, *, public_message: str | None = None) -> None: + super().__init__(message) + self.public_message = public_message diff --git a/recap/commands/idempotency.py b/recap/commands/idempotency.py new file mode 100644 index 0000000..6f59439 --- /dev/null +++ b/recap/commands/idempotency.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import hashlib +import json +from uuid import UUID + +from pydantic import BaseModel + + +def command_fingerprint( + *, + method: str, + route_template: str, + namespace_path: str | None, + source_id: UUID | None, + body: BaseModel, +) -> str: + payload = { + "method": method, + "route": route_template, + "namespace": namespace_path, + "source_id": str(source_id) if source_id else None, + "body": body.model_dump(mode="json", exclude_none=False), + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() diff --git a/recap/commands/models.py b/recap/commands/models.py new file mode 100644 index 0000000..fef9a6c --- /dev/null +++ b/recap/commands/models.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +from recap.authentication.models import RequestActor +from recap.authorization.query import NamespacePolicy +from recap.dsl.drafts import ( + ProcessRunDraft, + ProcessTemplateDraft, + ResourceTemplateDraft, +) +from recap.lifecycle import LifecycleStatus +from recap.schemas.process import ProcessRunCopyOptions +from recap.schemas.resource import ResourceCopyOptions +from recap.server.audit import AuditSink + + +class CommandModel(BaseModel): + """Closed, immutable base for client-controlled command payloads.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +class CreateResource(CommandModel): + id: UUID | None = None + namespace_path: str + name: str + template_id: UUID + parent_id: UUID | None = None + properties: dict[str, dict[str, object]] | None = None + + +class UpdateResource(CommandModel): + resource_id: UUID + expected_revision: int + name: str | None = None + properties: dict[str, dict[str, object]] | None = None + + +class CreateNamespace(CommandModel): + path: str + metadata: dict[str, Any] | None = None + + +class GetNamespace(CommandModel): + path: str + + +class UpdateNamespace(CommandModel): + namespace_id: UUID + expected_revision: int + metadata: dict[str, Any] | None = None + status: LifecycleStatus | None = None + + +class CopyResource(CommandModel): + source_resource_id: UUID + destination_namespace_path: str + options: ResourceCopyOptions = ResourceCopyOptions() + + +class CreateProcessTemplate(CommandModel): + namespace_path: str + draft: ProcessTemplateDraft + + +class UpdateProcessTemplate(CommandModel): + template_id: UUID + expected_revision: int + draft: ProcessTemplateDraft + + +class CreateResourceTemplate(CommandModel): + namespace_path: str + draft: ResourceTemplateDraft + + +class UpdateResourceTemplate(CommandModel): + template_id: UUID + expected_revision: int + draft: ResourceTemplateDraft + + +class CreateProcessRun(CommandModel): + namespace_path: str + draft: ProcessRunDraft + + +class UpdateProcessRun(CommandModel): + process_run_id: UUID + expected_revision: int + description: str | None = None + status: str | None = None + assignments: dict[str, UUID] | None = None + steps: dict[str, dict[str, dict[str, object]]] | None = None + + +class CopyProcessRun(CommandModel): + source_process_run_id: UUID + destination_namespace_path: str + options: ProcessRunCopyOptions = ProcessRunCopyOptions() + + +class SetLifecycleStatus(CommandModel): + object_type: str + object_id: UUID + expected_revision: int + status: str + + +@dataclass(frozen=True, slots=True) +class CommandContext: + actor: RequestActor + request_id: str + policy: NamespacePolicy + audit_sink: AuditSink + authorization_generation: str | None + idempotency_key: str | None = None diff --git a/recap/commands/registry.py b/recap/commands/registry.py new file mode 100644 index 0000000..9fe43f1 --- /dev/null +++ b/recap/commands/registry.py @@ -0,0 +1,655 @@ +from __future__ import annotations + +import re +from collections.abc import Callable, Collection, Mapping +from dataclasses import dataclass +from typing import Any, Literal +from uuid import UUID + +from pydantic import BaseModel + +from recap.adapter.transport import _prepare_dynamic_models +from recap.commands.errors import CommandValidationError +from recap.commands.models import ( + CommandModel, + CopyProcessRun, + CopyResource, + CreateNamespace, + CreateProcessRun, + CreateProcessTemplate, + CreateResource, + CreateResourceTemplate, + SetLifecycleStatus, + UpdateNamespace, + UpdateProcessRun, + UpdateProcessTemplate, + UpdateResource, + UpdateResourceTemplate, +) +from recap.dsl.drafts import ( + ProcessRunDraft, + ProcessTemplateDraft, + ResourceTemplateDraft, +) +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext, NamespaceSchema +from recap.schemas.process import ProcessRunSchema, ProcessTemplateSchema +from recap.schemas.resource import ( + ResourceCopyOptions, + ResourceSchema, + ResourceTemplateSchema, +) +from recap.server.rest_models import ( + CopyProcessRunRequest, + CopyResourceRequest, + CreateNamespaceRequest, + CreateResourceRequest, + SetLifecycleStatusRequest, + UpdateNamespaceRequest, + UpdateProcessRunRequest, + UpdateResourceRequest, +) + +Method = Literal["POST", "PATCH", "PUT"] +_IF_MATCH = re.compile(r'(?:W/)?"?([1-9][0-9]*)"?') + + +def _body_data(body: BaseModel | Mapping[str, Any]) -> dict[str, Any]: + return body.model_dump() if isinstance(body, BaseModel) else dict(body) + + +def _expected_revision(headers: Mapping[str, Any]) -> int: + if_match = headers.get("If-Match") + if not isinstance(if_match, str): + raise CommandValidationError("Missing If-Match header") + match = _IF_MATCH.fullmatch(if_match.strip()) + if match is None: + raise CommandValidationError("Invalid If-Match header") + return int(match.group(1)) + + +def _path_uuid(path_params: Mapping[str, Any], name: str) -> UUID: + return UUID(str(path_params[name])) + + +def _revision_etag(revision: int) -> str: + return f'"{revision}"' + + +def _draft(body: BaseModel, schema: type[BaseModel]) -> BaseModel: + return body if isinstance(body, schema) else schema.model_validate(body) + + +@dataclass(frozen=True, slots=True) +class EncodedRequest: + method: Method + path: str + body: dict[str, Any] | None + etag: str | None + + +@dataclass(frozen=True, slots=True) +class CommandRegistration: + command_type: type[CommandModel] + name: str + service_handler: str + method: Method + route_template: str + request_model: type[BaseModel] | None + encode_request: Callable[[CommandModel], EncodedRequest] + decode_command: Callable[ + [Mapping[str, Any], Mapping[str, Any], BaseModel], CommandModel + ] + decode_response: Callable[..., Any] + + +class CommandRegistry: + def __init__(self, registrations: Collection[CommandRegistration]) -> None: + registrations = tuple(registrations) + command_types = [registration.command_type for registration in registrations] + names = [registration.name for registration in registrations] + if len(set(command_types)) != len(command_types): + raise ValueError("Duplicate command registration") + if len(set(names)) != len(names): + raise ValueError("Duplicate command registration name") + self._registrations = registrations + self._by_type = {registration.command_type: registration for registration in registrations} + self._by_name = {registration.name: registration for registration in registrations} + + def by_type(self, command_type: type[CommandModel]) -> CommandRegistration: + try: + return self._by_type[command_type] + except KeyError as exc: + raise CommandValidationError( + f"No command registration for {command_type.__name__}" + ) from exc + + def by_command(self, command: CommandModel) -> CommandRegistration: + return self.by_type(type(command)) + + def by_name(self, name: str) -> CommandRegistration: + try: + return self._by_name[name] + except KeyError as exc: + raise CommandValidationError(f"No command registration named {name}") from exc + + def all(self) -> tuple[CommandRegistration, ...]: + return self._registrations + + def validate_complete(self, command_types: Collection[type[CommandModel]]) -> None: + expected = set(command_types) + registered = set(self._by_type) + missing = expected - registered + unexpected = registered - expected + if missing or unexpected: + details = [] + if missing: + names = ", ".join(sorted(command_type.__name__ for command_type in missing)) + details.append(f"Missing command registrations: {names}") + if unexpected: + names = ", ".join( + sorted(command_type.__name__ for command_type in unexpected) + ) + details.append(f"Unexpected command registrations: {names}") + raise CommandValidationError("; ".join(details)) + + +def _encode_update_resource(command: CommandModel) -> EncodedRequest: + assert isinstance(command, UpdateResource) + data = command.model_dump(mode="json") + return EncodedRequest( + method="PATCH", + path=f"/api/v1/resources/{command.resource_id}", + body={ + key: value + for key, value in data.items() + if key not in {"resource_id", "expected_revision"} + }, + etag=_revision_etag(command.expected_revision), + ) + + +def _decode_update_resource( + path_params: Mapping[str, Any], + headers: Mapping[str, Any], + body: BaseModel, +) -> UpdateResource: + data = _body_data(body) + return UpdateResource( + resource_id=_path_uuid(path_params, "resource_id"), + expected_revision=_expected_revision(headers), + **data, + ) + + +def _decode_resource_response(entity: Any, etag: str | None = None, **_: Any) -> ResourceSchema: + if isinstance(entity, dict): + _prepare_dynamic_models(entity) + return ResourceSchema.model_validate(entity) + + +def _encode_create_resource(command: CommandModel) -> EncodedRequest: + assert isinstance(command, CreateResource) + data = command.model_dump(mode="json") + data.pop("namespace_path") + if data["id"] is None: + data.pop("id") + return EncodedRequest( + method="POST", + path=f"/api/v1/resources/{command.namespace_path.strip('/')}", + body=data, + etag=None, + ) + + +def _decode_create_resource( + path_params: Mapping[str, Any], headers: Mapping[str, Any], body: BaseModel +) -> CreateResource: + data = _body_data(body) + data["template_id"] = UUID(str(data["template_id"])) + if data.get("parent_id") is not None: + data["parent_id"] = UUID(str(data["parent_id"])) + return CreateResource(namespace_path=str(path_params["namespace_path"]), **data) + + +def _encode_copy_resource(command: CommandModel) -> EncodedRequest: + assert isinstance(command, CopyResource) + body = { + "destination_namespace": command.destination_namespace_path.strip("/"), + **command.options.model_dump(mode="json"), + } + return EncodedRequest( + method="POST", + path=f"/api/v1/resources/{command.source_resource_id}/copies", + body=body, + etag=None, + ) + + +def _decode_copy_resource( + path_params: Mapping[str, Any], headers: Mapping[str, Any], body: BaseModel +) -> CopyResource: + data = _body_data(body) + destination = data.pop("destination_namespace") + return CopyResource( + source_resource_id=_path_uuid(path_params, "source_resource_id"), + destination_namespace_path=destination, + options=ResourceCopyOptions.model_validate(data), + ) + + +def _encode_copy_process_run(command: CommandModel) -> EncodedRequest: + assert isinstance(command, CopyProcessRun) + return EncodedRequest( + method="POST", + path=f"/api/v1/process-runs/{command.source_process_run_id}/copies", + body={ + "destination_namespace": command.destination_namespace_path, + **command.options.model_dump(mode="json"), + }, + etag=None, + ) + + +def _decode_copy_process_run( + path_params: Mapping[str, Any], headers: Mapping[str, Any], body: BaseModel +) -> CopyProcessRun: + data = _body_data(body) + destination = data.pop("destination_namespace") + return CopyProcessRun( + source_process_run_id=_path_uuid(path_params, "source_process_run_id"), + destination_namespace_path=destination, + options=data, + ) + + +def _encode_create_resource_template(command: CommandModel) -> EncodedRequest: + assert isinstance(command, CreateResourceTemplate) + body = command.draft.model_dump(mode="json") + if body["id"] is None: + body.pop("id") + return EncodedRequest( + method="POST", + path=f"/api/v1/resource-templates/{command.namespace_path.strip('/')}", + body=body, + etag=None, + ) + + +def _decode_create_resource_template( + path_params: Mapping[str, Any], headers: Mapping[str, Any], body: BaseModel +) -> CreateResourceTemplate: + draft = _draft(body, ResourceTemplateDraft) + return CreateResourceTemplate( + namespace_path=str(path_params["namespace_path"]), draft=draft + ) + + +def _encode_update_resource_template(command: CommandModel) -> EncodedRequest: + assert isinstance(command, UpdateResourceTemplate) + return EncodedRequest( + method="PATCH", + path=f"/api/v1/resource-templates/{command.template_id}", + body=command.draft.model_dump(mode="json"), + etag=_revision_etag(command.expected_revision), + ) + + +def _decode_update_resource_template( + path_params: Mapping[str, Any], headers: Mapping[str, Any], body: BaseModel +) -> UpdateResourceTemplate: + draft = _draft(body, ResourceTemplateDraft) + return UpdateResourceTemplate( + template_id=_path_uuid(path_params, "template_id"), + expected_revision=_expected_revision(headers), + draft=draft, + ) + + +def _decode_resource_template_response(entity: Any, etag: str | None = None, **_: Any) -> ResourceTemplateSchema: + return ResourceTemplateSchema.model_validate(entity) + + +def _encode_create_process_template(command: CommandModel) -> EncodedRequest: + assert isinstance(command, CreateProcessTemplate) + body = command.draft.model_dump(mode="json") + if body["id"] is None: + body.pop("id") + return EncodedRequest( + method="POST", + path=f"/api/v1/process-templates/{command.namespace_path.strip('/')}", + body=body, + etag=None, + ) + + +def _decode_create_process_template( + path_params: Mapping[str, Any], headers: Mapping[str, Any], body: BaseModel +) -> CreateProcessTemplate: + draft = _draft(body, ProcessTemplateDraft) + return CreateProcessTemplate(namespace_path=str(path_params["namespace_path"]), draft=draft) + + +def _encode_update_process_template(command: CommandModel) -> EncodedRequest: + assert isinstance(command, UpdateProcessTemplate) + return EncodedRequest( + method="PATCH", + path=f"/api/v1/process-templates/{command.template_id}", + body=command.draft.model_dump(mode="json"), + etag=_revision_etag(command.expected_revision), + ) + + +def _decode_update_process_template( + path_params: Mapping[str, Any], headers: Mapping[str, Any], body: BaseModel +) -> UpdateProcessTemplate: + draft = _draft(body, ProcessTemplateDraft) + return UpdateProcessTemplate( + template_id=_path_uuid(path_params, "template_id"), + expected_revision=_expected_revision(headers), + draft=draft, + ) + + +def _encode_create_process_run(command: CommandModel) -> EncodedRequest: + assert isinstance(command, CreateProcessRun) + body = command.draft.model_dump(mode="json") + if body["id"] is None: + body.pop("id") + return EncodedRequest( + method="POST", + path=f"/api/v1/process-runs/{command.namespace_path.strip('/')}", + body=body, + etag=None, + ) + + +def _decode_create_process_run( + path_params: Mapping[str, Any], headers: Mapping[str, Any], body: BaseModel +) -> CreateProcessRun: + draft = _draft(body, ProcessRunDraft) + return CreateProcessRun(namespace_path=str(path_params["namespace_path"]), draft=draft) + + +def _encode_update_process_run(command: CommandModel) -> EncodedRequest: + assert isinstance(command, UpdateProcessRun) + data = command.model_dump(mode="json") + data.pop("process_run_id") + data.pop("expected_revision") + return EncodedRequest( + method="PATCH", + path=f"/api/v1/process-runs/{command.process_run_id}", + body=data, + etag=_revision_etag(command.expected_revision), + ) + + +def _decode_update_process_run( + path_params: Mapping[str, Any], headers: Mapping[str, Any], body: BaseModel +) -> UpdateProcessRun: + data = _body_data(body) + return UpdateProcessRun( + process_run_id=_path_uuid(path_params, "process_run_id"), + expected_revision=_expected_revision(headers), + **data, + ) + + +def _decode_process_template_response(entity: Any, etag: str | None = None, **_: Any) -> ProcessTemplateSchema: + return ProcessTemplateSchema.model_validate(entity) + + +def _decode_process_run_response(entity: Any, etag: str | None = None, **_: Any) -> ProcessRunSchema: + return ProcessRunSchema.model_validate(entity) + + +def _encode_create_namespace(command: CommandModel) -> EncodedRequest: + assert isinstance(command, CreateNamespace) + return EncodedRequest( + method="PUT", + path=f"/api/v1/namespaces/{command.path.strip('/')}", + body={"metadata": command.metadata or {}}, + etag=None, + ) + + +def _decode_create_namespace( + path_params: Mapping[str, Any], + headers: Mapping[str, Any], + body: BaseModel, +) -> CreateNamespace: + data = _body_data(body) + return CreateNamespace(path=str(path_params["namespace_path"]), **data) + + +def _encode_update_namespace(command: CommandModel) -> EncodedRequest: + assert isinstance(command, UpdateNamespace) + data = command.model_dump(mode="json") + return EncodedRequest( + method="PATCH", + path=f"/api/v1/namespaces/{command.namespace_id}", + body={ + key: value + for key, value in data.items() + if key not in {"namespace_id", "expected_revision"} and value is not None + }, + etag=_revision_etag(command.expected_revision), + ) + + +def _decode_update_namespace( + path_params: Mapping[str, Any], + headers: Mapping[str, Any], + body: BaseModel, +) -> UpdateNamespace: + data = _body_data(body) + return UpdateNamespace( + namespace_id=_path_uuid(path_params, "namespace_id"), + expected_revision=_expected_revision(headers), + **data, + ) + + +def _decode_namespace_response( + entity: Any, etag: str | None = None, **_: Any +) -> NamespaceContext: + namespace = NamespaceSchema.model_validate(entity) + return NamespaceContext( + id=namespace.id, + path=namespace.path, + metadata=namespace.metadata, + status=namespace.status, + revision=namespace.revision, + etag=etag, + ) + + +def _encode_lifecycle_status(command: CommandModel) -> EncodedRequest: + assert isinstance(command, SetLifecycleStatus) + return EncodedRequest( + method="POST", + path=f"/api/v1/lifecycle/{command.object_type}/{command.object_id}", + body={"status": command.status}, + etag=_revision_etag(command.expected_revision), + ) + + +def _decode_lifecycle_status( + path_params: Mapping[str, Any], + headers: Mapping[str, Any], + body: BaseModel, +) -> SetLifecycleStatus: + data = _body_data(body) + return SetLifecycleStatus( + object_type=str(path_params["object_type"]), + object_id=_path_uuid(path_params, "object_id"), + expected_revision=_expected_revision(headers), + status=data["status"].value if isinstance(data["status"], LifecycleStatus) else data["status"], + ) + + +def _decode_lifecycle_response( + entity: Any, etag: str | None = None, *, command: CommandModel +) -> Any: + assert isinstance(command, SetLifecycleStatus) + schemas = { + "resource": ResourceSchema, + "resource_template": ResourceTemplateSchema, + "process_template": ProcessTemplateSchema, + "process_run": ProcessRunSchema, + } + schema = schemas.get(command.object_type) + if schema is None: + raise TypeError(f"Unsupported lifecycle object: {command.object_type}") + if isinstance(entity, dict) and schema is ResourceSchema: + _prepare_dynamic_models(entity) + return schema.model_validate(entity) + + +COMMAND_REGISTRY = CommandRegistry( + ( + CommandRegistration( + command_type=CreateResource, + name="create_resource", + service_handler="create_resource", + method="POST", + route_template="/api/v1/resources/{namespace_path:path}", + request_model=CreateResourceRequest, + encode_request=_encode_create_resource, + decode_command=_decode_create_resource, + decode_response=_decode_resource_response, + ), + CommandRegistration( + command_type=UpdateResource, + name="update_resource", + service_handler="update_resource", + method="PATCH", + route_template="/api/v1/resources/{resource_id}", + request_model=UpdateResourceRequest, + encode_request=_encode_update_resource, + decode_command=_decode_update_resource, + decode_response=_decode_resource_response, + ), + CommandRegistration( + command_type=CopyResource, + name="copy_resource", + service_handler="copy_resource", + method="POST", + route_template="/api/v1/resources/{source_resource_id}/copies", + request_model=CopyResourceRequest, + encode_request=_encode_copy_resource, + decode_command=_decode_copy_resource, + decode_response=_decode_resource_response, + ), + CommandRegistration( + command_type=CopyProcessRun, + name="copy_process_run", + service_handler="copy_process_run", + method="POST", + route_template="/api/v1/process-runs/{source_process_run_id}/copies", + request_model=CopyProcessRunRequest, + encode_request=_encode_copy_process_run, + decode_command=_decode_copy_process_run, + decode_response=_decode_process_run_response, + ), + CommandRegistration( + command_type=CreateResourceTemplate, + name="create_resource_template", + service_handler="create_resource_template", + method="POST", + route_template="/api/v1/resource-templates/{namespace_path:path}", + request_model=ResourceTemplateDraft, + encode_request=_encode_create_resource_template, + decode_command=_decode_create_resource_template, + decode_response=_decode_resource_template_response, + ), + CommandRegistration( + command_type=UpdateResourceTemplate, + name="update_resource_template", + service_handler="update_resource_template", + method="PATCH", + route_template="/api/v1/resource-templates/{template_id}", + request_model=ResourceTemplateDraft, + encode_request=_encode_update_resource_template, + decode_command=_decode_update_resource_template, + decode_response=_decode_resource_template_response, + ), + CommandRegistration( + command_type=CreateProcessTemplate, + name="create_process_template", + service_handler="create_process_template", + method="POST", + route_template="/api/v1/process-templates/{namespace_path:path}", + request_model=ProcessTemplateDraft, + encode_request=_encode_create_process_template, + decode_command=_decode_create_process_template, + decode_response=_decode_process_template_response, + ), + CommandRegistration( + command_type=UpdateProcessTemplate, + name="update_process_template", + service_handler="update_process_template", + method="PATCH", + route_template="/api/v1/process-templates/{template_id}", + request_model=ProcessTemplateDraft, + encode_request=_encode_update_process_template, + decode_command=_decode_update_process_template, + decode_response=_decode_process_template_response, + ), + CommandRegistration( + command_type=CreateProcessRun, + name="create_process_run", + service_handler="create_process_run", + method="POST", + route_template="/api/v1/process-runs/{namespace_path:path}", + request_model=ProcessRunDraft, + encode_request=_encode_create_process_run, + decode_command=_decode_create_process_run, + decode_response=_decode_process_run_response, + ), + CommandRegistration( + command_type=UpdateProcessRun, + name="update_process_run", + service_handler="update_process_run", + method="PATCH", + route_template="/api/v1/process-runs/{process_run_id}", + request_model=UpdateProcessRunRequest, + encode_request=_encode_update_process_run, + decode_command=_decode_update_process_run, + decode_response=_decode_process_run_response, + ), + CommandRegistration( + command_type=SetLifecycleStatus, + name="set_lifecycle_status", + service_handler="set_lifecycle_status", + method="POST", + route_template="/api/v1/lifecycle/{object_type}/{object_id}", + request_model=SetLifecycleStatusRequest, + encode_request=_encode_lifecycle_status, + decode_command=_decode_lifecycle_status, + decode_response=_decode_lifecycle_response, + ), + CommandRegistration( + command_type=CreateNamespace, + name="create_namespace", + service_handler="create_namespace", + method="PUT", + route_template="/api/v1/namespaces/{namespace_path:path}", + request_model=CreateNamespaceRequest, + encode_request=_encode_create_namespace, + decode_command=_decode_create_namespace, + decode_response=_decode_namespace_response, + ), + CommandRegistration( + command_type=UpdateNamespace, + name="update_namespace", + service_handler="update_namespace", + method="PATCH", + route_template="/api/v1/namespaces/{namespace_id}", + request_model=UpdateNamespaceRequest, + encode_request=_encode_update_namespace, + decode_command=_decode_update_namespace, + decode_response=_decode_namespace_response, + ), + ) +) diff --git a/recap/commands/service.py b/recap/commands/service.py new file mode 100644 index 0000000..c77dabd --- /dev/null +++ b/recap/commands/service.py @@ -0,0 +1,1831 @@ +from __future__ import annotations + +import json +from collections.abc import Mapping +from copy import deepcopy +from typing import Any +from uuid import UUID, uuid4 + +from json_merge_patch import merge +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import joinedload, sessionmaker + +from recap.authorization.scopes import Scope +from recap.client.permissions import DenialCode +from recap.commands.audit import DurableAuditSink, record_failure_after_rollback +from recap.commands.errors import ( + CommandConflictError, + CommandError, + CommandNotFoundError, + CommandValidationError, +) +from recap.commands.idempotency import command_fingerprint +from recap.commands.models import ( + CommandContext, + CommandModel, + SetLifecycleStatus, +) +from recap.commands.registry import COMMAND_REGISTRY +from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate +from recap.db.audit import MutationAuditRepository +from recap.db.base import compare_and_swap_revision +from recap.db.idempotency import IdempotencyRepository +from recap.db.namespace import Namespace, NamespaceRepository +from recap.db.process import ( + ProcessRun, + ProcessTemplate, + ResourceAssignment, + ResourceSlot, +) +from recap.db.resource import Property, Resource, ResourceTemplate, ResourceType +from recap.db.step import Step, StepTemplate, StepTemplateResourceSlotBinding +from recap.dsl.drafts import ( + ProcessRunDraft, + ProcessTemplateDraft, + ResourceTemplateDraft, +) +from recap.exceptions import AuthorizationDenied +from recap.lifecycle import LifecycleStatus, validate_transition +from recap.schemas.attribute import AttributeTemplateValidator +from recap.schemas.namespace import NamespaceSchema +from recap.schemas.process import ( + ProcessRunCopyOptions, + ProcessRunSchema, + ProcessTemplateSchema, +) +from recap.schemas.resource import ( + ResourceCopyOptions, + ResourceSchema, +) +from recap.schemas.step import ParameterSchema, StepSchema +from recap.server.audit import AuditOutcome, AuditRecord +from recap.server.errors import ErrorCode +from recap.utils.namespace import ( + canonicalize_namespace_path, + is_namespace_ancestor, + parent_namespace_path, +) + + +class _CreateFingerprint(BaseModel): + metadata: dict[str, Any] + + +class _CreateResourceFingerprint(BaseModel): + name: str + template_id: UUID + parent_id: UUID | None + properties: dict[str, dict[str, object]] | None + + +class _UpdateResourceFingerprint(BaseModel): + name: str | None + properties: dict[str, dict[str, object]] | None + expected_revision: int + + +class _UpdateFingerprint(BaseModel): + metadata: dict[str, Any] | None + status: LifecycleStatus | None + expected_revision: int + + +class _UpdateProcessTemplateFingerprint(BaseModel): + draft: ProcessTemplateDraft + expected_revision: int + + +class _UpdateResourceTemplateFingerprint(BaseModel): + draft: ResourceTemplateDraft + expected_revision: int + + +class _UpdateProcessRunFingerprint(BaseModel): + description: str | None + status: str | None + assignments: dict[str, UUID] | None + steps: dict[str, dict[str, dict[str, object]]] | None + expected_revision: int + + +class _LifecycleFingerprint(BaseModel): + object_type: str + status: str + expected_revision: int + + +class CommandService: + """Execute namespace writes with one owning database transaction.""" + + def __init__(self, session_factory: sessionmaker) -> None: + self._session_factory = session_factory + + def execute(self, command: CommandModel, context: CommandContext): + registration = COMMAND_REGISTRY.by_command(command) + handler = getattr(self, registration.service_handler) + return handler( + context, + **{ + field_name: getattr(command, field_name) + for field_name in type(command).model_fields + }, + ) + + def set_lifecycle_status( + self, + context: CommandContext, + *, + object_type: str, + object_id: UUID, + expected_revision: int, + status: str, + ): + models = { + "process_template": (ProcessTemplate, Scope.PROCESS_TEMPLATE_WRITE), + "resource_template": (ResourceTemplate, Scope.RESOURCE_TEMPLATE_WRITE), + "resource": (Resource, Scope.RESOURCE_WRITE), + "process_run": (ProcessRun, Scope.PROCESS_RUN_WRITE), + } + model_scope = models.get(object_type) + if model_scope is None: + raise CommandValidationError(f"Unsupported lifecycle object: {object_type}") + model, scope = model_scope + try: + target_status = LifecycleStatus(status) + except ValueError as error: + raise CommandValidationError(str(error)) from error + try: + with self._session_factory.begin() as session: + obj = session.get(model, object_id) + if obj is None: + raise CommandNotFoundError(f"{object_type} not found") + self._authorize_scope( + context, obj.namespace.path, scope, "set_lifecycle_status" + ) + fingerprint = command_fingerprint( + method="POST", + route_template="/api/v1/lifecycle/{object_type}/{object_id}", + namespace_path=None, + source_id=object_id, + body=_LifecycleFingerprint( + object_type=object_type, + status=status, + expected_revision=expected_revision, + ), + ) + idempotency = IdempotencyRepository(session) + decision = self._claim( + idempotency, + context, + fingerprint, + lambda _target_id: None, + ) + if decision is not None and decision.replayed: + assert decision.response is not None + return COMMAND_REGISTRY.by_type(SetLifecycleStatus).decode_response( + decision.response, + None, + command=SetLifecycleStatus( + object_type=object_type, + object_id=object_id, + expected_revision=expected_revision, + status=status, + ), + ) + validate_transition(obj.status, target_status) + if target_status is LifecycleStatus.ACTIVE: + if isinstance(obj, ProcessRun): + obj.finalize(bump_revision=False) + else: + obj.activate(bump_revision=False) + else: + obj.archive(bump_revision=False) + compare_and_swap_revision( + session, + model, + object_id, + expected_revision=expected_revision, + values={}, + ) + session.flush() + session.refresh(obj) + lifecycle_command = SetLifecycleStatus( + object_type=object_type, + object_id=object_id, + expected_revision=expected_revision, + status=status, + ) + result = COMMAND_REGISTRY.by_type(SetLifecycleStatus).decode_response( + obj, None, command=lifecycle_command + ) + if decision is not None: + idempotency.complete( + decision, + target_id=str(obj.id), + response=result.model_dump(mode="json"), + ) + self._emit_success( + session, + context, + "set_lifecycle_status", + str(object_id), + resource_type=object_type, + ) + return result + except Exception as error: + self._emit_failure( + context, + "set_lifecycle_status", + str(object_id), + error, + resource_type=object_type, + ) + raise + + def create_resource( # noqa: C901 + self, + context: CommandContext, + *, + namespace_path: str, + id: UUID | None = None, + name: str, + template_id: UUID, + parent_id: UUID | None = None, + properties: dict[str, dict[str, object]] | None = None, + ) -> ResourceSchema: + try: + canonical_path = canonicalize_namespace_path(namespace_path) + except ValueError as error: + raise CommandValidationError(str(error)) from error + self._authorize_scope( + context, canonical_path, Scope.RESOURCE_WRITE, "create_resource" + ) + fingerprint = command_fingerprint( + method="POST", + route_template="/api/v1/resources/{namespace_path:path}", + namespace_path=canonical_path, + source_id=None, + body=_CreateResourceFingerprint( + name=name, + template_id=template_id, + parent_id=parent_id, + properties=properties, + ), + ) + try: + with self._session_factory.begin() as session: + namespace = session.scalar( + select(Namespace).where(Namespace.path == canonical_path) + ) + if namespace is None: + raise CommandNotFoundError("Namespace not found") + template = session.get(ResourceTemplate, template_id) + if template is None: + raise CommandNotFoundError("Resource template not found") + parent = session.get(Resource, parent_id) if parent_id else None + if parent_id and parent is None: + raise CommandNotFoundError("Parent resource not found") + if parent and parent.namespace_id != namespace.id: + raise CommandValidationError( + "Parent resource belongs to another namespace" + ) + idempotency = IdempotencyRepository(session) + decision = self._claim( + idempotency, context, fingerprint, lambda _id: None + ) + if decision is not None and decision.replayed: + assert decision.response is not None + return ResourceSchema.model_validate(decision.response) + resource = Resource( + id=id or uuid4(), + namespace=namespace, + name=name, + template=template, + parent=parent, + _init_children=False, + ) + session.add(resource) + resource._initialize_from_resource_template(template) + if properties: + self._apply_resource_changes(resource, properties) + session.flush() + result = ResourceSchema.model_validate(resource) + response = result.model_dump(mode="json") + if decision is not None: + idempotency.complete( + decision, target_id=str(resource.id), response=response + ) + self._emit_success( + session, + context, + "create_resource", + str(resource.id), + resource_type="resource", + ) + return result + except IntegrityError as error: + mapped = CommandConflictError("Resource name already exists under parent") + self._emit_failure( + context, "create_resource", None, mapped, resource_type="resource" + ) + raise mapped from error + except Exception as error: + self._emit_failure( + context, "create_resource", None, error, resource_type="resource" + ) + raise + + def update_resource( # noqa: C901 + self, + context: CommandContext, + *, + resource_id: UUID, + expected_revision: int, + name: str | None = None, + properties: dict[str, dict[str, object]] | None = None, + ) -> ResourceSchema: + if expected_revision < 1: + raise CommandValidationError("Expected revision must be positive") + if name is None and not properties: + raise CommandValidationError("Resource update is empty") + fingerprint = command_fingerprint( + method="PATCH", + route_template="/api/v1/resources/{resource_id}", + namespace_path=None, + source_id=resource_id, + body=_UpdateResourceFingerprint( + name=name, properties=properties, expected_revision=expected_revision + ), + ) + try: + with self._session_factory.begin() as session: + resource = session.get(Resource, resource_id) + if resource is None: + raise CommandNotFoundError("Resource not found") + self._authorize_scope( + context, + resource.namespace.path, + Scope.RESOURCE_WRITE, + "update_resource", + ) + idempotency = IdempotencyRepository(session) + decision = self._claim( + idempotency, context, fingerprint, lambda _id: None + ) + if decision is not None and decision.replayed: + assert decision.response is not None + return ResourceSchema.model_validate(decision.response) + if resource.status is not LifecycleStatus.MUTABLE: + raise CommandConflictError("Cannot update an active resource") + if name is not None: + duplicate = session.scalar( + select(Resource.id).where( + Resource.parent_id == resource.parent_id, + Resource.name == name, + Resource.id != resource.id, + ) + ) + if duplicate is not None: + raise CommandConflictError( + "Resource name already exists under parent" + ) + resource.name = name + if properties: + self._apply_resource_changes(resource, properties) + compare_and_swap_revision( + session, + Resource, + resource_id, + expected_revision=expected_revision, + values={}, + ) + session.flush() + session.refresh(resource) + result = ResourceSchema.model_validate(resource) + response = result.model_dump(mode="json") + if decision is not None: + idempotency.complete( + decision, target_id=str(resource.id), response=response + ) + self._emit_success( + session, + context, + "update_resource", + str(resource.id), + resource_type="resource", + ) + return result + except Exception as error: + self._emit_failure( + context, + "update_resource", + str(resource_id), + error, + resource_type="resource", + ) + raise + + def copy_resource( # noqa: C901 + self, + context: CommandContext, + *, + source_resource_id: UUID, + destination_namespace_path: str, + options: ResourceCopyOptions | None = None, + ) -> ResourceSchema: + options = options or ResourceCopyOptions() + try: + destination_path = canonicalize_namespace_path(destination_namespace_path) + except ValueError as error: + raise CommandValidationError(str(error)) from error + fingerprint = command_fingerprint( + method="POST", + route_template="/api/v1/resources/{source_resource_id}/copies", + namespace_path=destination_path, + source_id=source_resource_id, + body=options, + ) + try: + with self._session_factory.begin() as session: + source = session.get(Resource, source_resource_id) + if source is None: + raise CommandNotFoundError("Resource not found") + destination = session.scalar( + select(Namespace).where(Namespace.path == destination_path) + ) + if destination is None: + raise CommandNotFoundError("Destination namespace not found") + self._authorize_scope( + context, source.namespace.path, Scope.RESOURCE_READ, "copy_resource" + ) + self._authorize_scope( + context, + destination.path, + Scope.RESOURCE_WRITE, + "copy_resource", + audit_denial=False, + ) + if source.parent_id is not None: + raise CommandValidationError( + "Source resource must be a resource graph root" + ) + if not is_namespace_ancestor(source.namespace.path, destination.path): + raise CommandValidationError( + "Destination namespace must be the source namespace or its descendant" + ) + parent = session.get(Resource, options.parent_id) if options.parent_id else None + if options.parent_id is not None and parent is None: + raise CommandNotFoundError("Parent resource not found") + if parent is not None and parent.namespace_id != destination.id: + raise CommandValidationError( + "Parent resource belongs to another namespace" + ) + copy_name = options.name if options.name is not None else source.name + if parent is not None and session.scalar( + select(Resource.id).where( + Resource.parent_id == parent.id, + Resource.name == copy_name, + ) + ) is not None: + raise CommandValidationError( + f"Parent resource already has a child named {copy_name!r}" + ) + idempotency = IdempotencyRepository(session) + decision = self._claim( + idempotency, context, fingerprint, lambda _id: None + ) + if decision is not None and decision.replayed: + assert decision.response is not None + return ResourceSchema.model_validate(decision.response) + flat = self._load_resource_tree(session, source) + children = {} + for item in flat: + if item.parent_id is not None: + children.setdefault(item.parent_id, []).append(item) + + def clone(original, parent=None): + result = Resource( + id=uuid4(), + name=original.name, + template=original.template, + namespace=destination, + parent=parent, + status=LifecycleStatus.MUTABLE, + revision=1, + _init_children=False, + ) + for original_property in original.properties.values(): + copied = Property( + id=uuid4(), + template=original_property.template, + resource=result, + ) + for key, value in original_property._values.items(): + target = copied._values[key] + target.id = uuid4() + target.value_json = deepcopy(value.value_json) + target.unit = value.unit + target.metadata_json = deepcopy(value.metadata_json) + for child in children.get(original.id, []): + clone(child, result) + return result + + with session.no_autoflush: + copied = clone(source, parent=parent) + copied.copied_from = source + if options.name is not None: + copied.name = options.name + session.add(copied) + self._apply_resource_changes(copied, options.changes.properties) + if source.status is LifecycleStatus.MUTABLE: + source.activate() + session.flush() + result = ResourceSchema.model_validate(copied) + response = result.model_dump(mode="json") + if decision is not None: + idempotency.complete( + decision, target_id=str(copied.id), response=response + ) + self._emit_success( + session, + context, + "copy_resource", + str(copied.id), + resource_type="resource", + ) + return result + except Exception as error: + self._emit_failure( + context, + "copy_resource", + str(source_resource_id), + error, + resource_type="resource", + ) + raise + + @staticmethod + def _load_resource_tree(session, root): + result = [root] + pending = [root.id] + while pending: + children = list( + session.scalars( + select(Resource).where(Resource.parent_id.in_(pending)) + ).all() + ) + result.extend(children) + pending = [child.id for child in children] + return result + + @staticmethod + def _apply_resource_changes(resource, changes): + for group_name, values in changes.items(): + prop = next( + ( + item + for item in resource.properties.values() + if group_name in {item.template.name, item.template.slug} + ), + None, + ) + if prop is None: + raise CommandValidationError( + f"Copied resource has no property group {group_name!r}" + ) + for attribute_name, raw_value in values.items(): + value = next( + ( + item + for item in prop._values.values() + if attribute_name in {item.template.name, item.template.slug} + ), + None, + ) + if value is None: + raise CommandValidationError( + f"Property {attribute_name!r} not found in group {group_name!r}" + ) + if isinstance(raw_value, dict): + value.set_value(deepcopy(raw_value.get("value"))) + if "unit" in raw_value: + value.unit = raw_value["unit"] + if "metadata_json" in raw_value: + value.metadata_json = deepcopy(raw_value["metadata_json"]) + else: + value.set_value(deepcopy(raw_value)) + + def create_namespace( + self, + context: CommandContext, + *, + path: str, + metadata: Mapping[str, Any] | None = None, + ) -> NamespaceSchema: + try: + canonical_path = canonicalize_namespace_path(path) + except ValueError as error: + raise CommandValidationError(str(error)) from error + local_metadata = dict(metadata or {}) + self._authorize( + context, + parent_namespace_path(canonical_path), + mutation="create_namespace", + ) + fingerprint = command_fingerprint( + method="PUT", + route_template="/api/v1/namespaces/{namespace_path:path}", + namespace_path=canonical_path, + source_id=None, + body=_CreateFingerprint(metadata=local_metadata), + ) + + try: + with self._session_factory.begin() as session: + idempotency = IdempotencyRepository(session) + decision = self._claim( + idempotency, + context, + fingerprint, + lambda _target_id: None, + ) + if decision is not None and decision.replayed: + assert decision.response is not None + return NamespaceSchema.model_validate(decision.response) + + try: + namespace = NamespaceRepository(session).create( + canonical_path, local_metadata + ) + except LookupError as error: + parent_path = parent_namespace_path(canonical_path) + raise CommandValidationError( + f"Parent namespace {parent_path!r} does not exist. " + f"Create it before creating {canonical_path!r}.", + public_message=( + "Parent namespace must be explicitly created before " + "creating a nested namespace." + ), + ) from error + session.flush() + result = NamespaceSchema.model_validate(namespace) + response = result.model_dump(mode="json") + if decision is not None: + idempotency.complete( + decision, target_id=str(namespace.id), response=response + ) + self._emit_success( + session, context, "create_namespace", str(namespace.id) + ) + return result + except IntegrityError as error: + mapped = CommandConflictError("Namespace already exists") + self._emit_failure(context, "create_namespace", None, mapped) + raise mapped from error + except Exception as error: + self._emit_failure(context, "create_namespace", None, error) + raise + + def update_namespace( + self, + context: CommandContext, + *, + namespace_id: UUID, + expected_revision: int, + metadata: Mapping[str, Any] | None = None, + status: LifecycleStatus | None = None, + ) -> NamespaceSchema: + if expected_revision < 1: + raise CommandValidationError("Expected revision must be positive") + local_metadata = None if metadata is None else dict(metadata) + fingerprint = command_fingerprint( + method="PATCH", + route_template="/api/v1/namespaces/{namespace_id}", + namespace_path=None, + source_id=namespace_id, + body=_UpdateFingerprint( + metadata=local_metadata, + status=status, + expected_revision=expected_revision, + ), + ) + + try: + with self._session_factory.begin() as session: + namespace = session.get(Namespace, namespace_id) + if namespace is None: + raise CommandNotFoundError("Namespace not found") + self._authorize(context, namespace.path, mutation="update_namespace") + + idempotency = IdempotencyRepository(session) + decision = self._claim( + idempotency, + context, + fingerprint, + lambda _target_id: None, + ) + if decision is not None and decision.replayed: + assert decision.response is not None + return NamespaceSchema.model_validate(decision.response) + + values: dict[str, Any] = {} + if local_metadata is not None: + values["metadata_json"] = merge( + namespace.metadata_json, local_metadata + ) + if status is not None: + try: + validate_transition(namespace.status, status) + except ValueError as error: + raise CommandValidationError(str(error)) from error + values["status"] = status + if not values: + raise CommandValidationError("Namespace update is empty") + + compare_and_swap_revision( + session, + Namespace, + namespace_id, + expected_revision=expected_revision, + values=values, + ) + session.flush() + session.refresh(namespace) + result = NamespaceSchema.model_validate(namespace) + response = result.model_dump(mode="json") + if decision is not None: + idempotency.complete( + decision, target_id=str(namespace.id), response=response + ) + self._emit_success( + session, context, "update_namespace", str(namespace.id) + ) + return result + except Exception as error: + self._emit_failure(context, "update_namespace", str(namespace_id), error) + raise + + def create_process_template( + self, + context: CommandContext, + *, + namespace_path: str, + draft: ProcessTemplateDraft, + ) -> ProcessTemplateSchema: + try: + canonical_path = canonicalize_namespace_path(namespace_path) + except ValueError as error: + raise CommandValidationError(str(error)) from error + self._authorize(context, canonical_path, mutation="create_process_template") + fingerprint = command_fingerprint( + method="POST", + route_template=("/api/v1/process-templates/{namespace_path:path}"), + namespace_path=canonical_path, + source_id=None, + body=draft, + ) + + try: + with self._session_factory.begin() as session: + namespace = session.scalar( + select(Namespace).where(Namespace.path == canonical_path) + ) + if namespace is None: + raise CommandNotFoundError("Namespace not found") + idempotency = IdempotencyRepository(session) + decision = self._claim( + idempotency, context, fingerprint, lambda _target_id: None + ) + if decision is not None and decision.replayed: + assert decision.response is not None + return ProcessTemplateSchema.model_validate(decision.response) + + template = ProcessTemplate( + id=draft.id or uuid4(), + namespace=namespace, + name=draft.name, + version=draft.version, + labels=list(draft.labels), + ) + session.add(template) + self._materialize_process_template(session, template, draft) + session.flush() + result = ProcessTemplateSchema.model_validate(template) + response = result.model_dump(mode="json") + if decision is not None: + idempotency.complete( + decision, target_id=str(template.id), response=response + ) + self._emit_success( + session, + context, + "create_process_template", + str(template.id), + resource_type="process_template", + ) + return result + except IntegrityError as error: + mapped = CommandConflictError("Process template already exists") + self._emit_failure( + context, + "create_process_template", + None, + mapped, + resource_type="process_template", + ) + raise mapped from error + except Exception as error: + self._emit_failure( + context, + "create_process_template", + None, + error, + resource_type="process_template", + ) + raise + + def update_process_template( + self, + context: CommandContext, + *, + template_id: UUID, + expected_revision: int, + draft: ProcessTemplateDraft, + ) -> ProcessTemplateSchema: + if expected_revision < 1: + raise CommandValidationError("Expected revision must be positive") + fingerprint = command_fingerprint( + method="PATCH", + route_template="/api/v1/process-templates/{template_id}", + namespace_path=None, + source_id=template_id, + body=_UpdateProcessTemplateFingerprint( + draft=draft, expected_revision=expected_revision + ), + ) + + try: + with self._session_factory.begin() as session: + template = session.get(ProcessTemplate, template_id) + if template is None: + raise CommandNotFoundError("Process template not found") + self._authorize( + context, + template.namespace.path, + mutation="update_process_template", + ) + idempotency = IdempotencyRepository(session) + decision = self._claim( + idempotency, context, fingerprint, lambda _target_id: None + ) + if decision is not None and decision.replayed: + assert decision.response is not None + return ProcessTemplateSchema.model_validate(decision.response) + if template.status is not LifecycleStatus.MUTABLE: + raise CommandConflictError( + "Cannot update an active process template" + ) + + duplicate = session.scalar( + select(ProcessTemplate.id).where( + ProcessTemplate.namespace_id == template.namespace_id, + ProcessTemplate.name == draft.name, + ProcessTemplate.version == draft.version, + ProcessTemplate.id != template.id, + ) + ) + if duplicate is not None: + raise CommandConflictError("Process template already exists") + + compare_and_swap_revision( + session, + ProcessTemplate, + template_id, + expected_revision=expected_revision, + values={ + "name": draft.name, + "version": draft.version, + "labels": list(draft.labels), + }, + ) + session.expire(template) + session.refresh(template) + self._clear_process_template(session, template) + self._materialize_process_template(session, template, draft) + session.flush() + result = ProcessTemplateSchema.model_validate(template) + response = result.model_dump(mode="json") + if decision is not None: + idempotency.complete( + decision, target_id=str(template.id), response=response + ) + self._emit_success( + session, + context, + "update_process_template", + str(template.id), + resource_type="process_template", + ) + return result + except Exception as error: + self._emit_failure( + context, + "update_process_template", + str(template_id), + error, + resource_type="process_template", + ) + raise + + def create_resource_template( + self, + context: CommandContext, + *, + namespace_path: str, + draft: ResourceTemplateDraft, + ): + try: + canonical_path = canonicalize_namespace_path(namespace_path) + except ValueError as error: + raise CommandValidationError(str(error)) from error + self._authorize(context, canonical_path, mutation="create_resource_template") + fingerprint = command_fingerprint( + method="POST", + route_template="/api/v1/resource-templates/{namespace_path:path}", + namespace_path=canonical_path, + source_id=None, + body=draft, + ) + try: + with self._session_factory.begin() as session: + namespace = session.scalar( + select(Namespace).where(Namespace.path == canonical_path) + ) + if namespace is None: + raise CommandNotFoundError("Namespace not found") + idempotency = IdempotencyRepository(session) + decision = self._claim( + idempotency, context, fingerprint, lambda _id: None + ) + if decision is not None and decision.replayed: + assert decision.response is not None + from recap.schemas.resource import ResourceTemplateSchema + + return ResourceTemplateSchema.model_validate(decision.response) + template = self._materialize_resource_template( + session, namespace, draft + ) + session.flush() + from recap.schemas.resource import ResourceTemplateSchema + + result = ResourceTemplateSchema.model_validate(template) + response = result.model_dump(mode="json") + if decision is not None: + idempotency.complete( + decision, target_id=str(template.id), response=response + ) + self._emit_success( + session, + context, + "create_resource_template", + str(template.id), + resource_type="resource_template", + ) + return result + except IntegrityError as error: + mapped = CommandConflictError( + "Resource template name/version already exists in namespace" + ) + self._emit_failure( + context, + "create_resource_template", + None, + mapped, + resource_type="resource_template", + ) + raise mapped from error + except Exception as error: + self._emit_failure( + context, + "create_resource_template", + None, + error, + resource_type="resource_template", + ) + raise + + def update_resource_template( + self, + context: CommandContext, + *, + template_id: UUID, + expected_revision: int, + draft: ResourceTemplateDraft, + ): + if expected_revision < 1: + raise CommandValidationError("Expected revision must be positive") + fingerprint = command_fingerprint( + method="PATCH", + route_template="/api/v1/resource-templates/{template_id}", + namespace_path=None, + source_id=template_id, + body=_UpdateResourceTemplateFingerprint( + draft=draft, expected_revision=expected_revision + ), + ) + try: + with self._session_factory.begin() as session: + template = session.get(ResourceTemplate, template_id) + if template is None: + raise CommandNotFoundError("Resource template not found") + self._authorize( + context, + template.namespace.path, + mutation="update_resource_template", + ) + idempotency = IdempotencyRepository(session) + decision = self._claim( + idempotency, context, fingerprint, lambda _id: None + ) + if decision is not None and decision.replayed: + assert decision.response is not None + from recap.schemas.resource import ResourceTemplateSchema + + return ResourceTemplateSchema.model_validate(decision.response) + if template.status is not LifecycleStatus.MUTABLE: + raise CommandConflictError( + "Cannot update an active resource template" + ) + duplicate = session.scalar( + select(ResourceTemplate.id).where( + ResourceTemplate.namespace_id == template.namespace_id, + ResourceTemplate.parent_id == template.parent_id, + ResourceTemplate.name == draft.name, + ResourceTemplate.version == draft.version, + ResourceTemplate.id != template.id, + ) + ) + if duplicate is not None: + raise CommandConflictError( + "Resource template name/version already exists in namespace" + ) + # Build replacement graph under a savepoint before advancing + # revision. Readers only observe changes after outer commit. + with session.begin_nested(): + self._clear_resource_template(session, template) + self._materialize_resource_contents(session, template, draft) + session.flush() + compare_and_swap_revision( + session, + ResourceTemplate, + template_id, + expected_revision=expected_revision, + values={ + "name": draft.name, + "version": draft.version, + "labels": list(draft.labels), + }, + ) + session.flush() + session.refresh(template) + from recap.schemas.resource import ResourceTemplateSchema + + result = ResourceTemplateSchema.model_validate(template) + response = result.model_dump(mode="json") + if decision is not None: + idempotency.complete( + decision, target_id=str(template.id), response=response + ) + self._emit_success( + session, + context, + "update_resource_template", + str(template.id), + resource_type="resource_template", + ) + return result + except Exception as error: + self._emit_failure( + context, + "update_resource_template", + str(template_id), + error, + resource_type="resource_template", + ) + raise + + def create_process_run( + self, context: CommandContext, *, namespace_path: str, draft: ProcessRunDraft + ): + try: + target_path = canonicalize_namespace_path(namespace_path) + except ValueError as error: + raise CommandValidationError(str(error)) from error + self._authorize_scope( + context, target_path, Scope.PROCESS_RUN_WRITE, "create_process_run" + ) + fingerprint = command_fingerprint( + method="POST", + route_template="/api/v1/process-runs/{namespace_path:path}", + namespace_path=target_path, + source_id=None, + body=draft, + ) + try: + with self._session_factory.begin() as session: + namespace = session.scalar( + select(Namespace).where(Namespace.path == target_path) + ) + if namespace is None: + raise CommandNotFoundError("Namespace not found") + template = session.get(ProcessTemplate, draft.template_id) + if template is None: + raise CommandNotFoundError("Process template not found") + self._authorize_scope( + context, + template.namespace.path, + Scope.PROCESS_TEMPLATE_READ, + "create_process_run", + ) + decision = self._claim( + IdempotencyRepository(session), + context, + fingerprint, + lambda _id: None, + ) + if decision is not None and decision.replayed: + return ProcessRunSchema.model_validate(decision.response) + run = ProcessRun( + id=draft.id or uuid4(), + namespace=namespace, + name=draft.name, + description=draft.description, + template=template, + ) + session.add(run) + session.flush() + self._apply_run_assignments(session, run, draft.assignments, context) + self._apply_run_steps(session, run, draft.steps) + session.flush() + session.expire_all() + run = session.get(ProcessRun, run.id) + result = self._process_run_schema(run) + if decision is not None: + idempotency = IdempotencyRepository(session) + idempotency.complete( + decision, + target_id=str(run.id), + response=result.model_dump(mode="json"), + ) + self._emit_success( + session, + context, + "create_process_run", + str(run.id), + resource_type="process_run", + ) + return result + except IntegrityError as error: + mapped = CommandConflictError("Process run already exists in namespace") + self._emit_failure( + context, "create_process_run", None, mapped, resource_type="process_run" + ) + raise mapped from error + except Exception as error: + self._emit_failure( + context, "create_process_run", None, error, resource_type="process_run" + ) + raise + + def update_process_run( # noqa: C901 + self, + context: CommandContext, + *, + process_run_id: UUID, + expected_revision: int, + description: str | None = None, + status: str | None = None, + assignments: dict[str, UUID] | None = None, + steps: dict[str, dict[str, dict[str, object]]] | None = None, + ): + if expected_revision < 1: + raise CommandValidationError("Expected revision must be positive") + fingerprint = command_fingerprint( + method="PATCH", + route_template="/api/v1/process-runs/{process_run_id}", + namespace_path=None, + source_id=process_run_id, + body=_UpdateProcessRunFingerprint( + description=description, + status=status, + assignments=assignments, + steps=steps, + expected_revision=expected_revision, + ), + ) + try: + with self._session_factory.begin() as session: + run = session.get(ProcessRun, process_run_id) + if run is None: + raise CommandNotFoundError("Process run not found") + self._authorize_scope( + context, + run.namespace.path, + Scope.PROCESS_RUN_WRITE, + "update_process_run", + ) + decision = self._claim( + IdempotencyRepository(session), + context, + fingerprint, + lambda _id: None, + ) + if decision is not None and decision.replayed: + return ProcessRunSchema.model_validate(decision.response) + if ( + description is None + and status is None + and assignments is None + and steps is None + ): + raise CommandValidationError("Process run update is empty") + if run.status is not LifecycleStatus.MUTABLE and ( + description is not None + or assignments is not None + or steps is not None + ): + raise CommandConflictError("Cannot update a finalized process run") + if assignments is not None: + self._apply_run_assignments(session, run, assignments, context) + if steps is not None: + self._apply_run_steps(session, run, steps) + session.flush() + values: dict[str, Any] = {} + if description is not None: + values["description"] = description + if status is not None: + try: + target_status = LifecycleStatus(status) + if target_status is LifecycleStatus.ACTIVE: + run.finalize(bump_revision=False) + else: + validate_transition(run.status, target_status) + values["status"] = target_status + except ValueError as error: + raise CommandValidationError(str(error)) from error + compare_and_swap_revision( + session, + ProcessRun, + process_run_id, + expected_revision=expected_revision, + values=values, + ) + session.flush() + session.expire_all() + run = session.get(ProcessRun, process_run_id) + session.refresh(run) + result = self._process_run_schema(run) + if decision is not None: + IdempotencyRepository(session).complete( + decision, + target_id=str(run.id), + response=result.model_dump(mode="json"), + ) + self._emit_success( + session, + context, + "update_process_run", + str(run.id), + resource_type="process_run", + ) + return result + except Exception as error: + self._emit_failure( + context, + "update_process_run", + str(process_run_id), + error, + resource_type="process_run", + ) + raise + + def copy_process_run( # noqa: C901 + self, + context: CommandContext, + *, + source_process_run_id: UUID, + destination_namespace_path: str, + options: ProcessRunCopyOptions | None = None, + ): + options = options or ProcessRunCopyOptions() + destination_path = canonicalize_namespace_path(destination_namespace_path) + fingerprint = command_fingerprint( + method="POST", + route_template="/api/v1/process-runs/{source_process_run_id}/copies", + namespace_path=destination_path, + source_id=source_process_run_id, + body=options, + ) + try: + with self._session_factory.begin() as session: + source = session.get(ProcessRun, source_process_run_id) + if source is None: + raise CommandNotFoundError("Process run not found") + destination = session.scalar( + select(Namespace).where(Namespace.path == destination_path) + ) + if destination is None: + raise CommandNotFoundError("Destination namespace not found") + self._authorize_scope( + context, + source.namespace.path, + Scope.PROCESS_RUN_READ, + "copy_process_run", + ) + self._authorize_scope( + context, + destination.path, + Scope.PROCESS_RUN_WRITE, + "copy_process_run", + ) + decision = self._claim( + IdempotencyRepository(session), + context, + fingerprint, + lambda _id: None, + ) + if decision is not None and decision.replayed: + return ProcessRunSchema.model_validate(decision.response) + + changes = options.changes + copied = ProcessRun( + id=uuid4(), + namespace=destination, + name=source.name, + description=( + changes.description + if changes.description is not None + else source.description + ), + template=source.template, + copied_from=source, + status=LifecycleStatus.MUTABLE, + revision=1, + ) + session.add(copied) + session.flush() + for slot, assignment in source.assignments.items(): + copied.resources[slot] = assignment.resource + + def copy_step(source_step, target_parent=None): + target_step = copied.steps.get(source_step.name) + if target_step is None: + target_step = Step( + template=source_step.template, + name=source_step.name, + parent=target_parent, + process_run=copied, + state=source_step.state, + ) + copied.steps[target_step.name] = target_step + elif target_parent is not None: + target_step.parent = target_parent + for child in source_step.children: + copy_step(child, target_step) + for name, source_parameter in source_step.parameters.items(): + target_parameter = target_step.parameters.get(name) + if target_parameter is None: + continue + for ( + value_name, + source_value, + ) in source_parameter._values.items(): + target_value = target_parameter._values.get(value_name) + if target_value is not None: + target_value.value_json = deepcopy( + source_value.value_json + ) + target_value.unit = source_value.unit + target_value.metadata_json = deepcopy( + source_value.metadata_json + ) + for assignment in source_step.assignments.values(): + target_step.assignments[assignment.resource_slot_id] = ( + ResourceAssignment( + process_run=copied, + resource_slot=assignment.resource_slot, + step=target_step, + resource=assignment.resource, + ) + ) + return target_step + + for source_step in source.steps.values(): + copy_step(source_step) + if changes.assignments is not None: + self._apply_run_assignments( + session, copied, changes.assignments, context + ) + if changes.steps is not None: + self._apply_run_steps(session, copied, changes.steps) + session.flush() + result = self._process_run_schema(copied) + if decision is not None: + IdempotencyRepository(session).complete( + decision, + target_id=str(copied.id), + response=result.model_dump(mode="json"), + ) + self._emit_success( + session, + context, + "copy_process_run", + str(copied.id), + resource_type="process_run", + ) + return result + except Exception as error: + self._emit_failure( + context, + "copy_process_run", + str(source_process_run_id), + error, + resource_type="process_run", + ) + raise + + @staticmethod + def _process_run_schema(run): + result = ProcessRunSchema.model_validate(run) + for orm_step in run.steps.values(): + current = result.steps[orm_step.name] + if orm_step.parameters: + parameters = { + name: ParameterSchema.model_validate( + { + "id": parameter.id, + "create_date": parameter.create_date, + "modified_date": parameter.modified_date, + "template": parameter.template, + "values": { + value_name: {"value": value.value, "unit": value.unit} + for value_name, value in parameter._values.items() + }, + } + ) + for name, parameter in orm_step.parameters.items() + } + payload = current.model_dump() + payload["parameters"] = parameters + result.steps[orm_step.name] = StepSchema.model_validate(payload) + return result + + @staticmethod + def _apply_run_assignments(session, run, assignments, context): + for slot_name, resource_id in assignments.items(): + slot = next( + ( + item + for item in run.template.resource_slots + if item.name == slot_name + ), + None, + ) + if slot is None: + raise CommandValidationError(f"Resource slot {slot_name!r} not found") + resource = session.scalar( + select(Resource) + .where(Resource.id == resource_id) + .options( + joinedload(Resource.template).joinedload(ResourceTemplate.types) + ) + ) + if resource is None: + raise CommandNotFoundError("Resource not found") + CommandService._authorize_scope( + context, + resource.namespace.path, + Scope.RESOURCE_READ, + "create_process_run", + ) + existing = run.assignments.get(slot) + if existing is not None and existing.resource_id != resource.id: + raise ValueError( + f"Slot {slot.name} is already occupied in run {run.id}" + ) + with session.no_autoflush: + run.resources[slot] = resource + + @staticmethod + def _apply_run_steps(session, run, steps): + for step_name, step_data in steps.items(): + step = run.steps.get(step_name) + if step is None: + raise CommandValidationError(f"Step {step_name!r} not found") + parameters = ( + step_data.parameters + if hasattr(step_data, "parameters") + else step_data.get("parameters", step_data) + ) + for group_name, values in (parameters or {}).items(): + param = step.parameters.get(group_name) + if param is None: + raise CommandValidationError( + f"Parameter group {group_name!r} not found" + ) + for attr_name, raw in values.items(): + value = param._values.get(attr_name) + if value is None: + value = next( + ( + item + for key, item in param._values.items() + if item.template.slug == attr_name + ), + None, + ) + if value is None: + raise CommandValidationError( + f"Parameter {attr_name!r} not found" + ) + if isinstance(raw, dict): + value.set_value(raw.get("value")) + if "unit" in raw: + value.unit = raw["unit"] + else: + value.set_value(raw) + session.add(value) + + @staticmethod + def _materialize_resource_template(session, namespace, draft): + template = ResourceTemplate( + id=draft.id or uuid4(), + namespace=namespace, + name=draft.name, + version=draft.version, + labels=list(draft.labels), + ) + session.add(template) + CommandService._materialize_resource_contents(session, template, draft) + return template + + @staticmethod + def _materialize_resource_contents(session, template, draft): + template.types = [] + for type_name in draft.type_names: + resource_type = session.scalar( + select(ResourceType).where(ResourceType.name == type_name) + ) + if resource_type is None: + resource_type = ResourceType(name=type_name) + session.add(resource_type) + template.types.append(resource_type) + for group_draft in draft.property_groups: + group = AttributeGroupTemplate(name=group_draft.name) + template.attribute_group_templates.append(group) + for attribute_draft in group_draft.attributes: + validated = AttributeTemplateValidator.model_validate( + attribute_draft.model_dump() + ) + default = validated.default + if isinstance(default, list): + default = json.dumps(default, default=str) + group.attribute_templates.append( + AttributeTemplate( + name=validated.name, + value_type=validated.type, + unit=validated.unit, + default_value=default, + metadata_json=validated.metadata, + ) + ) + for child_draft in draft.children: + child = ResourceTemplate( + id=child_draft.id or uuid4(), + namespace=template.namespace, + name=child_draft.name, + version=child_draft.version, + labels=list(child_draft.labels), + parent=template, + ) + session.add(child) + template.children[child.name] = child + CommandService._materialize_resource_contents(session, child, child_draft) + + @staticmethod + def _clear_resource_template(session, template): + for child in list(template.children.values()): + CommandService._delete_resource_template_tree(session, child) + for group in list(template.attribute_group_templates): + for attribute in list(group.attribute_templates): + session.delete(attribute) + session.delete(group) + session.flush() + session.expire(template, ["children", "attribute_group_templates", "types"]) + + @staticmethod + def _delete_resource_template_tree(session, template): + for child in list(template.children.values()): + CommandService._delete_resource_template_tree(session, child) + for group in list(template.attribute_group_templates): + for attribute in list(group.attribute_templates): + session.delete(attribute) + session.delete(group) + session.delete(template) + + @staticmethod + def _clear_process_template(session, template: ProcessTemplate) -> None: + for step in list(template.step_templates.values()): + for group in list(step.attribute_group_templates): + for attribute in list(group.attribute_templates): + session.delete(attribute) + session.delete(step) + session.flush() + for slot in list(template.resource_slots): + session.delete(slot) + session.flush() + session.expire(template, ["step_templates", "resource_slots"]) + + @staticmethod + def _materialize_process_template( + session, template: ProcessTemplate, draft: ProcessTemplateDraft + ) -> None: + slots = {} + for slot_draft in draft.resource_slots: + resource_type = session.scalar( + select(ResourceType).where( + ResourceType.name == slot_draft.resource_type + ) + ) + if resource_type is None: + if not slot_draft.create_resource_type: + raise CommandValidationError( + f"Resource type {slot_draft.resource_type!r} not found" + ) + resource_type = ResourceType(name=slot_draft.resource_type) + session.add(resource_type) + slot = ResourceSlot( + name=slot_draft.name, + resource_type=resource_type, + direction=slot_draft.direction, + required=slot_draft.required, + ) + template.resource_slots.append(slot) + slots[slot_draft.name] = slot + + for step_draft in draft.steps: + step = StepTemplate(name=step_draft.name) + template.step_templates[step.name] = step + for group_draft in step_draft.parameter_groups: + group = AttributeGroupTemplate(name=group_draft.name) + step.attribute_group_templates.append(group) + for attribute_draft in group_draft.attributes: + validated = AttributeTemplateValidator.model_validate( + attribute_draft.model_dump() + ) + default = validated.default + if isinstance(default, list): + default = json.dumps(default, default=str) + group.attribute_templates.append( + AttributeTemplate( + name=validated.name, + value_type=validated.type, + unit=validated.unit, + default_value=default, + metadata_json=validated.metadata, + ) + ) + for role, slot_name in step_draft.role_bindings.items(): + step.bindings[role] = StepTemplateResourceSlotBinding( + role=role, resource_slot=slots[slot_name] + ) + + @staticmethod + def _claim(repository, context, fingerprint, authorize_replay): + if context.idempotency_key is None: + return None + return repository.claim( + context.actor.actor_id, + context.idempotency_key, + fingerprint, + authorize_replay, + ) + + @staticmethod + def _authorize( + context: CommandContext, + namespace_path: str, + *, + mutation: str, + audit_denial: bool = True, + ) -> None: + permissions = context.policy.permissions_for(context.actor, namespace_path) + if Scope.NAMESPACE_WRITE in permissions.effective_scopes: + return + if audit_denial: + context.audit_sink.emit( + CommandService._audit_record( + context, + mutation, + None, + AuditOutcome.DENIED, + DenialCode.INSUFFICIENT_SCOPE, + ) + ) + raise AuthorizationDenied() + + @staticmethod + def _authorize_scope( + context: CommandContext, + namespace_path: str, + scope: Scope, + mutation: str, + *, + audit_denial: bool = True, + ) -> None: + permissions = context.policy.permissions_for(context.actor, namespace_path) + if scope in permissions.effective_scopes: + return + if audit_denial: + context.audit_sink.emit( + CommandService._audit_record( + context, + mutation, + None, + AuditOutcome.DENIED, + DenialCode.INSUFFICIENT_SCOPE, + resource_type="resource", + ) + ) + raise AuthorizationDenied() + + @staticmethod + def _emit_success( + session, context, mutation, resource_id, *, resource_type="namespace" + ) -> None: + record = CommandService._audit_record( + context, + mutation, + resource_id, + AuditOutcome.SUCCESS, + None, + resource_type=resource_type, + ) + DurableAuditSink(MutationAuditRepository(session)).emit(record) + context.audit_sink.emit(record) + + def _emit_failure( + self, + context, + mutation, + resource_id, + error, + *, + resource_type="namespace", + ) -> None: + if isinstance(error, AuthorizationDenied): + return + record = self._audit_record( + context, + mutation, + resource_id, + AuditOutcome.ERROR, + self._error_code(error), + resource_type=resource_type, + ) + context.audit_sink.emit(record) + record_failure_after_rollback(self._session_factory, record) + + @staticmethod + def _error_code(error: Exception) -> ErrorCode: + if isinstance(error, CommandNotFoundError): + return ErrorCode.NOT_FOUND + if isinstance(error, CommandConflictError): + return ErrorCode.CONFLICT + if isinstance(error, CommandValidationError): + return ErrorCode.VALIDATION_ERROR + if isinstance(error, CommandError): + return ErrorCode.REQUEST_ERROR + return ErrorCode.INTERNAL_ERROR + + @staticmethod + def _audit_record( + context, + mutation, + resource_id, + outcome, + reason_code, + *, + resource_type="namespace", + ): + return AuditRecord( + request_id=UUID(context.request_id), + actor_id=context.actor.actor_id, + mutation=mutation, + resource_type=resource_type, + resource_id=resource_id, + outcome=outcome, + reason_code=reason_code, + ) diff --git a/recap/db/__init__.py b/recap/db/__init__.py index f527a3c..0765565 100644 --- a/recap/db/__init__.py +++ b/recap/db/__init__.py @@ -1,4 +1,7 @@ from .attribute import AttributeGroupTemplate, AttributeTemplate, AttributeValue +from .audit import MutationAudit +from .idempotency import IdempotencyRecord +from .namespace import Namespace from .process import ProcessRun, ProcessTemplate from .resource import Property, Resource, ResourceTemplate, ResourceType from .step import ( @@ -11,10 +14,13 @@ __all__ = [ "Property", + "MutationAudit", + "IdempotencyRecord", "Parameter", "AttributeTemplate", "AttributeGroupTemplate", "AttributeValue", + "Namespace", "Step", "StepTemplate", "StepTemplateEdge", diff --git a/recap/db/audit.py b/recap/db/audit.py new file mode 100644 index 0000000..540fb34 --- /dev/null +++ b/recap/db/audit.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import DateTime, String, Uuid, func +from sqlalchemy.orm import Mapped, Session, mapped_column + +from recap.server.audit import AuditRecord + +from .base import Base + + +class MutationAudit(Base): + __tablename__ = "mutation_audit" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + request_id: Mapped[UUID] = mapped_column(Uuid, nullable=False) + actor_id: Mapped[str | None] = mapped_column(String, nullable=True) + mutation: Mapped[str] = mapped_column(String, nullable=False) + resource_type: Mapped[str] = mapped_column(String, nullable=False) + resource_id: Mapped[str | None] = mapped_column(String, nullable=True) + outcome: Mapped[str] = mapped_column(String, nullable=False) + reason_code: Mapped[str | None] = mapped_column(String, nullable=True) + create_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def as_record(self) -> AuditRecord: + return AuditRecord( + request_id=self.request_id, + actor_id=self.actor_id, + mutation=self.mutation, + resource_type=self.resource_type, + resource_id=self.resource_id, + outcome=self.outcome, + reason_code=self.reason_code, + ) + + +class MutationAuditRepository: + def __init__(self, session: Session) -> None: + self._session = session + + def add(self, record: AuditRecord) -> None: + self._session.add( + MutationAudit( + request_id=record.request_id, + actor_id=record.actor_id, + mutation=record.mutation, + resource_type=record.resource_type, + resource_id=record.resource_id, + outcome=record.outcome.value, + reason_code=record.reason_code.value if record.reason_code else None, + ) + ) diff --git a/recap/db/base.py b/recap/db/base.py index d18e93a..535b678 100644 --- a/recap/db/base.py +++ b/recap/db/base.py @@ -1,13 +1,48 @@ +from collections.abc import Mapping from datetime import datetime +from typing import Any -from sqlalchemy import DateTime, func -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy import DateTime, Enum, func, update +from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column + +from recap.commands.errors import CommandConflictError +from recap.lifecycle import LifecycleStatus class Base(DeclarativeBase): pass +class RevisionedLifecycleMixin: + status: Mapped[LifecycleStatus] = mapped_column( + Enum(LifecycleStatus, name="lifecyclestatus"), + nullable=False, + default=LifecycleStatus.MUTABLE, + ) + revision: Mapped[int] = mapped_column(nullable=False, default=1) + + +def compare_and_swap_revision( + session: Session, + model: type[RevisionedLifecycleMixin], + entity_id: Any, + *, + expected_revision: int, + values: Mapping[str, Any], +) -> int: + """Apply one update only when caller's expected revision is current.""" + if "revision" in values: + raise ValueError("Revision is managed by compare-and-swap") + result = session.execute( + update(model) + .where(model.id == entity_id, model.revision == expected_revision) + .values(**values, revision=model.revision + 1) + ) + if result.rowcount != 1: + raise CommandConflictError("Expected revision is stale") + return expected_revision + 1 + + # Reusable timestamps class TimestampMixin: # Timestamp when the row is first inserted (set by the DB) diff --git a/recap/db/campaign.py b/recap/db/campaign.py deleted file mode 100644 index efa3c12..0000000 --- a/recap/db/campaign.py +++ /dev/null @@ -1,31 +0,0 @@ -import typing -from typing import Any -from uuid import UUID, uuid4 - -from sqlalchemy import JSON, UniqueConstraint -from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.ext.mutable import MutableDict -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from recap.db.base import Base, TimestampMixin - -if typing.TYPE_CHECKING: - from recap.db.process import ProcessRun - - -class Campaign(TimestampMixin, Base): - __tablename__ = "campaign" - id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) - name: Mapped[str] = mapped_column(nullable=False) - proposal: Mapped[str] = mapped_column(nullable=False) - saf: Mapped[str] = mapped_column(nullable=True) - meta_data: Mapped[dict[str, Any] | None] = mapped_column( - MutableDict.as_mutable(JSON), nullable=True - ) - process_runs: Mapped[list["ProcessRun"]] = relationship( - "ProcessRun", back_populates="campaign" - ) - resources = association_proxy("process_runs", "resources") - __table_args__ = ( - UniqueConstraint("name", "proposal", name="uq_campaign_name_proposal"), - ) diff --git a/recap/db/engine.py b/recap/db/engine.py new file mode 100644 index 0000000..3feda8a --- /dev/null +++ b/recap/db/engine.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Protocol + +from sqlalchemy import Engine, create_engine, event +from sqlalchemy.engine import make_url +from sqlalchemy.orm import sessionmaker + +SQLITE_BUSY_TIMEOUT_MS = 5000 + + +class DatabaseConfiguration(Protocol): + @property + def database_url(self) -> str: ... + + +def create_engine_and_session_factory( + config: DatabaseConfiguration, +) -> tuple[Engine, sessionmaker]: + """Build an engine and session factory tuned for selected database.""" + + database_url = config.database_url + url = make_url(database_url) + if url.get_backend_name() == "sqlite": + engine = create_engine( + url, + connect_args={ + "check_same_thread": False, + "timeout": SQLITE_BUSY_TIMEOUT_MS / 1000, + }, + ) + + @event.listens_for(engine, "connect") + def set_sqlite_busy_timeout(dbapi_connection, _connection_record) -> None: + cursor = dbapi_connection.cursor() + try: + cursor.execute(f"PRAGMA busy_timeout={SQLITE_BUSY_TIMEOUT_MS}") + finally: + cursor.close() + + elif url.get_backend_name() == "postgresql": + if url.drivername == "postgresql": + url = url.set(drivername="postgresql+psycopg") + engine = create_engine( + url, + pool_pre_ping=True, + pool_size=5, + max_overflow=10, + ) + else: + raise ValueError(f"Unsupported database dialect: {url.get_backend_name()}") + + return engine, sessionmaker(bind=engine, expire_on_commit=False) diff --git a/recap/db/idempotency.py b/recap/db/idempotency.py new file mode 100644 index 0000000..aaaeade --- /dev/null +++ b/recap/db/idempotency.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from sqlalchemy import JSON, DateTime, String, func, select, update +from sqlalchemy.dialects.postgresql import insert as postgresql_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.orm import Mapped, Session, mapped_column + +from recap.commands.errors import CommandConflictError + +from .base import Base + + +class IdempotencyRecord(Base): + __tablename__ = "command_idempotency" + + actor_id: Mapped[str] = mapped_column(String, primary_key=True) + idempotency_key: Mapped[str] = mapped_column(String, primary_key=True) + fingerprint: Mapped[str] = mapped_column(String(64), nullable=False) + target_id: Mapped[str | None] = mapped_column(String, nullable=True) + response_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + create_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + +@dataclass(frozen=True, slots=True) +class IdempotencyDecision: + actor_id: str + idempotency_key: str + fingerprint: str + replayed: bool + target_id: str | None = None + response: dict[str, Any] | None = None + + +class IdempotencyRepository: + def __init__(self, session: Session) -> None: + self._session = session + + def claim( + self, + actor_id: str, + idempotency_key: str, + fingerprint: str, + authorize_replay: Callable[[str | None], None], + ) -> IdempotencyDecision: + table = IdempotencyRecord.__table__ + values = { + "actor_id": actor_id, + "idempotency_key": idempotency_key, + "fingerprint": fingerprint, + } + dialect = self._session.get_bind().dialect.name + if dialect == "postgresql": + statement = postgresql_insert(table).values(**values) + elif dialect == "sqlite": + statement = sqlite_insert(table).values(**values) + else: # pragma: no cover - application engine rejects other dialects + raise ValueError(f"Unsupported database dialect: {dialect}") + statement = statement.on_conflict_do_nothing( + index_elements=[table.c.actor_id, table.c.idempotency_key] + ) + + inserted = self._session.execute(statement).rowcount == 1 + if inserted: + return IdempotencyDecision( + actor_id=actor_id, + idempotency_key=idempotency_key, + fingerprint=fingerprint, + replayed=False, + ) + + record = self._session.scalar( + select(IdempotencyRecord).where( + IdempotencyRecord.actor_id == actor_id, + IdempotencyRecord.idempotency_key == idempotency_key, + ) + ) + if record is None: # pragma: no cover - guarded by atomic insert + raise RuntimeError("Idempotency claim disappeared") + if record.fingerprint != fingerprint: + raise CommandConflictError( + "Idempotency key was already used for a different command" + ) + if record.response_json is None: + raise CommandConflictError("Idempotency command is still in progress") + + authorize_replay(record.target_id) + return IdempotencyDecision( + actor_id=actor_id, + idempotency_key=idempotency_key, + fingerprint=fingerprint, + replayed=True, + target_id=record.target_id, + response=dict(record.response_json), + ) + + def complete( + self, + decision: IdempotencyDecision, + *, + target_id: str | None, + response: Mapping[str, Any], + ) -> None: + if decision.replayed: + raise CommandConflictError( + "Replayed idempotency command is already complete" + ) + result = self._session.execute( + update(IdempotencyRecord) + .where( + IdempotencyRecord.actor_id == decision.actor_id, + IdempotencyRecord.idempotency_key == decision.idempotency_key, + IdempotencyRecord.fingerprint == decision.fingerprint, + IdempotencyRecord.response_json.is_(None), + ) + .values(target_id=target_id, response_json=dict(response)) + ) + if result.rowcount != 1: + raise CommandConflictError("Idempotency command could not be completed") diff --git a/recap/db/migrations/env.py b/recap/db/migrations/env.py index 8125f45..97b6f72 100644 --- a/recap/db/migrations/env.py +++ b/recap/db/migrations/env.py @@ -20,6 +20,11 @@ # target_metadata = mymodel.Base.metadata target_metadata = Base.metadata +migration_options = { + name: config.attributes.get(name) + for name in ("campaign_namespace_paths", "base_namespace_path") +} + # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") @@ -44,6 +49,7 @@ def run_migrations_offline() -> None: target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + **migration_options, ) with context.begin_transaction(): @@ -64,7 +70,11 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure( + connection=connection, + target_metadata=target_metadata, + **migration_options, + ) with context.begin_transaction(): context.run_migrations() diff --git a/recap/db/migrations/versions/71c5ce51c034_add_namespaces.py b/recap/db/migrations/versions/71c5ce51c034_add_namespaces.py new file mode 100644 index 0000000..62502f6 --- /dev/null +++ b/recap/db/migrations/versions/71c5ce51c034_add_namespaces.py @@ -0,0 +1,329 @@ +"""add namespaces + +Revision ID: 71c5ce51c034 +Revises: f11ecd5c55cf +Create Date: 2026-07-27 22:38:56.480008 + +""" + +import json +from uuid import NAMESPACE_URL, UUID, uuid5 + +import sqlalchemy as sa +from alembic import context, op + +# revision identifiers, used by Alembic. +revision = "71c5ce51c034" +down_revision = "f11ecd5c55cf" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "namespace", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("path", sa.String(), nullable=False), + sa.Column("parent_id", sa.Uuid(), nullable=True), + sa.Column("metadata_json", sa.JSON(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column( + "create_date", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "modified_date", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.ForeignKeyConstraint(["parent_id"], ["namespace.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("path"), + ) + + for table_name in ( + "process_run", + "process_template", + "resource_template", + "resource", + ): + with op.batch_alter_table(table_name) as batch_op: + batch_op.add_column(sa.Column("namespace_id", sa.Uuid(), nullable=True)) + batch_op.add_column(sa.Column("status", sa.String(), nullable=True)) + batch_op.add_column(sa.Column("revision", sa.Integer(), nullable=True)) + batch_op.create_foreign_key( + f"fk_{table_name}_namespace_id_namespace", + "namespace", + ["namespace_id"], + ["id"], + ) + + naming_convention = {"uq": "uq_%(table_name)s_%(column_0_name)s"} + with op.batch_alter_table( + "process_template", naming_convention=naming_convention + ) as batch_op: + batch_op.drop_constraint("uq_process_template_name", type_="unique") + batch_op.drop_constraint("uq_process_template_name_version", type_="unique") + batch_op.drop_column("is_active") + batch_op.create_unique_constraint( + "uq_process_template_namespace_name_version", + ["namespace_id", "name", "version"], + ) + + with op.batch_alter_table( + "process_run", naming_convention=naming_convention + ) as batch_op: + batch_op.drop_constraint("uq_process_run_name", type_="unique") + batch_op.create_unique_constraint( + "uq_process_run_namespace_name", ["namespace_id", "name"] + ) + + with op.batch_alter_table("resource_template") as batch_op: + batch_op.drop_constraint( + "uq_resource_template_parent_name_version", type_="unique" + ) + batch_op.create_unique_constraint( + "uq_resource_template_namespace_parent_name_version", + ["namespace_id", "parent_id", "name", "version"], + ) + + with op.batch_alter_table( + "resource", reflect_kwargs={"resolve_fks": False} + ) as batch_op: + batch_op.drop_column("active") + + with op.batch_alter_table("resource") as batch_op: + batch_op.add_column(sa.Column("copied_from_id", sa.Uuid(), nullable=True)) + batch_op.create_foreign_key( + "fk_resource_copied_from_id_resource", + "resource", + ["copied_from_id"], + ["id"], + ) + + for table_name in ("process_template", "resource_template"): + with op.batch_alter_table(table_name) as batch_op: + batch_op.add_column( + sa.Column("labels", sa.JSON(), nullable=False, server_default="[]") + ) + + connection = op.get_bind() + options = context.get_context().opts + base_path = options.get("base_namespace_path", "") + if base_path is None: + base_path = "" + configured_paths = options.get("campaign_namespace_paths") or {} + base_id = uuid5(NAMESPACE_URL, f"recap:namespace:{base_path}") + namespace = sa.table( + "namespace", + sa.column("id", sa.Uuid()), + sa.column("path", sa.String()), + sa.column("parent_id", sa.Uuid()), + sa.column("metadata_json", sa.JSON()), + sa.column("status", sa.String()), + sa.column("revision", sa.Integer()), + ) + connection.execute( + namespace.insert(), + { + "id": base_id, + "path": base_path, + "parent_id": None, + "metadata_json": {}, + "status": "MUTABLE", + "revision": 1, + }, + ) + + campaigns = connection.execute( + sa.text("SELECT id, name, proposal, saf, meta_data FROM campaign") + ).mappings() + for campaign in campaigns: + campaign_id = UUID(str(campaign["id"])) + default_campaign_path = ( + f"campaign/{campaign_id}" + if base_path == "" + else f"{base_path}/campaign/{campaign_id}" + ) + campaign_path = configured_paths.get(str(campaign_id), default_campaign_path) + campaign_metadata = campaign["meta_data"] + if isinstance(campaign_metadata, str): + campaign_metadata = json.loads(campaign_metadata) + metadata = { + "recap.campaign.name": campaign["name"], + "nsls2.proposal": campaign["proposal"], + "nsls2.saf": campaign["saf"], + "recap.campaign.metadata": campaign_metadata, + } + connection.execute( + namespace.insert(), + { + "id": campaign_id, + "path": campaign_path, + "parent_id": base_id, + "metadata_json": metadata, + "status": "ACTIVE", + "revision": 1, + }, + ) + + connection.execute( + sa.text( + """ + UPDATE process_run + SET namespace_id = campaign_id, status = 'ACTIVE', revision = 1 + """ + ) + ) + for table_name in ("process_template", "resource_template"): + connection.execute( + sa.text( + f""" + UPDATE {table_name} + SET namespace_id = :base_id, status = 'MUTABLE', revision = 1 + """ + ).bindparams(sa.bindparam("base_id", type_=sa.Uuid())), + {"base_id": base_id}, + ) + connection.execute( + sa.text( + """ + UPDATE process_template + SET status = 'ACTIVE' + WHERE id IN (SELECT process_template_id FROM process_run) + """ + ) + ) + connection.execute( + sa.text( + """ + UPDATE resource_template + SET status = 'ACTIVE' + WHERE id IN ( + SELECT resource_template_id FROM resource + WHERE resource_template_id IS NOT NULL + ) + """ + ) + ) + connection.execute( + sa.text( + """ + UPDATE resource + SET namespace_id = :base_id, + status = CASE + WHEN id IN (SELECT resource_id FROM resource_assignment) + THEN 'ACTIVE' + ELSE 'MUTABLE' + END, + revision = 1 + """ + ).bindparams(sa.bindparam("base_id", type_=sa.Uuid())), + {"base_id": base_id}, + ) + + for table_name in ( + "process_run", + "process_template", + "resource_template", + "resource", + ): + with op.batch_alter_table(table_name) as batch_op: + batch_op.alter_column( + "namespace_id", existing_type=sa.Uuid(), nullable=False + ) + batch_op.alter_column("status", existing_type=sa.String(), nullable=False) + batch_op.alter_column( + "revision", existing_type=sa.Integer(), nullable=False + ) + + for table_name in ( + "process_run", + "process_template", + "resource_template", + "resource", + ): + missing = connection.scalar( + sa.text(f"SELECT count(*) FROM {table_name} WHERE namespace_id IS NULL") + ) + if missing: + raise RuntimeError(f"Missing namespace IDs in {table_name}: {missing}") + + +def downgrade() -> None: + with op.batch_alter_table( + "resource", reflect_kwargs={"resolve_fks": False} + ) as batch_op: + batch_op.add_column( + sa.Column("active", sa.Boolean(), nullable=False, server_default="1") + ) + + with op.batch_alter_table( + "resource_template", reflect_kwargs={"resolve_fks": False} + ) as batch_op: + batch_op.drop_constraint( + "uq_resource_template_namespace_parent_name_version", type_="unique" + ) + batch_op.create_unique_constraint( + "uq_resource_template_parent_name_version", + ["parent_id", "name", "version"], + ) + + with op.batch_alter_table( + "process_run", reflect_kwargs={"resolve_fks": False} + ) as batch_op: + batch_op.drop_constraint("uq_process_run_namespace_name", type_="unique") + batch_op.create_unique_constraint("uq_process_run_name", ["name"]) + + with op.batch_alter_table( + "process_template", reflect_kwargs={"resolve_fks": False} + ) as batch_op: + batch_op.drop_constraint( + "uq_process_template_namespace_name_version", type_="unique" + ) + batch_op.add_column( + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="0") + ) + batch_op.create_unique_constraint("uq_process_template_name", ["name"]) + batch_op.create_unique_constraint( + "uq_process_template_name_version", ["name", "version"] + ) + + for table_name in ("resource_template", "process_template"): + with op.batch_alter_table( + table_name, reflect_kwargs={"resolve_fks": False} + ) as batch_op: + batch_op.drop_column("labels") + + with op.batch_alter_table( + "resource", reflect_kwargs={"resolve_fks": False} + ) as batch_op: + batch_op.drop_constraint( + "fk_resource_copied_from_id_resource", type_="foreignkey" + ) + batch_op.drop_column("copied_from_id") + + for table_name in reversed( + ( + "process_run", + "process_template", + "resource_template", + "resource", + ) + ): + with op.batch_alter_table( + table_name, reflect_kwargs={"resolve_fks": False} + ) as batch_op: + batch_op.drop_constraint( + f"fk_{table_name}_namespace_id_namespace", type_="foreignkey" + ) + batch_op.drop_column("revision") + batch_op.drop_column("status") + batch_op.drop_column("namespace_id") + + if sa.inspect(op.get_bind()).has_table("namespace"): + op.drop_table("namespace") diff --git a/recap/db/migrations/versions/8f3c2a1b7d90_remove_campaigns.py b/recap/db/migrations/versions/8f3c2a1b7d90_remove_campaigns.py new file mode 100644 index 0000000..4450e01 --- /dev/null +++ b/recap/db/migrations/versions/8f3c2a1b7d90_remove_campaigns.py @@ -0,0 +1,134 @@ +"""remove campaigns + +Revision ID: 8f3c2a1b7d90 +Revises: 71c5ce51c034 +Create Date: 2026-07-28 +""" + +import json + +import sqlalchemy as sa +from alembic import op + +revision = "8f3c2a1b7d90" +down_revision = "71c5ce51c034" +branch_labels = None +depends_on = None + +OWNED_TABLES = ( + "process_run", + "process_template", + "resource_template", + "resource", +) + + +def upgrade() -> None: + connection = op.get_bind() + + for table_name in OWNED_TABLES: + missing = connection.scalar( + sa.text(f"SELECT count(*) FROM {table_name} WHERE namespace_id IS NULL") + ) + if missing: + raise RuntimeError(f"Missing namespace IDs in {table_name}: {missing}") + orphaned = connection.scalar( + sa.text( + f""" + SELECT count(*) FROM {table_name} AS owned + WHERE NOT EXISTS ( + SELECT 1 FROM namespace WHERE namespace.id = owned.namespace_id + ) + """ + ) + ) + if orphaned: + raise RuntimeError(f"Invalid namespace IDs in {table_name}: {orphaned}") + + mismatched = connection.scalar( + sa.text( + """ + SELECT count(*) FROM process_run + WHERE campaign_id IS NULL OR campaign_id != namespace_id + """ + ) + ) + if mismatched: + raise RuntimeError( + f"Process runs have mismatched Campaign/Namespace ownership: {mismatched}" + ) + + with op.batch_alter_table( + "process_run", reflect_kwargs={"resolve_fks": False} + ) as batch_op: + batch_op.drop_column("campaign_id") + op.drop_table("campaign") + + +def downgrade() -> None: + op.create_table( + "campaign", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("proposal", sa.String(), nullable=False), + sa.Column("saf", sa.String(), nullable=True), + sa.Column("meta_data", sa.JSON(), nullable=True), + sa.Column( + "create_date", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "modified_date", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name", "proposal", name="uq_campaign_name_proposal"), + ) + connection = op.get_bind() + rows = connection.execute( + sa.text( + """ + SELECT DISTINCT namespace.id, namespace.path, namespace.metadata_json + FROM namespace + JOIN process_run ON process_run.namespace_id = namespace.id + """ + ) + ).mappings() + campaign = sa.table( + "campaign", + sa.column("id", sa.Uuid()), + sa.column("name", sa.String()), + sa.column("proposal", sa.String()), + sa.column("saf", sa.String()), + sa.column("meta_data", sa.JSON()), + ) + for row in rows: + metadata = row["metadata_json"] or {} + if isinstance(metadata, str): + metadata = json.loads(metadata) + connection.execute( + campaign.insert(), + { + "id": row["id"], + "name": metadata.get("recap.campaign.name", row["path"]), + "proposal": metadata.get("nsls2.proposal", row["path"]), + "saf": metadata.get("nsls2.saf"), + "meta_data": metadata.get("recap.campaign.metadata"), + }, + ) + with op.batch_alter_table( + "process_run", reflect_kwargs={"resolve_fks": False} + ) as batch_op: + batch_op.add_column(sa.Column("campaign_id", sa.Uuid(), nullable=True)) + connection.execute(sa.text("UPDATE process_run SET campaign_id = namespace_id")) + with op.batch_alter_table( + "process_run", reflect_kwargs={"resolve_fks": False} + ) as batch_op: + batch_op.alter_column("campaign_id", existing_type=sa.Uuid(), nullable=False) + batch_op.create_foreign_key( + "fk_process_run_campaign_id_campaign", "campaign", ["campaign_id"], ["id"] + ) diff --git a/recap/db/migrations/versions/b47f5e2a9c10_add_command_infrastructure.py b/recap/db/migrations/versions/b47f5e2a9c10_add_command_infrastructure.py new file mode 100644 index 0000000..6615f0a --- /dev/null +++ b/recap/db/migrations/versions/b47f5e2a9c10_add_command_infrastructure.py @@ -0,0 +1,55 @@ +"""add command infrastructure + +Revision ID: b47f5e2a9c10 +Revises: 8f3c2a1b7d90 +Create Date: 2026-07-28 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "b47f5e2a9c10" +down_revision = "8f3c2a1b7d90" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "command_idempotency", + sa.Column("actor_id", sa.String(), nullable=False), + sa.Column("idempotency_key", sa.String(), nullable=False), + sa.Column("fingerprint", sa.String(length=64), nullable=False), + sa.Column("target_id", sa.String(), nullable=True), + sa.Column("response_json", sa.JSON(), nullable=True), + sa.Column( + "create_date", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.PrimaryKeyConstraint("actor_id", "idempotency_key"), + ) + op.create_table( + "mutation_audit", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("request_id", sa.Uuid(), nullable=False), + sa.Column("actor_id", sa.String(), nullable=True), + sa.Column("mutation", sa.String(), nullable=False), + sa.Column("resource_type", sa.String(), nullable=False), + sa.Column("resource_id", sa.String(), nullable=True), + sa.Column("outcome", sa.String(), nullable=False), + sa.Column("reason_code", sa.String(), nullable=True), + sa.Column( + "create_date", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("mutation_audit") + op.drop_table("command_idempotency") diff --git a/recap/db/migrations/versions/c9d2e8f4a1b7_add_process_run_lineage.py b/recap/db/migrations/versions/c9d2e8f4a1b7_add_process_run_lineage.py new file mode 100644 index 0000000..8ad65ca --- /dev/null +++ b/recap/db/migrations/versions/c9d2e8f4a1b7_add_process_run_lineage.py @@ -0,0 +1,38 @@ +"""add process run copy lineage + +Revision ID: c9d2e8f4a1b7 +Revises: b47f5e2a9c10 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "c9d2e8f4a1b7" +down_revision = "b47f5e2a9c10" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("process_run") as batch: + batch.add_column(sa.Column("copied_from_id", sa.Uuid(), nullable=True)) + batch.create_foreign_key( + "fk_process_run_copied_from", "process_run", ["copied_from_id"], ["id"] + ) + batch.drop_constraint("uq_process_run_namespace_name", type_="unique") + op.create_index( + "uq_process_run_namespace_name_root", + "process_run", + ["namespace_id", "name"], + unique=True, + sqlite_where=sa.text("copied_from_id IS NULL"), + ) + + +def downgrade() -> None: + op.drop_index("uq_process_run_namespace_name_root", table_name="process_run") + op.create_unique_constraint( + "uq_process_run_namespace_name", "process_run", ["namespace_id", "name"] + ) + op.drop_constraint("fk_process_run_copied_from", "process_run", type_="foreignkey") + op.drop_column("process_run", "copied_from_id") diff --git a/recap/db/namespace.py b/recap/db/namespace.py new file mode 100644 index 0000000..84cf707 --- /dev/null +++ b/recap/db/namespace.py @@ -0,0 +1,82 @@ +from collections.abc import Callable, Mapping +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import JSON, ForeignKey, select +from sqlalchemy.ext.mutable import MutableDict +from sqlalchemy.orm import Mapped, Session, mapped_column, relationship + +from recap.utils.namespace import canonicalize_namespace_path, namespace_ancestors + +from .base import Base, RevisionedLifecycleMixin, TimestampMixin + +SiteMetadataValidator = Callable[ + [Mapping[str, Any], Mapping[str, Any]], + None, +] + + +class Namespace(RevisionedLifecycleMixin, TimestampMixin, Base): + __tablename__ = "namespace" + + id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) + path: Mapped[str] = mapped_column(unique=True, nullable=False) + parent_id: Mapped[UUID | None] = mapped_column( + ForeignKey("namespace.id"), nullable=True + ) + parent: Mapped["Namespace | None"] = relationship( + remote_side=[id], back_populates="children" + ) + children: Mapped[list["Namespace"]] = relationship(back_populates="parent") + metadata_json: Mapped[dict[str, Any]] = mapped_column( + MutableDict.as_mutable(JSON), nullable=False, default=dict + ) + + +class NamespaceRepository: + def __init__( + self, + session: Session, + site_validator: SiteMetadataValidator | None = None, + ) -> None: + self._session = session + self._site_validator = site_validator + + def create(self, path: str, metadata: Mapping[str, Any] | None = None) -> Namespace: + path = canonicalize_namespace_path(path) + local_metadata = dict(metadata or {}) + ancestor_paths = namespace_ancestors(path)[:-1] + parent_path = ancestor_paths[-1] + parent = self._session.scalar( + select(Namespace).where(Namespace.path == parent_path) + ) + if parent is None: + raise LookupError(f"Parent namespace does not exist: {parent_path}") + + inherited = self.effective_metadata(parent.id) + if self._site_validator is not None: + self._site_validator(inherited, local_metadata) + + namespace = Namespace( + path=path, + parent=parent, + metadata_json=local_metadata, + ) + self._session.add(namespace) + return namespace + + def effective_metadata(self, namespace_id: UUID) -> dict[str, Any]: + namespace = self._session.get(Namespace, namespace_id) + if namespace is None: + raise LookupError(f"Namespace does not exist: {namespace_id}") + + lineage = [] + current: Namespace | None = namespace + while current is not None: + lineage.append(current) + current = current.parent + + effective: dict[str, Any] = {} + for item in reversed(lineage): + effective.update(item.metadata_json) + return effective diff --git a/recap/db/process.py b/recap/db/process.py index 12c2acb..b6731e4 100644 --- a/recap/db/process.py +++ b/recap/db/process.py @@ -2,10 +2,20 @@ from collections import namedtuple from uuid import UUID, uuid4 -from sqlalchemy import Enum, ForeignKey, UniqueConstraint, event, func, inspect, select +from sqlalchemy import ( + JSON, + Enum, + ForeignKey, + Index, + UniqueConstraint, + event, + inspect, +) from sqlalchemy.ext import associationproxy +from sqlalchemy.ext.mutable import MutableList from sqlalchemy.orm import ( Mapped, + Session, attribute_mapped_collection, mapped_collection, mapped_column, @@ -14,12 +24,13 @@ validates, ) -from recap.db.campaign import Campaign +from recap.db.namespace import Namespace from recap.db.step import Step, StepTemplate, StepTemplateEdge from recap.exceptions import DuplicateResourceError -from recap.utils.general import Direction +from recap.lifecycle import LifecycleStatus, validate_transition +from recap.utils.general import Direction, make_slug -from .base import Base, TimestampMixin +from .base import Base, RevisionedLifecycleMixin, TimestampMixin if typing.TYPE_CHECKING: from recap.db.resource import Resource, ResourceType @@ -27,12 +38,18 @@ AssignedResource = namedtuple("AssignedResource", ["slot", "resource"]) -class ProcessTemplate(TimestampMixin, Base): +class ProcessTemplate(RevisionedLifecycleMixin, TimestampMixin, Base): __tablename__ = "process_template" id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) - name: Mapped[str] = mapped_column(unique=True, nullable=False) + namespace_id: Mapped[UUID] = mapped_column( + ForeignKey("namespace.id"), nullable=False + ) + namespace: Mapped[Namespace] = relationship() + name: Mapped[str] = mapped_column(nullable=False) version: Mapped[str] = mapped_column(nullable=False) - is_active: Mapped[bool] = mapped_column(nullable=False, default=False) + labels: Mapped[list[str]] = mapped_column( + MutableList.as_mutable(JSON), nullable=False, default=list + ) step_templates: Mapped[dict[str, "StepTemplate"]] = relationship( back_populates="process_template", collection_class=mapped_collection(lambda st: st.name), @@ -46,9 +63,36 @@ class ProcessTemplate(TimestampMixin, Base): "ResourceSlot", back_populates="process_template" ) __table_args__ = ( - UniqueConstraint("name", "version", name="uq_process_template_name_version"), + UniqueConstraint( + "namespace_id", + "name", + "version", + name="uq_process_template_namespace_name_version", + ), ) + @validates("labels") + def _normalize_labels(self, key, labels): + return [make_slug(label) for label in labels] + + def activate(self, *, bump_revision: bool = True): + source_status = self.status or LifecycleStatus.MUTABLE + validate_transition(source_status, LifecycleStatus.ACTIVE) + if source_status is LifecycleStatus.ACTIVE: + return + self.status = LifecycleStatus.ACTIVE + if bump_revision and inspect(self).persistent and self.revision is not None: + self.revision += 1 + + def archive(self, *, bump_revision: bool = True): + source_status = self.status or LifecycleStatus.MUTABLE + validate_transition(source_status, LifecycleStatus.ARCHIVED) + if source_status is LifecycleStatus.ARCHIVED: + return + self.status = LifecycleStatus.ARCHIVED + if bump_revision and inspect(self).persistent and self.revision is not None: + self.revision += 1 + class ResourceSlot(TimestampMixin, Base): __tablename__ = "resource_slot" @@ -76,12 +120,22 @@ class ResourceSlot(TimestampMixin, Base): ) -class ProcessRun(TimestampMixin, Base): +class ProcessRun(RevisionedLifecycleMixin, TimestampMixin, Base): __tablename__ = "process_run" id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) - name: Mapped[str] = mapped_column(unique=True, nullable=False) + namespace_id: Mapped[UUID] = mapped_column( + ForeignKey("namespace.id"), nullable=False + ) + namespace: Mapped[Namespace] = relationship() + name: Mapped[str] = mapped_column(nullable=False) description: Mapped[str] = mapped_column(unique=False, nullable=False) + copied_from_id: Mapped[UUID | None] = mapped_column( + ForeignKey("process_run.id"), nullable=True + ) + copied_from: Mapped["ProcessRun | None"] = relationship( + "ProcessRun", foreign_keys=[copied_from_id], remote_side=[id] + ) process_template_id: Mapped[UUID] = mapped_column( ForeignKey("process_template.id"), nullable=False @@ -106,15 +160,23 @@ class ProcessRun(TimestampMixin, Base): back_populates="process_run", collection_class=mapped_collection(lambda s: s.name), ) - campaign_id: Mapped[UUID] = mapped_column(ForeignKey("campaign.id"), nullable=False) - campaign: Mapped[Campaign] = relationship("Campaign", back_populates="process_runs") + __table_args__ = ( + Index( + "uq_process_run_namespace_name_root", + "namespace_id", + "name", + unique=True, + sqlite_where=(copied_from_id.is_(None)), + ), + ) def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) template: ProcessTemplate | None = kwargs.get("template") if template is None: raise ValueError("Missing template for ProcessRun") - for step_template in template.step_templates.values(): + step_templates = list(template.step_templates.values()) + super().__init__(*args, **kwargs) + for step_template in step_templates: step = Step(template=step_template, name=step_template.name) # Attach via the mapped collection to ensure the dict key is derived # from the final name instead of a default/None during construction. @@ -130,6 +192,11 @@ def _check_assignment(self, key, assignment: "ResourceAssignment"): # noqa slot = assignment.resource_slot resource = assignment.resource + if slot.process_template is not self.template: + raise ValueError( + f"Slot {slot.name} does not belong to process template {self.template.name}" + ) + # Resource must advertise the slot's type via its template's types resource_type_ids = {rt.id for rt in resource.template.types} if slot.resource_type_id not in resource_type_ids: @@ -188,30 +255,30 @@ def assigned_resources(self): ar.append(AssignedResource(slot=resource_slot, resource=resource)) return ar + def finalize(self, *, bump_revision: bool = True): + source_status = self.status or LifecycleStatus.MUTABLE + validate_transition(source_status, LifecycleStatus.ACTIVE) + if source_status is LifecycleStatus.ACTIVE: + return + self.status = LifecycleStatus.ACTIVE + if bump_revision and inspect(self).persistent and self.revision is not None: + self.revision += 1 + + def archive(self, *, bump_revision: bool = True): + source_status = self.status or LifecycleStatus.MUTABLE + validate_transition(source_status, LifecycleStatus.ARCHIVED) + if source_status is LifecycleStatus.ARCHIVED: + return + self.status = LifecycleStatus.ARCHIVED + if bump_revision and inspect(self).persistent and self.revision is not None: + self.revision += 1 + @event.listens_for(ProcessTemplate, "before_update", propagate=True) -@event.listens_for(ProcessTemplate, "before_delete", propagate=True) -def _guard_process_template(mapper, connection, target: ProcessTemplate): - state = inspect(target) - column_changes = [ - col.key - for col in mapper.column_attrs - if state.attrs[col.key].history.has_changes() - and col.key not in {"modified_date"} - ] - if not column_changes: - return - count_stmt = ( - select(func.count()) - .select_from(ProcessRun) - .where(ProcessRun.process_template_id == target.id) - ) - cnt = connection.scalar(count_stmt) - if cnt and cnt > 0: - raise ValueError( - "Cannot modify or delete a process template with existing runs. " - "Create a new template version instead." - ) +@event.listens_for(ProcessRun, "before_update", propagate=True) +def _guard_process_namespace_id(mapper, connection, target): + if inspect(target).attrs.namespace_id.history.has_changes(): + raise ValueError("namespace_id is immutable") class ResourceAssignment(TimestampMixin, Base): @@ -234,11 +301,11 @@ class ResourceAssignment(TimestampMixin, Base): step: Mapped["Step | None"] = relationship("Step", back_populates="assignments") @validates("resource") - def _check_resource_campaign_uniqueness(self, key, resource: "Resource"): - if self.process_run and self.process_run.campaign: + def _check_resource_namespace_uniqueness(self, key, resource: "Resource"): + if self.process_run: if self.process_run_id is None or self.resource_slot_id is None: return resource - campaign_id = self.process_run.campaign.id + namespace_id = self.process_run.namespace_id this_step_id = self.step_id or ( self.step.id if self.step is not None else None ) @@ -278,13 +345,13 @@ def _check_resource_campaign_uniqueness(self, key, resource: "Resource"): continue if ( assignment.process_run - and assignment.process_run.campaign_id == campaign_id + and assignment.process_run.namespace_id == namespace_id and assignment.resource.parent_id == resource.parent_id and other_step_id == this_step_id ): raise DuplicateResourceError( resource.name, - assignment.process_run.campaign.name, + assignment.process_run.namespace.path, assignment.process_run.name, assignment.step.name if assignment.step else None, ) @@ -295,3 +362,157 @@ def _check_resource_campaign_uniqueness(self, key, resource: "Resource"): "process_run_id", "resource_slot_id", "step_id", name="uq_run_slot_step" ), ) + + +def _relationship_value(obj, name): + value = getattr(obj, name, None) + if value is not None: + return value + state = inspect(obj) + if name not in state.attrs: + return None + deleted = state.attrs[name].history.deleted + return deleted[0] if deleted else None + + +def _resource_root(resource): + current = resource + while (parent := _relationship_value(current, "parent")) is not None: + current = parent + return current + + +def _resource_template_root(template): + from recap.db.resource import ROOT_RESOURCE_TEMPLATE_ID + + current = template + while ( + parent := _relationship_value(current, "parent") + ) is not None and parent.id != ROOT_RESOURCE_TEMPLATE_ID: + current = parent + return current + + +def _aggregate_root(session, obj): # noqa: C901 + from recap.db.attribute import ( + AttributeGroupTemplate, + AttributeTemplate, + AttributeValue, + ) + from recap.db.resource import Property, Resource, ResourceTemplate + from recap.db.step import ( + Parameter, + Step, + StepTemplateResourceSlotBinding, + ) + + if isinstance(obj, ProcessTemplate | ProcessRun): + return obj + if isinstance(obj, StepTemplate): + return _relationship_value(obj, "process_template") + if isinstance(obj, ResourceSlot): + return _relationship_value(obj, "process_template") + if isinstance(obj, StepTemplateResourceSlotBinding): + step_template = _relationship_value(obj, "step_template") + return _aggregate_root(session, step_template) if step_template else None + if isinstance(obj, ResourceTemplate): + return _resource_template_root(obj) + if isinstance(obj, AttributeGroupTemplate): + owner = _relationship_value(obj, "resource_template") or _relationship_value( + obj, "step_template" + ) + return _aggregate_root(session, owner) if owner else None + if isinstance(obj, AttributeTemplate): + group = _relationship_value(obj, "attribute_group_template") + return _aggregate_root(session, group) if group else None + if isinstance(obj, Resource): + return _resource_root(obj) + if isinstance(obj, Property): + resource = _relationship_value(obj, "resource") + return _resource_root(resource) if resource else None + if isinstance(obj, AttributeValue): + prop = _relationship_value(obj, "property") + if prop is not None: + return _aggregate_root(session, prop) + parameter = _relationship_value(obj, "parameter") + return _aggregate_root(session, parameter) if parameter else None + if isinstance(obj, Parameter): + step = _relationship_value(obj, "step") + return _aggregate_root(session, step) if step else None + if isinstance(obj, Step): + return _relationship_value(obj, "process_run") + return None + + +def _source_status(root): + history = inspect(root).attrs.status.history + return history.deleted[0] if history.deleted else root.status + + +def _validate_status_change(root): + history = inspect(root).attrs.status.history + if history.has_changes() and history.deleted: + validate_transition(history.deleted[0], root.status) + + +@event.listens_for(Session, "before_flush") +def _enforce_provenance_lifecycle(session, flush_context, instances): # noqa: C901 + from recap.db.resource import Resource + + # Resource creation freezes its template definition. Process-run inputs and + # outputs freeze only when the run is finalized. + for obj in tuple(session.new): + if isinstance(obj, Resource) and obj.template is not None: + _resource_template_root(obj.template).activate() + + for obj in tuple(session.dirty): + if not isinstance(obj, ProcessRun) or obj.status is not LifecycleStatus.ACTIVE: + continue + history = inspect(obj).attrs.status.history + if not history.has_changes(): + continue + if obj.template is not None: + obj.template.activate() + for assignment in obj.assignments.values(): + if assignment.resource is not None: + _resource_root(assignment.resource).activate() + + lifecycle_roots = { + obj + for obj in session.dirty + if isinstance(obj, ProcessTemplate | ProcessRun | Resource) + } + from recap.db.resource import ResourceTemplate + + lifecycle_roots.update( + obj for obj in session.dirty if isinstance(obj, ResourceTemplate) + ) + for root in lifecycle_roots: + _validate_status_change(root) + + changed = set(session.new) | set(session.dirty) | set(session.deleted) + for obj in changed: + root = _aggregate_root(session, obj) + if root is None or root in session.new: + continue + source_status = _source_status(root) + if source_status is LifecycleStatus.MUTABLE: + continue + if obj is root: + state = inspect(obj) + changed_keys = { + attr.key + for attr in state.attrs + if attr.history.has_changes() + and attr.key + not in {"assignments", "revision", "status", "modified_date"} + } + if not changed_keys and obj not in session.deleted: + continue + if isinstance(root, ProcessTemplate): + raise ValueError("Cannot mutate an active process template aggregate") + if isinstance(root, ProcessRun): + raise ValueError("Cannot mutate a finalized process run aggregate") + if isinstance(root, ResourceTemplate): + raise ValueError("Cannot mutate an active resource template aggregate") + raise ValueError("Cannot mutate an active resource aggregate") diff --git a/recap/db/resource.py b/recap/db/resource.py index 800dfa3..1e77e07 100644 --- a/recap/db/resource.py +++ b/recap/db/resource.py @@ -2,25 +2,35 @@ from uuid import UUID, uuid4 from sqlalchemy import ( + JSON, Column, ForeignKey, Table, UniqueConstraint, event, - func, inspect, select, ) from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.orm import Mapped, mapped_collection, mapped_column, relationship +from sqlalchemy.ext.mutable import MutableList +from sqlalchemy.orm import ( + Mapped, + mapped_collection, + mapped_column, + object_session, + relationship, + validates, +) +from recap.db.namespace import Namespace from recap.db.process import ResourceAssignment +from recap.lifecycle import LifecycleStatus, validate_transition from recap.utils.general import make_slug if TYPE_CHECKING: from recap.db.attribute import AttributeGroupTemplate -from .base import Base, TimestampMixin +from .base import Base, RevisionedLifecycleMixin, TimestampMixin # Sentinel for root ResourceTemplate ROOT_RESOURCE_TEMPLATE_ID = UUID("00000000-0000-0000-0000-000000000001") @@ -64,8 +74,7 @@ def __init__(self, *args, **kwargs): template: AttributeGroupTemplate = kwargs.get("template") super().__init__(*args, **kwargs) for vt in template.attribute_templates: - av = AttributeValue(template=vt, property=self) - av.set_value(vt.default_value) + AttributeValue(template=vt, property=self) resource_template_type_association = Table( @@ -78,12 +87,19 @@ def __init__(self, *args, **kwargs): ) -class ResourceTemplate(TimestampMixin, Base): +class ResourceTemplate(RevisionedLifecycleMixin, TimestampMixin, Base): __tablename__ = "resource_template" id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) + namespace_id: Mapped[UUID] = mapped_column( + ForeignKey("namespace.id"), nullable=False + ) + namespace: Mapped[Namespace] = relationship() name: Mapped[str] = mapped_column(nullable=False) slug: Mapped[str] = mapped_column(nullable=True) version: Mapped[str] = mapped_column(nullable=False, default="1.0") + labels: Mapped[list[str]] = mapped_column( + MutableList.as_mutable(JSON), nullable=False, default=list + ) types: Mapped[list["ResourceType"]] = relationship( "ResourceType", @@ -110,13 +126,41 @@ class ResourceTemplate(TimestampMixin, Base): __table_args__ = ( UniqueConstraint( + "namespace_id", "parent_id", "name", "version", - name="uq_resource_template_parent_name_version", + name="uq_resource_template_namespace_parent_name_version", ), ) + @validates("labels") + def _normalize_labels(self, key, labels): + return [make_slug(label) for label in labels] + + @validates("children") + def _inherit_child_namespace(self, key, child): + child.namespace = self.namespace + return child + + def activate(self, *, bump_revision: bool = True): + source_status = self.status or LifecycleStatus.MUTABLE + validate_transition(source_status, LifecycleStatus.ACTIVE) + if source_status is LifecycleStatus.ACTIVE: + return + self.status = LifecycleStatus.ACTIVE + if bump_revision and inspect(self).persistent and self.revision is not None: + self.revision += 1 + + def archive(self, *, bump_revision: bool = True): + source_status = self.status or LifecycleStatus.MUTABLE + validate_transition(source_status, LifecycleStatus.ARCHIVED) + if source_status is LifecycleStatus.ARCHIVED: + return + self.status = LifecycleStatus.ARCHIVED + if bump_revision and inspect(self).persistent and self.revision is not None: + self.revision += 1 + # --- Keep slug always in sync with name --- @event.listens_for(ResourceTemplate, "before_insert", propagate=True) @@ -135,6 +179,8 @@ def _before_insert_resource_template(mapper, connection, target: ResourceTemplat name="__root__", slug="__root__", version="1.0", + namespace_id=target.namespace_id, + labels=[], parent_id=None, ) ) @@ -152,12 +198,21 @@ class ResourceType(TimestampMixin, Base): name: Mapped[str] = mapped_column(unique=True, nullable=False) -class Resource(TimestampMixin, Base): +class Resource(RevisionedLifecycleMixin, TimestampMixin, Base): __tablename__ = "resource" id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) + namespace_id: Mapped[UUID] = mapped_column( + ForeignKey("namespace.id"), nullable=False + ) + namespace: Mapped[Namespace] = relationship() name: Mapped[str] = mapped_column(nullable=False) slug: Mapped[str | None] = mapped_column(nullable=True, index=True) - active: Mapped[bool] = mapped_column(nullable=False, default=True) + copied_from_id: Mapped[UUID | None] = mapped_column( + ForeignKey("resource.id"), nullable=True + ) + copied_from: Mapped["Resource | None"] = relationship( + "Resource", foreign_keys=[copied_from_id], remote_side=[id] + ) resource_template_id: Mapped[UUID] = mapped_column( ForeignKey("resource_template.id"), nullable=True ) @@ -166,11 +221,15 @@ class Resource(TimestampMixin, Base): ForeignKey("resource.id"), nullable=True ) parent: Mapped["Resource"] = relationship( - "Resource", back_populates="children", remote_side=[id] + "Resource", + back_populates="children", + remote_side=[id], + foreign_keys=[parent_id], ) children: Mapped[dict[str, "Resource"]] = relationship( "Resource", back_populates="parent", + foreign_keys=[parent_id], collection_class=mapped_collection(lambda c: c.name), ) properties = relationship( @@ -232,13 +291,19 @@ def _initialize_from_resource_template( for child_ct in self.template.children.values(): if child_ct.id is not None and child_ct.id in visited: continue - Resource( + child = Resource( name=child_ct.name, template=child_ct, parent=self, + namespace=self.namespace, + _init_children=False, _visited_children=visited, _max_depth=max_depth - 1, ) + session = object_session(self) + if session is not None: + session.add(child) + child._initialize_from_resource_template(child_ct, visited, max_depth - 1) __table_args__ = ( UniqueConstraint( @@ -248,21 +313,29 @@ def _initialize_from_resource_template( ), ) + def activate(self, *, bump_revision: bool = True): + source_status = self.status or LifecycleStatus.MUTABLE + validate_transition(source_status, LifecycleStatus.ACTIVE) + if source_status is LifecycleStatus.ACTIVE: + return + self.status = LifecycleStatus.ACTIVE + if bump_revision and inspect(self).persistent and self.revision is not None: + self.revision += 1 + + def archive(self, *, bump_revision: bool = True): + source_status = self.status or LifecycleStatus.MUTABLE + validate_transition(source_status, LifecycleStatus.ARCHIVED) + if source_status is LifecycleStatus.ARCHIVED: + return + self.status = LifecycleStatus.ARCHIVED + if bump_revision and inspect(self).persistent and self.revision is not None: + self.revision += 1 + # --- Keep slug always in sync with name --- @event.listens_for(Resource, "before_insert", propagate=True) def _before_insert(mapper, connection, target: Resource): target.slug = make_slug(target.name) - # Set active as True - if target.active is None: - target.active = True - - # Update all other copies of the resource to active = False - if target.active and target.name: - tbl = Resource.__table__ - connection.execute( - tbl.update().where(tbl.c.name == target.name).values(active=False) - ) def _descendant_templates_cte(template_id): @@ -272,41 +345,14 @@ def _descendant_templates_cte(template_id): return base.union_all(descendants) -@event.listens_for(ResourceTemplate, "before_update", propagate=True) -@event.listens_for(ResourceTemplate, "before_delete", propagate=True) -def _guard_resource_template(mapper, connection, target: ResourceTemplate): - state = inspect(target) - column_changes = [ - col.key - for col in mapper.column_attrs - if state.attrs[col.key].history.has_changes() - and col.key not in {"modified_date"} - ] - if not column_changes: - return - cte = _descendant_templates_cte(target.id) - res_tbl = Resource.__table__ - count_stmt = ( - select(func.count()) - .select_from(res_tbl) - .where(res_tbl.c.resource_template_id.in_(select(cte.c.id))) - ) - cnt = connection.scalar(count_stmt) - if cnt and cnt > 0: - raise ValueError( - "Cannot modify or delete a resource template that already has resources. " - "Create a new template version instead." - ) - - @event.listens_for(Resource, "before_update", propagate=True) def _before_update(mapper, connection, target: Resource): target.slug = make_slug(target.name) - # If this resource is set to active, set others as inactive - if target.active and target.name: - tbl = Resource.__table__ - connection.execute( - tbl.update() - .where(tbl.c.name == target.name, tbl.c.id != target.id) - .values(active=False) - ) + if inspect(target).attrs.namespace_id.history.has_changes(): + raise ValueError("namespace_id is immutable") + + +@event.listens_for(ResourceTemplate, "before_update", propagate=True) +def _guard_resource_template_namespace_id(mapper, connection, target): + if inspect(target).attrs.namespace_id.history.has_changes(): + raise ValueError("namespace_id is immutable") diff --git a/recap/db/step.py b/recap/db/step.py index 6c9cc81..bf888b9 100644 --- a/recap/db/step.py +++ b/recap/db/step.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING from uuid import UUID, uuid4 -from sqlalchemy import ForeignKey, UniqueConstraint, event, func, inspect, select +from sqlalchemy import ForeignKey, UniqueConstraint, func from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import ( Mapped, @@ -55,8 +55,7 @@ class Parameter(TimestampMixin, Base): # , AttributeValueMixin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for value_template in self.template.attribute_templates: - av = AttributeValue(template=value_template, parameter=self) - av.set_value(value_template.default_value) + AttributeValue(template=value_template, parameter=self) class StepTemplate(TimestampMixin, Base): @@ -94,35 +93,6 @@ class StepTemplate(TimestampMixin, Base): ) -@event.listens_for(StepTemplate, "before_update", propagate=True) -@event.listens_for(StepTemplate, "before_delete", propagate=True) -def _guard_step_template(mapper, connection, target: StepTemplate): - # Prevent editing/deleting a step template if runs already exist for the parent process template. - from recap.db.process import ProcessRun - - state = inspect(target) - column_changes = [ - col.key - for col in mapper.column_attrs - if state.attrs[col.key].history.has_changes() - and col.key not in {"modified_date"} - ] - if not column_changes: - return - - count_stmt = ( - select(func.count()) - .select_from(ProcessRun) - .where(ProcessRun.process_template_id == target.process_template_id) - ) - cnt = connection.scalar(count_stmt) - if cnt and cnt > 0: - raise ValueError( - "Cannot modify or delete a process template step when runs exist. " - "Create a new process template version instead." - ) - - class StepTemplateResourceSlotBinding(TimestampMixin, Base): __tablename__ = "step_template_resource_slot_binding" id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) diff --git a/recap/dsl/__init__.py b/recap/dsl/__init__.py index 03bb6f4..8e6f583 100644 --- a/recap/dsl/__init__.py +++ b/recap/dsl/__init__.py @@ -1 +1,55 @@ -# DSL builder and query modules. +"""Public query and builder APIs. + +Imports are resolved lazily to keep adapter and schema initialization acyclic. +""" + +__all__ = [ + "AttributeGroupBuilder", + "BaseQuery", + "DraftAttributeGroupBuilder", + "Field", + "FieldOrdering", + "FieldPredicate", + "NamespaceQuery", + "ProcessRunBuilder", + "ProcessRunQuery", + "ProcessTemplateBuilder", + "ProcessTemplateQuery", + "QueryDSL", + "ResourceBuilder", + "ResourceQuery", + "ResourceTemplateBuilder", + "ResourceTemplateQuery", + "StepTemplateBuilder", +] + +_MODULES = { + "AttributeGroupBuilder": ".attribute_builder", + "BaseQuery": ".query", + "Field": ".query", + "FieldOrdering": ".query", + "FieldPredicate": ".query", + "NamespaceQuery": ".query", + "ProcessRunQuery": ".query", + "ProcessTemplateQuery": ".query", + "QueryDSL": ".query", + "ResourceQuery": ".query", + "ResourceTemplateQuery": ".query", + "DraftAttributeGroupBuilder": ".process_builder", + "ProcessRunBuilder": ".process_builder", + "ProcessTemplateBuilder": ".process_builder", + "StepTemplateBuilder": ".process_builder", + "ResourceBuilder": ".resource_builder", + "ResourceTemplateBuilder": ".resource_builder", +} + + +def __getattr__(name): + module_name = _MODULES.get(name) + if module_name is None: + raise AttributeError(name) + from importlib import import_module + + value = getattr(import_module(module_name, __name__), name) + globals()[name] = value + return value diff --git a/recap/dsl/attribute_builder.py b/recap/dsl/attribute_builder.py index 528ba10..bb4212d 100644 --- a/recap/dsl/attribute_builder.py +++ b/recap/dsl/attribute_builder.py @@ -1,8 +1,6 @@ import typing from typing import Any, Generic, TypeVar -from recap.adapter import Backend - if typing.TYPE_CHECKING: from recap.dsl.process_builder import StepTemplateBuilder from recap.dsl.resource_builder import ResourceTemplateBuilder @@ -20,9 +18,9 @@ def __init__( ): self.group_name = group_name self.parent: ParentType = parent - self.backend: Backend = parent.backend - self._attribute_group = self.backend.add_attr_group( - self.group_name, self.parent._template + raise RuntimeError( + "AttributeGroupBuilder direct mutation is unsupported; use a " + "command-backed template builder" ) def add_attribute( @@ -33,19 +31,10 @@ def add_attribute( default: Any, metadata: dict[str, Any] | None = None, ) -> "AttributeGroupBuilder[ParentType]": - self.backend.add_attribute( - attr_name, - value_type, - unit, - default, - self._attribute_group, - metadata=metadata, - ) - return self + raise RuntimeError("AttributeGroupBuilder direct mutation is unsupported") def remove_attribute(self, attr_name: str) -> "AttributeGroupBuilder": - self.backend.remove_attribute(attr_name) - return self + raise RuntimeError("AttributeGroupBuilder direct mutation is unsupported") def close_group(self) -> ParentType: return self.parent diff --git a/recap/dsl/builder_state.py b/recap/dsl/builder_state.py new file mode 100644 index 0000000..4ec8fd1 --- /dev/null +++ b/recap/dsl/builder_state.py @@ -0,0 +1,61 @@ +from typing import Any + +from pydantic import BaseModel, Field + +from recap.lifecycle import LifecycleStatus + + +class BuilderChanges(BaseModel): + fields: dict[str, Any] = Field(default_factory=dict) + lifecycle: LifecycleStatus | None = None + + +class BuilderTransactionState: + def __init__(self) -> None: + self._depth = 0 + self._pending_lifecycle: LifecycleStatus | None = None + self._owned_lifecycle: dict[object, LifecycleStatus] = {} + self._failed = False + + @property + def in_context(self) -> bool: + return self._depth > 0 + + @property + def pending_lifecycle(self) -> LifecycleStatus | None: + return self._pending_lifecycle + + def pending_lifecycle_for(self, owner: object) -> LifecycleStatus | None: + return self._owned_lifecycle.get(owner) + + def enter(self) -> None: + if self._depth == 0: + self._failed = False + self._depth += 1 + + def exit(self, exc_type: type[BaseException] | None) -> bool: + if exc_type is not None: + self._failed = True + self._depth -= 1 + if self._depth < 0: + raise RuntimeError("Builder context depth underflow") + return self._depth == 0 and not self._failed + + def request_lifecycle( + self, status: LifecycleStatus, *, owner: object | None = None + ) -> None: + if owner is not None: + pending = self._owned_lifecycle.get(owner) + if pending not in (None, status): + raise ValueError("Conflicting lifecycle requests") + self._owned_lifecycle[owner] = status + return + if self._pending_lifecycle not in (None, status): + raise ValueError("Conflicting lifecycle requests") + self._pending_lifecycle = status + + def clear_lifecycle(self, *, owner: object | None = None) -> None: + if owner is not None: + self._owned_lifecycle.pop(owner, None) + return + self._pending_lifecycle = None diff --git a/recap/dsl/drafts.py b/recap/dsl/drafts.py new file mode 100644 index 0000000..ac6ca32 --- /dev/null +++ b/recap/dsl/drafts.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from recap.schemas.attribute import AttributeTemplateValidator, TypeName +from recap.utils.general import Direction + + +class DraftModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +def detached_model(model: BaseModel) -> BaseModel: + """Copy caller-owned models before using them as builder draft state.""" + return model.model_copy(deep=True) + + +class AttributeDraft(DraftModel): + name: str = Field(min_length=1) + type: TypeName + unit: str = "" + default: Any = None + metadata: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_definition(self): + AttributeTemplateValidator.model_validate(self.model_dump()) + return self + + +class AttributeGroupDraft(DraftModel): + name: str = Field(min_length=1) + attributes: tuple[AttributeDraft, ...] = () + + @model_validator(mode="after") + def validate_attribute_names(self): + _require_unique(self.attributes, "attribute") + return self + + +class ResourceSlotDraft(DraftModel): + name: str = Field(min_length=1) + resource_type: str = Field(min_length=1) + direction: Direction + required: bool = True + create_resource_type: bool = False + + +class StepTemplateDraft(DraftModel): + name: str = Field(min_length=1) + parameter_groups: tuple[AttributeGroupDraft, ...] = () + role_bindings: dict[str, str] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_group_names(self): + _require_unique(self.parameter_groups, "parameter group") + if any(not role or not slot for role, slot in self.role_bindings.items()): + raise ValueError("Role and resource slot names must not be empty") + return self + + +class ProcessTemplateDraft(DraftModel): + id: UUID | None = None + name: str = Field(min_length=1) + version: str = Field(min_length=1) + labels: tuple[str, ...] = () + resource_slots: tuple[ResourceSlotDraft, ...] = () + steps: tuple[StepTemplateDraft, ...] = () + + @model_validator(mode="after") + def validate_nested_names_and_references(self): + _require_unique(self.resource_slots, "resource slot") + _require_unique(self.steps, "step") + slot_names = {slot.name for slot in self.resource_slots} + for step in self.steps: + for role, slot_name in step.role_bindings.items(): + if slot_name not in slot_names: + raise ValueError( + f"Role {role!r} on step {step.name!r} references unknown " + f"resource slot {slot_name!r}" + ) + return self + + +class ResourceTemplateDraft(DraftModel): + id: UUID | None = None + name: str = Field(min_length=1) + version: str = Field(min_length=1) + labels: tuple[str, ...] = () + type_names: tuple[str, ...] = () + property_groups: tuple[AttributeGroupDraft, ...] = () + children: tuple[ResourceTemplateDraft, ...] = () + + @model_validator(mode="after") + def validate_nested_names(self): + _require_unique(self.property_groups, "property group") + _require_unique(self.children, "child template") + return self + + +class ProcessRunStepDraft(DraftModel): + parameters: dict[str, dict[str, Any]] = Field(default_factory=dict) + + +class ProcessRunDraft(DraftModel): + id: UUID | None = None + name: str = Field(min_length=1) + description: str + template_id: UUID + assignments: dict[str, UUID] = Field(default_factory=dict) + steps: dict[str, ProcessRunStepDraft] = Field(default_factory=dict) + + +def _require_unique(items, kind: str) -> None: + seen: set[str] = set() + for item in items: + if item.name in seen: + raise ValueError(f"Duplicate {kind} name: {item.name!r}") + seen.add(item.name) diff --git a/recap/dsl/process_builder.py b/recap/dsl/process_builder.py index 40ab8f8..962460c 100644 --- a/recap/dsl/process_builder.py +++ b/recap/dsl/process_builder.py @@ -1,94 +1,206 @@ import warnings from typing import Any, Literal -from uuid import UUID +from uuid import UUID, uuid4 -from pydantic import BaseModel +from pydantic import BaseModel, create_model from sqlalchemy.exc import NoResultFound -from recap.adapter import Backend +from recap.client.backend import ClientBackend +from recap.commands.models import ( + CommandContext, + CopyProcessRun, + CreateProcessRun, + CreateProcessTemplate, + SetLifecycleStatus, + UpdateProcessRun, + UpdateProcessTemplate, +) from recap.dsl.attribute_builder import AttributeGroupBuilder +from recap.dsl.builder_state import BuilderChanges, BuilderTransactionState +from recap.dsl.drafts import ( + AttributeDraft, + AttributeGroupDraft, + ProcessRunDraft, + ProcessRunStepDraft, + ProcessTemplateDraft, + ResourceSlotDraft, + StepTemplateDraft, + detached_model, +) from recap.dsl.query import QuerySpec from recap.exceptions import ( ExistingProcessRunError, ExistingProcessRunWarning, ExistingProcessTemplateError, ExistingProcessTemplateWarning, + RecapNotFoundError, ) +from recap.lifecycle import LifecycleStatus from recap.schemas.attribute import AttributeTemplateValidator +from recap.schemas.namespace import NamespaceContext from recap.schemas.process import ( - CampaignSchema, ProcessRunSchema, ProcessTemplateRef, ProcessTemplateSchema, ) -from recap.schemas.resource import ResourceSchema, ResourceSlotSchema +from recap.schemas.resource import ResourceAssignmentSchema, ResourceSchema from recap.schemas.step import StepSchema, StepTemplateRef -from recap.utils.dsl import lock_instance_fields +from recap.utils.dsl import build_step_parameters_model, lock_instance_fields from recap.utils.general import Direction class ProcessTemplateBuilder: - def __init__( + """Builder for process templates, resource slots, and step templates. + + Context-manager exit commits clean work and rolls back exceptions. Existing + templates load by UUID; ``on_existing`` controls identity reuse, while draft + validation reports invalid slots, steps, or parameters before submission. + """ + + def __init__( # noqa: C901 self, - backend: Backend, name: str | None, version: str | None, + *, + backend: ClientBackend, + namespace_context: NamespaceContext, + command_context: CommandContext, process_template_id: UUID | None = None, on_existing: Literal["silent", "warn", "raise"] = "warn", ): self.backend = backend - self._uow = None + self.namespace_context = namespace_context + self._command_context = command_context + self._submitted = False + self._last_draft = None + self._transaction = BuilderTransactionState() + self._expected_revision = 1 + self._draft_labels: list[str] = [] + self._draft_resource_slots: list[ResourceSlotDraft] = [] + self._draft_steps: list[StepTemplateBuilder] = [] if on_existing not in {"silent", "warn", "raise"}: raise ValueError("on_existing must be one of: 'silent', 'warn', 'raise'") self.on_existing = on_existing - try: - self._ensure_uow() - self.name = name - self.version = version - self._template: ProcessTemplateRef | None = None - self._resource_slots: dict[str, ResourceSlotSchema] = {} - self._current_step_builder: StepTemplateBuilder | None = None - self._initialize_template(process_template_id) - except Exception: - if self._uow: - self._uow.rollback() - self._uow = None - raise - - def _initialize_template(self, process_template_id: UUID | None): + self.name = name + self.version = version + self._template: ProcessTemplateRef | None = None + self._is_new_template = process_template_id is None + self._draft_model: ProcessTemplateSchema | None = None + self._resource_slots = {} + self._current_step_builder = None if process_template_id is not None: - tmpl = self.backend.get_process_template( - name=None, version=None, id=process_template_id, expand=False - ) - self.name = tmpl.name - self.version = tmpl.version - self._template = tmpl - return - if self.name is None or self.version is None: + self._initialize_command_update(process_template_id) + elif name is None or version is None: raise ValueError( "name and version are required to create a process template" ) + else: + self._template = ProcessTemplateRef.model_construct( + id=uuid4(), + create_date=None, + modified_date=None, + namespace_id=self.namespace_context.id, + status=LifecycleStatus.MUTABLE, + revision=1, + name=name, + version=version, + labels=[], + ) + existing = self.backend.query( + ProcessTemplateSchema, + QuerySpec( + filters={"name": name, "version": version}, + preloads=["step_templates", "resource_slots"], + include_mutable=True, + ), + namespace_path=self.namespace_context.path, + ) + if existing: + if on_existing == "raise": + raise ExistingProcessTemplateError( + f"Process template {name!r} version {version!r} already exists" + ) + if on_existing == "warn": + warnings.warn( + f"Process template {name!r} version {version!r} already exists and will be reused; bump the version", + ExistingProcessTemplateWarning, + stacklevel=2, + ) + self._template = existing[0] + self._expected_revision = existing[0].revision + self._initialize_command_update(existing[0].id) def __enter__(self): - self._ensure_uow() - if self._template is not None: - self._reload_template() - self.name = self._template.name - self.version = self._template.version + self._transaction.enter() return self def __exit__(self, exc_type, exc, tb): - if exc_type is None: - self.save() - else: - if self._uow: - self._uow.rollback() - self._uow = None + if self._transaction.exit(exc_type): + self._flush() def save(self): - self._ensure_uow() - self._uow.commit() - self._uow = None + if not self._transaction.in_context: + raise RuntimeError("Builder changes require a context manager") + return self._flush() + + def changes(self) -> BuilderChanges: + draft = self._build_draft() + return BuilderChanges( + fields={"draft": draft.model_dump(mode="json")} + if draft != self._last_draft + else {}, + lifecycle=self._transaction.pending_lifecycle, + ) + + def _flush(self): + draft = self._build_draft() + if draft != self._last_draft: + command = ( + CreateProcessTemplate( + namespace_path=self.namespace_context.path, draft=draft + ) + if self._is_new_template + else UpdateProcessTemplate( + template_id=self._template.id, + expected_revision=self._expected_revision, + draft=draft, + ) + ) + result = self.backend._execute(command, self._command_context) + if result is None: + return self + self._template = result + self._expected_revision = result.revision + self._last_draft = draft + self._submitted = True + elif self._template is not None: + self._last_draft = draft + + pending = self._transaction.pending_lifecycle + if pending is None: + return self + self._ensure_template() + result = self.backend._execute( + SetLifecycleStatus( + object_type="process_template", + object_id=self.template.id, + expected_revision=self._template.revision, + status=pending.value, + ), + self._command_context, + ) + if isinstance(result, ProcessTemplateSchema): + self._template = result + self._expected_revision = result.revision + self._transaction.clear_lifecycle() + return self + + def finalize(self): + self._transaction.request_lifecycle(LifecycleStatus.ACTIVE) + return self + + def archive(self): + self._transaction.request_lifecycle(LifecycleStatus.ARCHIVED) return self @property @@ -100,43 +212,9 @@ def template(self) -> ProcessTemplateRef: return self._template def _ensure_template(self): - self._ensure_uow() if self._template: return - if self.name is None or self.version is None: - raise ValueError( - "name and version are required to create a process template" - ) - try: - created = self.backend.create_process_template(self.name, self.version) - if created is None: - raise ValueError("Process template already exists") - self._template = created - except Exception as exc: - if self.on_existing == "raise": - raise ExistingProcessTemplateError( - f"Process template {self.name!r} version {self.version!r} already exists" - ) from exc - self._restart_uow() - self._template = self.backend.get_process_template( - self.name, self.version, expand=False - ) - if self.on_existing == "warn": - warnings.warn( - ( - f"Process template {self.name!r} version {self.version!r} already " - "exists and will be reused; no new template will be created. " - "If you want a new template, bump the version." - ), - ExistingProcessTemplateWarning, - stacklevel=2, - ) - - def _reload_template(self): - self._ensure_uow() - self._template = self.backend.get_process_template( - self.name, self.version, expand=True - ) + self.save() def add_resource_slot( self, @@ -146,15 +224,27 @@ def add_resource_slot( create_resource_type=False, required: bool = True, ) -> "ProcessTemplateBuilder": - self._ensure_uow() - self._ensure_template() - self._resource_slots[name] = self.backend.add_resource_slot( - name, - resource_type, - direction, - self.template, - create_resource_type, - required=required, + """Add typed input/output resource slot after validating direction.""" + existing = next( + (slot for slot in self._draft_resource_slots if slot.name == name), None + ) + if existing is not None: + if ( + existing.resource_type != resource_type + or existing.direction != direction + ): + raise ValueError( + f"ResourceSlot {name} already exists with different type/direction" + ) + return self + self._draft_resource_slots.append( + ResourceSlotDraft( + name=name, + resource_type=resource_type, + direction=direction, + create_resource_type=create_resource_type, + required=required, + ) ) return self @@ -162,276 +252,547 @@ def add_step( self, name: str, ): - self._ensure_uow() - self._ensure_template() - step_template = self.backend.add_step(name, self.template) - step_template_builder = StepTemplateBuilder( - parent=self, step_template=step_template + """Open builder for a named process step.""" + existing = next( + (step for step in self._draft_steps if step._draft_name == name), None ) - return step_template_builder + if existing is not None: + return existing + builder = StepTemplateBuilder(parent=self, draft_name=name) + self._draft_steps.append(builder) + return builder def get_model(self, *, update: bool = False) -> ProcessTemplateSchema: """ Return a pydantic model for the process template, optionally reloading from the backend first. Critical fields are locked against mutation. """ - self._ensure_uow() - if update: - self._reload_template() - elif self._template is None: - self._ensure_template() - - model = self.backend.get_process_template(self.name, self.version, expand=True) + if (update and self._transaction.in_context) or ( + self._template is None and self._transaction.in_context + ): + self.save() + if not isinstance(self._template, ProcessTemplateSchema): + raise RuntimeError("Command backend did not return process template") return lock_instance_fields( - model.model_copy(deep=True), + self._template.model_copy(deep=True), {"id", "create_date", "modified_date", "version"}, ) def set_model(self, model: ProcessTemplateSchema | ProcessTemplateRef): + """Replace working template after verifying UUID identity.""" if self._template is None: raise RuntimeError("Template not initialized") if model.id != self._template.id: raise ValueError("ID for this ProcessTemplate does not match the builder") - self._template = model - - def _ensure_uow(self): - if self._uow is None: - self._uow = self.backend.begin() - return self._uow + self._draft_model = detached_model(model) + if isinstance(model, ProcessTemplateSchema): + self.name = model.name + self.version = model.version + self._draft_labels = list(model.labels) + self._submitted = False + + def _build_draft(self) -> ProcessTemplateDraft: + if self._draft_model is not None: + model = self._draft_model + return ProcessTemplateDraft( + id=model.id, + name=model.name, + version=model.version, + labels=model.labels, + resource_slots=[ + ResourceSlotDraft( + name=slot.name, + resource_type=slot.resource_type.name, + direction=slot.direction, + required=slot.required, + ) + for slot in model.resource_slots + ], + steps=[ + StepTemplateDraft( + name=step.name, + role_bindings={ + role: slot.name + for role, slot in step.resource_slots.items() + }, + parameter_groups=[ + AttributeGroupDraft( + name=group.name, + attributes=[ + AttributeDraft( + name=attribute.name, + type=attribute.value_type, + unit=attribute.unit or "", + default=attribute.default_value, + metadata=attribute.metadata or {}, + ) + for attribute in group.attribute_templates + ], + ) + for group in step.attribute_group_templates + ], + ) + for step in model.step_templates.values() + ], + ) + return ProcessTemplateDraft( + id=self._template.id, + name=self.name, + version=self.version, + labels=self._draft_labels, + resource_slots=self._draft_resource_slots, + steps=[step._build_draft() for step in self._draft_steps], + ) - def _restart_uow(self): - if self._uow: - self._uow.rollback() - self._uow = self.backend.begin() - return self._uow + def _initialize_command_update(self, process_template_id: UUID) -> None: + self._is_new_template = False + templates = self.backend.query( + ProcessTemplateSchema, + QuerySpec( + filters={"id": process_template_id}, + preloads=["step_templates", "resource_slots"], + include_mutable=True, + ), + namespace_path=self.namespace_context.path, + ) + if not templates: + raise RecapNotFoundError( + f"ProcessTemplate with id {process_template_id} not found" + ) + template = templates[0] + self._template = template + self.name = template.name + self.version = template.version + self._expected_revision = template.revision + self._draft_labels = list(template.labels) + self._draft_resource_slots = [ + ResourceSlotDraft( + name=slot.name, + resource_type=slot.resource_type.name, + direction=slot.direction, + required=slot.required, + ) + for slot in template.resource_slots + ] + for step in template.step_templates.values(): + step_builder = StepTemplateBuilder(parent=self, draft_name=step.name) + step_builder._draft_role_bindings.update( + {role: slot.name for role, slot in step.resource_slots.items()} + ) + for group in step.attribute_group_templates: + group_builder = DraftAttributeGroupBuilder(group.name, step_builder) + group_builder._attributes.extend( + AttributeDraft( + name=attribute.name, + type=attribute.value_type, + unit=attribute.unit or "", + default=attribute.default_value, + metadata=attribute.metadata or {}, + ) + for attribute in group.attribute_templates + ) + step_builder._draft_parameter_groups.append(group_builder) + self._draft_steps.append(step_builder) + self._last_draft = self._build_draft() + self._submitted = True class StepTemplateBuilder: - """Scoped editor for a single step""" + """Scoped editor for one step's parameter groups and resource bindings.""" - def __init__(self, parent: ProcessTemplateBuilder, step_template: StepTemplateRef): + def __init__( # noqa: C901 + self, + parent: ProcessTemplateBuilder, + step_template: StepTemplateRef | None = None, + draft_name: str | None = None, + ): self.parent: ProcessTemplateBuilder = parent - self.backend: Backend = parent.backend - self.process_template = parent.template + self._draft_name = draft_name + self._draft_role_bindings: dict[str, str] = {} + self._draft_parameter_groups: list[DraftAttributeGroupBuilder] = [] self._template = step_template - self._bound_slots = {} def close_step(self) -> ProcessTemplateBuilder: + """Return owning process-template builder.""" return self.parent def param_group( self, group_name: str ) -> "AttributeGroupBuilder[StepTemplateBuilder]": - attr_group_builder: AttributeGroupBuilder[StepTemplateBuilder] = ( - AttributeGroupBuilder(group_name=group_name, parent=self) + """Open parameter-group builder for this step.""" + existing = next( + ( + group + for group in self._draft_parameter_groups + if group.group_name == group_name + ), + None, ) - return attr_group_builder + if existing is not None: + return existing + builder = DraftAttributeGroupBuilder(group_name, self) + self._draft_parameter_groups.append(builder) + return builder def bind_slot(self, role: str, slot_name: str): - slot = self.backend.bind_slot( - role, slot_name, self.process_template, self._template - ) - self._bound_slots[slot.name] = slot + """Bind process resource slot to step role.""" + self._draft_role_bindings[role] = slot_name return self + def _build_draft(self) -> StepTemplateDraft: + return StepTemplateDraft( + name=self._draft_name, + role_bindings=self._draft_role_bindings, + parameter_groups=[ + group._build_draft() for group in self._draft_parameter_groups + ], + ) + def add_parameters(self, param_def: dict[str, list[dict[str, Any]]]): - """Add parameter groups and their attributes to this step template. + """Validate and add grouped parameter definitions.""" + for group_key, params in param_def.items(): + group = self.param_group(group_key) + for param in params: + attribute = AttributeTemplateValidator.model_validate(param) + group.add_attribute( + attribute.name, + attribute.type, + attribute.unit, + attribute.default, + metadata=attribute.metadata, + ) + group.close_group() + return self - Args: - param_def: A mapping of group name → list of attribute dicts. Each - attribute dict accepts the keys ``name``, ``type``, - ``default``, ``unit`` (optional), and ``metadata`` (optional). - Example:: +class DraftAttributeGroupBuilder: + """Draft-only builder for step parameter definitions.""" - step_builder.add_parameters({ - "harvest": [ - {"name": "arrival", "type": "datetime", "default": None}, - ] - }) + def __init__(self, group_name: str, parent: StepTemplateBuilder): + self.group_name = group_name + self.parent = parent + self._attributes: list[AttributeDraft] = [] - Returns: - ``self``, to allow method chaining. - """ - for group_key, params in param_def.items(): - agb = AttributeGroupBuilder(group_name=group_key, parent=self) - for param in params: - attr = AttributeTemplateValidator.model_validate(param) - agb.add_attribute( - attr.name, - attr.type, - attr.unit, - attr.default, - metadata=attr.metadata, - ) - agb.close_group() + def add_attribute( + self, + attr_name: str, + value_type: str, + unit: str, + default: Any, + metadata: dict[str, Any] | None = None, + ) -> "DraftAttributeGroupBuilder": + existing = next( + (item for item in self._attributes if item.name == attr_name), None + ) + if existing is not None: + return self + self._attributes.append( + AttributeDraft( + name=attr_name, + type=value_type, + unit=unit, + default=default, + metadata=metadata or {}, + ) + ) return self + def close_group(self) -> StepTemplateBuilder: + return self.parent + + def _build_draft(self) -> AttributeGroupDraft: + return AttributeGroupDraft(name=self.group_name, attributes=self._attributes) + class ProcessRunBuilder: - def __init__( + """Builder for process-run assignments, parameters, and child steps. + + Clean context-manager exit commits and exception exit rolls back local work; + command-backed remote builders submit validated drafts. UUID loading, + ``on_existing``, lifecycle methods, and validation errors are preserved. + """ + + def __init__( # noqa: C901 self, name: str | None, description: str | None, template_name: str | None, - campaign: CampaignSchema | None, - backend: Backend, version: str | None = None, + *, + backend: ClientBackend, + namespace_context: NamespaceContext, process_run_id: UUID | None = None, on_existing: Literal["silent", "warn", "raise"] = "warn", + command_context: CommandContext, + template_id: UUID | None = None, ): + if command_context is None: + raise ValueError("ProcessRunBuilder requires command context") self.backend = backend - self._uow = None self.name = name self.description = description + self.namespace_context = namespace_context self.template_name = template_name self.version = version if on_existing not in {"silent", "warn", "raise"}: raise ValueError("on_existing must be one of: 'silent', 'warn', 'raise'") self.on_existing = on_existing - self._process_template: ProcessTemplateSchema | ProcessTemplateRef | None = None - self._loaded_in_uow: bool = False - self._model_dirty: bool = False - self._params_flushed: bool = False - try: - self._initialize_process_run(process_run_id, campaign) - self._loaded_in_uow = True # mark run as fresh in this UoW - self._steps = list(self._process_run.steps.values()) - self._resources = {} - except Exception: - if self._uow: - self._uow.rollback() - self._uow = None - raise - - def _initialize_process_run( - self, - process_run_id: UUID | None, - campaign: CampaignSchema | None, - ): - self._ensure_uow() - if process_run_id is not None: - self._load_existing_process_run(process_run_id) - return - self._validate_new_process_run_inputs(campaign) - self._process_template = self.backend.get_process_template( - self.template_name, self.version, expand=True - ) - try: - self._process_run = self.backend.create_process_run( - self.name, self.description, self._process_template, campaign - ) - except Exception as exc: - self._handle_existing_process_run(exc) - - def _load_existing_process_run(self, process_run_id: UUID): - self._process_run = self._reload_process_run(process_run_id) - template = self._process_run.template - self._process_template = self.backend.get_process_template( - template.name, template.version, expand=True - ) - self.name = self._process_run.name - self.description = self._process_run.description - self.template_name = template.name - self.version = template.version - - def _validate_new_process_run_inputs(self, campaign: CampaignSchema | None): + self._command_context = command_context + self._template_id = template_id + self._process_template = None + self._submitted = False + self._save_called = False + self._transaction = BuilderTransactionState() + self._draft_assignments: dict[str, UUID] = {} + self._draft_steps: dict[str, dict[str, dict[str, object]]] = {} if ( - self.name is None - or self.description is None - or self.template_name is None - or self.version is None + template_id is None + and process_run_id is None + and (template_name is None or version is None) ): raise ValueError( - "name, description, template_name, and version are required to create a process run" + "template_id or process_run_id are required for command-backed builders" ) - if campaign is None: - raise ValueError("Campaign is required to create a process run") - - def _handle_existing_process_run(self, create_error: Exception): - self._restart_uow() - existing = self.backend.query( - ProcessRunSchema, + self._process_run = ( + self._reload_process_run(process_run_id) + if process_run_id is not None + else None + ) + self._draft_process_run = ( + self._process_run.model_copy(deep=True) + if self._process_run is not None + else None + ) + self._dirty = False + if self._process_run is not None: + self.name = self._process_run.name + self.description = self._process_run.description + # self._template_id = self._process_run.__dict__["template"].id + self._template_id = self._process_run.template.id + filters = ( + {"id": self._template_id} + if self._template_id is not None + else {"name": self.template_name, "version": self.version} + ) + templates = self.backend.query( + ProcessTemplateSchema, QuerySpec( - filters={"name": self.name}, - preloads=["steps", "steps.parameters", "resources"], + filters=filters, + preloads=["step_templates", "resource_slots"], + include_mutable=True, ), + namespace_path=namespace_context.path, ) - if self.on_existing == "raise": - raise ExistingProcessRunError( - f"Process run {self.name!r} already exists" - ) from create_error - if not existing: - raise create_error - if self.on_existing == "warn": - warnings.warn( - ( - f"Process run {self.name!r} already exists and will be reused; " - "no new run will be created." + if not templates: + raise ValueError( + f"Process template {self.template_name!r} version {version!r} not found" + ) + self._process_template = templates[0] + self._template_id = self._process_template.id + if self._process_run is None: + self._draft_process_run = ProcessRunSchema.model_construct( + id=uuid4(), + create_date=None, + modified_date=None, + namespace_id=self.namespace_context.id, + status=LifecycleStatus.MUTABLE, + revision=1, + name=self.name, + description=self.description, + template=self._process_template, + steps={}, + assigned_resources={}, + ) + if self._process_run is None: + existing_runs = self.backend.query( + ProcessRunSchema, + QuerySpec( + filters={"name": name}, + preloads=["template", "steps", "steps.parameters", "resources"], + include_mutable=True, ), - ExistingProcessRunWarning, - stacklevel=2, + namespace_path=namespace_context.path, ) - self._process_run = existing[0] - self._loaded_in_uow = True # mark fresh after recovery load + if existing_runs: + if on_existing == "raise": + raise ExistingProcessRunError( + f"Process run {name!r} already exists" + ) + if on_existing == "warn": + warnings.warn( + f"Process run {name!r} already exists and will be reused", + ExistingProcessRunWarning, + stacklevel=2, + ) + self._process_run = existing_runs[0] + if not isinstance(self._process_run, ProcessRunSchema): + raise TypeError( + "Process-run builder requires ProcessRunSchema result" + ) + self._draft_process_run = self._process_run.model_copy(deep=True) + self.name = self._process_run.name + self.description = self._process_run.description + self._steps = ( + list(self._process_run.steps.values()) + if self._process_run is not None + else [] + ) + if self._process_run is not None: + self._draft_process_run = self._process_run.model_copy(deep=True) + self._submitted = True + self._baseline_process_run = ( + self._process_run.model_copy(deep=True) + if self._process_run is not None + else None + ) + self._draft_assignments = self._assignment_ids(self._draft_process_run) + self._draft_steps = self._model_steps_payload(self._draft_process_run) + self._resources = {} def __enter__(self): - self._ensure_uow() - if getattr(self, "_process_run", None) is not None and not self._loaded_in_uow: - # Re-entering after save() or _restart_uow() — reload current state - self._process_run = self._reload_process_run(self._process_run.id) - template = self._process_run.template - self._process_template = self.backend.get_process_template( - template.name, template.version, expand=True - ) - self.name = self._process_run.name - self.description = self._process_run.description - self.template_name = template.name - self.version = template.version - self._steps = list(self._process_run.steps.values()) + self._transaction.enter() return self def __exit__(self, exc_type, exc, tb): - if exc_type is None: - if self._model_dirty: - if self._params_flushed: - # set_params() flushed param values directly to the ORM, but - # the in-memory pydantic schema still holds the pre-flush - # values. Reload so persist() does not overwrite the flushed - # params with stale schema data. (Only needed when a model - # mutation is combined with set_params() in the same block.) - self._process_run = self._reload_process_run(self._process_run.id) - self.persist() - self.save() - else: - if self._uow: - self._uow.rollback() - self._uow = None + if self._transaction.exit(exc_type): + self._save() def save(self): - self._ensure_uow() - self._uow.commit() - self._loaded_in_uow = False - self._model_dirty = False - self._params_flushed = False - self._uow = None + """Validate and persist current process-run changes.""" + if not self._transaction.in_context: + raise RuntimeError("Builder changes require a context manager") + return self._save() + + def _save(self): + if self._submitted and not self._dirty and self._pending_lifecycle() is None: + return self + assignments = self._assignment_changes() + steps = self._step_changes() + description = self._description_change() + needs_data = ( + not self._submitted + or bool(assignments) + or bool(steps) + or description is not None + ) + result = self._process_run + if needs_data and self._process_run is None: + command = CreateProcessRun( + namespace_path=self.namespace_context.path, + draft=ProcessRunDraft( + id=self._draft_process_run.id, + name=self.name, + description=self.description, + template_id=self._template_id, + assignments=assignments, + steps={ + name: ProcessRunStepDraft(parameters=value) + for name, value in self._draft_steps.items() + }, + ), + ) + elif needs_data and self._process_run.status is not LifecycleStatus.MUTABLE: + command = CopyProcessRun( + source_process_run_id=self._process_run.id, + destination_namespace_path=self.namespace_context.path, + options={ + "changes": { + "description": description, + "assignments": assignments or None, + "steps": steps or None, + } + }, + ) + elif needs_data: + command = UpdateProcessRun( + process_run_id=self._process_run.id, + expected_revision=self._process_run.revision, + description=description, + assignments=assignments or None, + steps=steps or None, + ) + if needs_data: + result = self.backend._execute(command, self._command_context) + self._save_called = True + if not isinstance(result, ProcessRunSchema): + return self + self._process_run = result + self._draft_process_run = result.model_copy(deep=True) + self._baseline_process_run = result.model_copy(deep=True) + self._draft_assignments = self._assignment_ids(result) + self._draft_steps = self._model_steps_payload(result) + self.name = result.name + self.description = result.description + self._steps = list(result.steps.values()) + self._dirty = False + self._submitted = True + + pending = self._pending_lifecycle() + if pending is not None: + if self._process_run is None: + return self + result = self.backend._execute( + SetLifecycleStatus( + object_type="process_run", + object_id=self._process_run.id, + expected_revision=self._process_run.revision, + status=pending.value, + ), + self._command_context, + ) + if isinstance(result, ProcessRunSchema): + self._process_run = result + self._draft_process_run = result.model_copy(deep=True) + self._baseline_process_run = result.model_copy(deep=True) + self._draft_assignments = self._assignment_ids(result) + self._draft_steps = self._model_steps_payload(result) + self._steps = list(result.steps.values()) + self._transaction.clear_lifecycle(owner=self) + return self + + def finalize(self): + """Transition process run to ACTIVE/finalized state.""" + self._transaction.request_lifecycle(LifecycleStatus.ACTIVE, owner=self) return self - def persist(self): - self._process_run = self.backend.update_process_run(self._process_run) + def archive(self): + """Transition process run to ARCHIVED.""" + self._transaction.request_lifecycle(LifecycleStatus.ARCHIVED, owner=self) return self + def changes(self) -> BuilderChanges: + fields = {} + assignments = self._assignment_changes() + steps = self._step_changes() + description = self._description_change() + if assignments: + fields["assignments"] = assignments + if steps: + fields["steps"] = steps + if description is not None: + fields["description"] = description + return BuilderChanges( + fields=fields, + lifecycle=self._pending_lifecycle(), + ) + @property def process_run(self) -> ProcessRunSchema: - return self._process_run + return self._draft_process_run def set_model(self, model: ProcessRunSchema): + """Replace working run model after verifying UUID identity.""" if self._process_run is None: raise RuntimeError("ProcessRun not initialized") if model.id != self._process_run.id: raise ValueError("ID for this ProcessRun does not match the builder") - self._process_run = model - self._model_dirty = True # pydantic schema mutated; persist needed + self._draft_process_run = detached_model(model) + self.name = model.name + self.description = model.description + self._draft_assignments = self._assignment_ids(model) + self._draft_steps = self._model_steps_payload(model) + self._dirty = True + self._save_called = False def assign_resource( # self, resource_slot_name: str, resource_name: str, resource_template_name: str @@ -439,77 +800,147 @@ def assign_resource( resource_slot_name: str, resource: ResourceSchema, ) -> "ProcessRunBuilder": - self._ensure_uow() - resource_slot = None - for slot in self._process_template.resource_slots: - if slot.name == resource_slot_name: - resource_slot = slot - break - # resource = self.backend.get_resource(resource_name, resource_template_name) - if resource_slot is None: - raise NoResultFound(f"Resource slot {resource_slot_name} not found") - self._process_run = self.backend.assign_resource( - resource_slot, resource, self._process_run + """Assign resource to named process slot.""" + self._draft_assignments[resource_slot_name] = resource.id + slot = next( + ( + slot + for slot in self._process_template.resource_slots + if slot.name == resource_slot_name + ), + None, ) - self._model_dirty = True # ORM updated and schema refreshed; persist needed + if slot is not None: + self._draft_process_run.assigned_resources[resource_slot_name] = ( + ResourceAssignmentSchema(slot=slot, resource=resource) + ) + self._dirty = True + self._save_called = False return self - def _check_resource_assignment(self): - self._ensure_uow() - self.backend.check_resource_assignment(self._process_template, self.process_run) - @property def steps(self) -> list[StepSchema]: - self._ensure_uow() - self._check_resource_assignment() - if self._steps is None: - self._steps = self.backend.get_steps(self.process_run) + if self._process_run is not None and not self._steps: + self._steps = list( + self._reload_process_run(self._process_run.id).steps.values() + ) return self._steps - def get_params( + def get_params( # noqa: C901 self, step_name: str | None = None, step_schema: StepSchema | None = None, ) -> type[BaseModel]: - self._ensure_uow() + """Return typed parameter model for selected process step.""" + if self._process_run is None: + self.save() + if not self._steps: + self._steps = list( + self._reload_process_run(self.process_run.id).steps.values() + ) if step_name is None and step_schema is None: raise ValueError("Provide step_name or step_schema to get params") if not step_schema and step_name: - for step in self.steps: + for step in self._steps: if step.name == step_name: step_schema = step break if step_schema is None: raise NoResultFound("Step not found with name: {step_name} ") - self._check_resource_assignment() - return self.backend.get_params(step_schema) + if isinstance(step_schema.parameters, dict) and not step_schema.parameters: + self._steps = list(self._reload_process_run(self.process_run.id).steps.values()) + step_schema = next( + (step for step in self._steps if step.name == step_name), step_schema + ) + required_slots = { + slot.name for slot in self._process_template.resource_slots if slot.required + } + assigned_slots = set(self._draft_assignments) + if self._process_run is not None: + assigned_slots.update(self._process_run.assigned_resources) + missing_slots = required_slots - assigned_slots + if missing_slots: + raise ValueError( + "Process run is missing resources for slots: " + + ", ".join(sorted(missing_slots)) + ) + parameters = step_schema.parameters + if isinstance(parameters, dict): + parameter_fields = tuple( + ( + getattr(parameter.template, "slug", None) + or parameter.template.name, + parameter.template.name, + ) + for parameter in parameters.values() + ) + parameters = build_step_parameters_model( + step_schema.name, parameter_fields + ).model_validate(parameters) + model = create_model( + f"{step_schema.name}Parameters", + __base__=type(parameters), + step_name=(Literal[step_schema.name], step_schema.name), + step_id=(UUID, step_schema.id), + ) + return model.model_validate( + { + **{ + name: getattr(parameters, name) + for name in type(parameters).model_fields + }, + "step_name": step_schema.name, + "step_id": step_schema.id, + } + ) def set_params(self, filled_params: type[BaseModel]): - self._ensure_uow() - self.backend.set_params(filled_params) - # backend.set_params() already flushed the values to the session within - # the open transaction; __exit__ commits them. No reload/persist needed. - self._params_flushed = True + """Validate and apply typed parameter values in current transaction.""" + step_data: dict[str, dict[str, object]] = {} + for field_name, field_info in type(filled_params).model_fields.items(): + if field_name in {"step_name", "step_id"}: + continue + group = getattr(filled_params, field_name) + if not isinstance(group, BaseModel): + continue + value_model = getattr(group, "values", group) + values = {} + for attr_name, attr_info in type(value_model).model_fields.items(): + value = getattr(value_model, attr_name) + if not hasattr(value, "value"): + continue + values[attr_info.alias or attr_name] = { + "value": value.value, + "unit": value.unit, + } + step_data[field_info.alias or field_name] = values + self._draft_steps[filled_params.step_name] = step_data + self._dirty = True + self._save_called = False return self - def add_child_step( - self, - child_step: StepSchema, - ) -> StepSchema: - self._ensure_uow() - if child_step.parent_id is None: - raise ValueError( - f"Child step {child_step.name} has no parent_id, was the step created using generate_child()?" - ) - if child_step.process_run_id != self._process_run.id: - raise ValueError( - f"Child step {child_step.name} does not belong to {self._process_run.name}" - ) - child = self.backend.add_child_step(self.process_run, child_step) - # refresh cached steps so subsequent operations see the new child - self._steps = None - return child + @staticmethod + def _model_steps_payload(model: ProcessRunSchema): + result = {} + for step in model.steps.values(): + groups = {} + if not isinstance(step.parameters, BaseModel): + result[step.name] = groups + continue + for group_name, group_info in type(step.parameters).model_fields.items(): + group = getattr(step.parameters, group_name) + value_model = getattr(group, "values", group) + groups[group_info.alias or group_name] = { + attr_info.alias or attr_name: { + "value": getattr(value_model, attr_name).value, + "unit": getattr(value_model, attr_name).unit, + } + for attr_name, attr_info in type(value_model).model_fields.items() + if hasattr(getattr(value_model, attr_name), "value") + } + result[step.name] = {"parameters": groups} + return result def get_model(self, *, update: bool = False) -> ProcessRunSchema: """ @@ -518,33 +949,67 @@ def get_model(self, *, update: bool = False) -> ProcessRunSchema: """ if update: self._process_run = self._reload_process_run(self._process_run.id) - model = self._process_run.model_copy(deep=True) + self._draft_process_run = self._process_run.model_copy(deep=True) + model = (self._draft_process_run or self._process_run).model_copy(deep=True) return lock_instance_fields( model, {"id", "create_date", "modified_date", "template"} ) - def _ensure_uow(self): - if self._uow is None: - self._uow = self.backend.begin() - return self._uow + def _ensure_command_saved(self): + if not self._submitted: + self.save() - def _restart_uow(self): - if self._uow: - self._uow.rollback() - self._uow = self.backend.begin() - self._loaded_in_uow = False - self._model_dirty = False - self._params_flushed = False - return self._uow + def _pending_lifecycle(self): + return self._transaction.pending_lifecycle_for(self) + + @staticmethod + def _assignment_ids(model: ProcessRunSchema | None) -> dict[str, UUID]: + if model is None: + return {} + return { + name: assignment.resource.id + for name, assignment in model.assigned_resources.items() + } + + def _assignment_changes(self) -> dict[str, UUID]: + current = self._draft_assignments + baseline = self._assignment_ids(self._baseline_process_run) + if self._baseline_process_run is None: + return dict(current) + return { + name: resource_id + for name, resource_id in current.items() + if baseline.get(name) != resource_id + } + + def _step_changes(self): + if self._baseline_process_run is None: + return dict(self._draft_steps) + baseline = self._model_steps_payload(self._baseline_process_run) + return { + name: value + for name, value in self._draft_steps.items() + if baseline.get(name) != value + } + + def _description_change(self): + if ( + self._baseline_process_run is None + or self.description != self._baseline_process_run.description + ): + return self.description + return None def _reload_process_run(self, process_run_id: UUID) -> ProcessRunSchema: runs = self.backend.query( ProcessRunSchema, QuerySpec( filters={"id": process_run_id}, - preloads=["steps", "steps.parameters", "resources"], + preloads=["template", "steps", "steps.parameters", "resources"], + include_mutable=True, ), + namespace_path=self.namespace_context.path, ) if not runs: - raise ValueError(f"ProcessRun with id {process_run_id} not found") + raise RecapNotFoundError(f"ProcessRun with id {process_run_id} not found") return runs[0] diff --git a/recap/dsl/query.py b/recap/dsl/query.py index aacd8dd..3590e01 100644 --- a/recap/dsl/query.py +++ b/recap/dsl/query.py @@ -1,12 +1,16 @@ import warnings from collections.abc import Mapping, Sequence from datetime import datetime -from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast +from pathlib import Path +from typing import IO, TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast from uuid import UUID -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, ConfigDict, field_validator from pydantic import Field as PydanticField +from recap.exporters.protocol import ExportContext +from recap.exporters.registry import default_exporter_registry +from recap.schemas.namespace import NamespaceContext, NamespaceSchema from recap.schemas.resource import ( ResourceRef, ResourceSchema, @@ -15,9 +19,8 @@ ) if TYPE_CHECKING: - from recap.adapter import Backend + from recap.adapter import ReadBackend from recap.schemas.process import ( - CampaignSchema, ProcessRunRef, ProcessRunSchema, ProcessTemplateRef, @@ -53,6 +56,13 @@ def _normalize_shape(shape: ShapeInput) -> Shape: + if shape == "ref": + warnings.warn( + "shape='ref' is deprecated; use shape='full', load='none' instead", + DeprecationWarning, + stacklevel=3, + ) + return "full" if shape == "schema": warnings.warn( "shape='schema' is deprecated; use shape='full' instead", @@ -65,6 +75,19 @@ def _normalize_shape(shape: ShapeInput) -> Shape: return cast(Shape, shape) +def _normalize_expand(expand: bool | None, load: LoadInput) -> LoadMode: + if expand: + warnings.warn( + "expand=True is deprecated; use load='eager' instead", + DeprecationWarning, + stacklevel=3, + ) + if load not in ("none", "eager"): + raise ValueError("expand=True cannot be combined with deprecated load='full'") + return "eager" + return _normalize_load(load) + + def _normalize_load(load: LoadInput) -> LoadMode: if load == "full": warnings.warn( @@ -89,6 +112,10 @@ def _validate_field_path(path: str) -> str: class FieldPredicate(BaseModel): + """Serializable field comparison used by :meth:`BaseQuery.where`.""" + + model_config = ConfigDict(extra="forbid") + field: str op: FieldOperator value: Any @@ -103,6 +130,10 @@ def __bool__(self) -> bool: class FieldOrdering(BaseModel): + """Serializable ascending or descending field ordering.""" + + model_config = ConfigDict(extra="forbid") + field: str direction: Literal["asc", "desc"] = "asc" @@ -110,6 +141,13 @@ class FieldOrdering(BaseModel): class Field: + """Typed query-field expression builder. + + Comparisons create immutable :class:`FieldPredicate` values; ``asc`` and + ``desc`` create :class:`FieldOrdering` values. Use query ``where`` calls + for AND semantics instead of Python ``and``/``or``. + """ + def __init__(self, path: str): self.path = _validate_field_path(path) @@ -117,27 +155,35 @@ def _predicate(self, op: FieldOperator, value: Any) -> FieldPredicate: return FieldPredicate(field=self.path, op=op, value=value) def __eq__(self, value: Any) -> FieldPredicate: # type: ignore[override] + """Create equality predicate ``path == value``.""" return self._predicate("eq", value) def __ne__(self, value: Any) -> FieldPredicate: # type: ignore[override] + """Create inequality predicate.""" return self._predicate("ne", value) def __lt__(self, value: Any) -> FieldPredicate: + """Create less-than predicate.""" return self._predicate("lt", value) def __le__(self, value: Any) -> FieldPredicate: + """Create less-than-or-equal predicate.""" return self._predicate("lte", value) def __gt__(self, value: Any) -> FieldPredicate: + """Create greater-than predicate.""" return self._predicate("gt", value) def __ge__(self, value: Any) -> FieldPredicate: + """Create greater-than-or-equal predicate.""" return self._predicate("gte", value) def in_(self, values: Sequence[Any]) -> FieldPredicate: + """Create membership predicate; strings are rejected as sequences.""" return self._membership_predicate("in", values) def not_in(self, values: Sequence[Any]) -> FieldPredicate: + """Create non-membership predicate.""" return self._membership_predicate("not_in", values) def _membership_predicate( @@ -148,22 +194,28 @@ def _membership_predicate( return self._predicate(op, list(values)) def contains(self, value: str) -> FieldPredicate: + """Create substring containment predicate.""" return self._predicate("contains", value) def starts_with(self, value: str) -> FieldPredicate: + """Create prefix predicate.""" return self._predicate("starts_with", value) def ends_with(self, value: str) -> FieldPredicate: + """Create suffix predicate.""" return self._predicate("ends_with", value) def asc(self) -> FieldOrdering: + """Order ascending by this field.""" return FieldOrdering(field=self.path, direction="asc") def desc(self) -> FieldOrdering: + """Order descending by this field.""" return FieldOrdering(field=self.path, direction="desc") class PropertyFilter(BaseModel): + model_config = ConfigDict(extra="forbid") name: str group: str | None = None op: Literal["eq", "gt", "gte", "lt", "lte", "between", "in"] = "eq" @@ -178,6 +230,7 @@ def model_dump(self, *args, **kwargs): class ParameterFilter(BaseModel): + model_config = ConfigDict(extra="forbid") name: str group: str | None = None step: str | None = None @@ -192,6 +245,7 @@ def model_dump(self, *args, **kwargs): class QuerySpec(BaseModel): + model_config = ConfigDict(extra="forbid") filters: dict[str, Any] = {} predicates: Sequence[Any] = () orderings: Sequence[Any] = () @@ -201,7 +255,10 @@ class QuerySpec(BaseModel): property_filters: list[PropertyFilter] = PydanticField(default_factory=list) parent_resource_id: UUID | None = None parameter_filters: list[ParameterFilter] = PydanticField(default_factory=list) - campaign_id: UUID | None = None + include_archived: bool = False + include_mutable: bool = False + local_metadata_filters: dict[str, Any] = PydanticField(default_factory=dict) + effective_metadata_filters: dict[str, Any] = PydanticField(default_factory=dict) load_mode: LoadMode | None = None on_unloaded: OnUnloadedPolicy | None = None @@ -238,12 +295,15 @@ def _normalize_orderings(cls, values): class BaseQuery(Generic[SchemaT]): + """Immutable query base with filtering, pagination, loading, and execution.""" + schema: type[SchemaT] def __init__( self: Self, - backend: "Backend", + backend: "ReadBackend", *, + context: NamespaceContext, model: type[SchemaT] | None = None, filters: dict[str, Any] | None = None, predicates: list[Any] | None = None, @@ -254,11 +314,14 @@ def __init__( property_filters: list[PropertyFilter] | None = None, parent_resource_id: UUID | None = None, parameter_filters: list[ParameterFilter] | None = None, - campaign_id: UUID | None = None, + include_archived: bool = False, + local_metadata_filters: dict[str, Any] | None = None, + effective_metadata_filters: dict[str, Any] | None = None, load_mode: LoadMode | None = None, on_unloaded: OnUnloadedPolicy | None = None, ): self._backend = backend + self._context = context self.model: type[SchemaT] = model or self.__class__.model # type: ignore[attr-defined] self._filters = filters or {} self._predicates = predicates or [] @@ -269,7 +332,9 @@ def __init__( self._property_filters = property_filters or [] self._parent_resource_id = parent_resource_id self._parameter_filters = parameter_filters or [] - self._campaign_id = campaign_id + self._include_archived = include_archived + self._local_metadata_filters = local_metadata_filters or {} + self._effective_metadata_filters = effective_metadata_filters or {} self._load_mode = load_mode self._on_unloaded = on_unloaded @@ -292,6 +357,7 @@ def _clone(self: Self, **overrides) -> "Self": """ params = dict( backend=self._backend, + context=self._context, model=self.model, filters=dict(self._filters), predicates=list(self._predicates), @@ -302,7 +368,9 @@ def _clone(self: Self, **overrides) -> "Self": property_filters=list(self._property_filters), parent_resource_id=self._parent_resource_id, parameter_filters=list(self._parameter_filters), - campaign_id=self._campaign_id, + include_archived=self._include_archived, + local_metadata_filters=dict(self._local_metadata_filters), + effective_metadata_filters=dict(self._effective_metadata_filters), load_mode=self._load_mode, on_unloaded=self._on_unloaded, ) @@ -311,11 +379,13 @@ def _clone(self: Self, **overrides) -> "Self": return clone def filter(self, **kwargs) -> "Self": + """Return clone with equality filters merged.""" new_filters = dict(self._filters) new_filters.update(kwargs) return self._clone(filters=new_filters) def where(self, *predicates) -> "Self": + """Return clone with predicates appended using AND semantics.""" for predicate in predicates: if not isinstance(predicate, FieldPredicate): warnings.warn( @@ -326,6 +396,7 @@ def where(self, *predicates) -> "Self": return self._clone(predicates=self._predicates + list(predicates)) def order_by(self, *orderings) -> "Self": + """Return clone with orderings appended.""" normalized = [] for ordering in orderings: if isinstance(ordering, Field): @@ -341,12 +412,19 @@ def order_by(self, *orderings) -> "Self": return self._clone(orderings=self._orderings + normalized) def limit(self, value: int) -> "Self": + """Return clone constrained to at most ``value`` rows.""" return self._clone(limit=value) def offset(self, value: int) -> "Self": + """Return clone skipping ``value`` rows.""" return self._clone(offset=value) + def include_archived(self) -> "Self": + """Return clone including archived entities.""" + return self._clone(include_archived=True) + def include(self, relation_names: str | Sequence[str]) -> "Self": + """Return clone requesting relation preloads.""" names = ( [relation_names] if isinstance(relation_names, str) @@ -370,55 +448,83 @@ def _spec(self) -> QuerySpec: property_filters=self._property_filters, parent_resource_id=self._parent_resource_id, parameter_filters=self._parameter_filters, - campaign_id=self._campaign_id, + include_archived=self._include_archived, + local_metadata_filters=self._local_metadata_filters, + effective_metadata_filters=self._effective_metadata_filters, load_mode=self._load_mode, on_unloaded=self._on_unloaded, ) def _execute(self) -> list[SchemaT]: - rows = self._backend.query(self.model, self._spec) + rows = self._backend.query( + self.model, self._spec, namespace_path=self._context.path + ) return rows def all(self) -> Sequence[SchemaT] | Sequence[BaseModel]: + """Execute query and return all matching models or references.""" return self._execute() def first(self) -> SchemaT | None: + """Execute query and return first match, or ``None``.""" return next(iter(self.limit(1)._execute()), None) def count(self) -> int: - return self._backend.count(self.model, self._spec) + """Return matching row count without materializing rows.""" + return self._backend.count( + self.model, self._spec, namespace_path=self._context.path + ) + def export(self, format: str, destination: Path | IO | None = None) -> object: + """Export one materialized query result through a registered exporter.""" + exporter = default_exporter_registry.get(format) + items = self._execute() + if any(not isinstance(item, BaseModel) for item in items): + raise TypeError("Export requires BaseModel entity results, not scalar counts") + return exporter.export(ExportContext(query=self, items=items), destination) -class CampaignQuery(BaseQuery[CampaignSchema]): - model = CampaignSchema - default_schema = None # e.g. recap.schemas.campaign.CampaignSchema - def include_process_runs( - self, - ) -> "CampaignQuery": - return self.include("process_run") +class NamespaceQuery(BaseQuery[NamespaceSchema]): + """Query namespaces, including local and effective metadata filters.""" + + model = NamespaceSchema + + def filter_local_metadata(self, **metadata: Any) -> "NamespaceQuery": + """Return clone filtering metadata stored directly on namespaces.""" + values = dict(self._local_metadata_filters) + values.update(metadata) + return self._clone(local_metadata_filters=values) + + def filter_effective_metadata(self, **metadata: Any) -> "NamespaceQuery": + """Return clone filtering metadata inherited through namespace ancestry.""" + values = dict(self._effective_metadata_filters) + values.update(metadata) + return self._clone(effective_metadata_filters=values) class ProcessRunQuery(BaseQuery[ProcessRunSchema | ProcessRunRef]): + """Query process runs with optional steps, resources, and parameters.""" + model = ProcessRunSchema default_schema = None def __init__( self, - backend: "Backend", + backend: "ReadBackend", *, shape: ShapeInput = "full", load: LoadInput = "none", + expand: bool | None = None, on_unloaded: OnUnloadedPolicy | None = None, **kwargs, ): shape = _normalize_shape(shape) - load = _normalize_load(load) + load = _normalize_expand(expand, load) if shape == "ref" and load != "none": raise ValueError("load must be 'none' when shape='ref'") self._shape = shape self._load = load - model = ProcessRunSchema if shape == "full" else ProcessRunRef + model = ProcessRunSchema super().__init__( backend, model=model, @@ -430,6 +536,7 @@ def __init__( def _clone(self, **overrides) -> "ProcessRunQuery": params = dict( backend=self._backend, + context=self._context, shape=self._shape, load=self._load, filters=dict(self._filters), @@ -441,7 +548,9 @@ def _clone(self, **overrides) -> "ProcessRunQuery": property_filters=list(self._property_filters), parent_resource_id=self._parent_resource_id, parameter_filters=list(self._parameter_filters), - campaign_id=self._campaign_id, + include_archived=self._include_archived, + local_metadata_filters=dict(self._local_metadata_filters), + effective_metadata_filters=dict(self._effective_metadata_filters), on_unloaded=self._on_unloaded, ) params.update(overrides) @@ -455,6 +564,7 @@ def include(self, relation_names: str | Sequence[str]) -> "ProcessRunQuery": return super().include(relation_names) def include_steps(self, *, include_parameters: bool = False) -> "ProcessRunQuery": + """Return clone preloading run steps and optionally their parameters.""" if include_parameters: return self.include(["steps", "steps.parameters"]) return self.include("steps") @@ -474,6 +584,7 @@ def filter_parameter( in_: Sequence[Any] | None = None, value_type: str | None = None, ) -> "ProcessRunQuery": + """Return clone filtering process-run parameters by name and comparison.""" comparators = { "eq": eq, "gt": gt, @@ -516,29 +627,33 @@ def filter_parameter( return self._clone(parameter_filters=self._parameter_filters + [pf]) def include_resources(self) -> "ProcessRunQuery": + """Return clone preloading assigned process-run resources.""" return self.include("resources") class ResourceQuery(BaseQuery[ResourceSchema | ResourceRef]): + """Query resources, properties, templates, and parent relationships.""" + model = ResourceSchema default_schema = None def __init__( self, - backend: "Backend", + backend: "ReadBackend", *, shape: ShapeInput = "full", load: LoadInput = "none", + expand: bool | None = None, on_unloaded: OnUnloadedPolicy | None = None, **kwargs, ): shape = _normalize_shape(shape) - load = _normalize_load(load) + load = _normalize_expand(expand, load) if shape == "ref" and load != "none": raise ValueError("load must be 'none' when shape='ref'") self._shape = shape self._load = load - model = ResourceSchema if shape == "full" else ResourceRef + model = ResourceSchema super().__init__( backend, model=model, @@ -550,6 +665,7 @@ def __init__( def _clone(self, **overrides) -> "ResourceQuery": params = dict( backend=self._backend, + context=self._context, shape=self._shape, load=self._load, filters=dict(self._filters), @@ -561,7 +677,9 @@ def _clone(self, **overrides) -> "ResourceQuery": property_filters=list(self._property_filters), parent_resource_id=self._parent_resource_id, parameter_filters=list(self._parameter_filters), - campaign_id=self._campaign_id, + include_archived=self._include_archived, + local_metadata_filters=dict(self._local_metadata_filters), + effective_metadata_filters=dict(self._effective_metadata_filters), on_unloaded=self._on_unloaded, ) params.update(overrides) @@ -575,6 +693,7 @@ def include(self, relation_names: str | Sequence[str]) -> "ResourceQuery": return super().include(relation_names) def include_template(self) -> "ResourceQuery": + """Return clone preloading each resource template.""" return self.include("template") def filter_property( @@ -591,6 +710,7 @@ def filter_property( in_: Sequence[Any] | None = None, value_type: str | None = None, ) -> "ResourceQuery": + """Return clone filtering typed resource properties.""" comparators = { "eq": eq, "gt": gt, @@ -634,6 +754,7 @@ def filter_property( def under_parent( self, parent: ResourceRef | ResourceSchema | UUID | str | Any ) -> "ResourceQuery": + """Return clone restricted to direct children of a parent resource.""" parent_id: UUID if isinstance(parent, ResourceRef | ResourceSchema): parent_id = parent.id @@ -655,6 +776,7 @@ def descendants( *, of_template: UUID | None = None, ) -> "ResourceQuery": + """Return clone restricted to descendants of a parent resource.""" """Fetch every resource beneath ``parent`` (all levels) in one bulk recursive query, with ``template`` and ``properties`` eagerly loaded. @@ -668,25 +790,28 @@ def descendants( class ResourceTemplateQuery(BaseQuery[ResourceTemplateSchema | ResourceTemplateRef]): + """Query resource templates and optionally hydrate child relationships.""" + model = ResourceTemplateSchema default_schema = None def __init__( self, - backend: "Backend", + backend: "ReadBackend", *, shape: ShapeInput = "full", load: LoadInput = "none", + expand: bool | None = None, on_unloaded: OnUnloadedPolicy | None = None, **kwargs, ): shape = _normalize_shape(shape) - load = _normalize_load(load) + load = _normalize_expand(expand, load) if shape == "ref" and load != "none": raise ValueError("load must be 'none' when shape='ref'") self._shape = shape self._load = load - model = ResourceTemplateSchema if shape == "full" else ResourceTemplateRef + model = ResourceTemplateSchema super().__init__( backend, model=model, @@ -698,6 +823,7 @@ def __init__( def _clone(self, **overrides) -> "ResourceTemplateQuery": params = dict( backend=self._backend, + context=self._context, shape=self._shape, load=self._load, filters=dict(self._filters), @@ -709,7 +835,9 @@ def _clone(self, **overrides) -> "ResourceTemplateQuery": property_filters=list(self._property_filters), parent_resource_id=self._parent_resource_id, parameter_filters=list(self._parameter_filters), - campaign_id=self._campaign_id, + include_archived=self._include_archived, + local_metadata_filters=dict(self._local_metadata_filters), + effective_metadata_filters=dict(self._effective_metadata_filters), on_unloaded=self._on_unloaded, ) params.update(overrides) @@ -723,38 +851,49 @@ def include(self, relation_names: str | Sequence[str]) -> "ResourceTemplateQuery return super().include(relation_names) def filter_by_types(self, type_list: list[str]) -> "ResourceTemplateQuery": + """Return clone matching templates carrying every requested type.""" return self.filter(types__names_in=type_list) + def filter_label(self, label: str) -> "ResourceTemplateQuery": + """Return clone matching resource-template label.""" + return self.filter(labels__contains=label) + def include_children(self) -> "ResourceTemplateQuery": + """Return clone preloading child templates.""" return self.include("children") def include_attribute_groups(self) -> "ResourceTemplateQuery": + """Return clone preloading attribute-group templates.""" return self.include("attribute_group_templates") def include_types(self) -> "ResourceTemplateQuery": + """Return clone preloading resource type tags.""" return self.include("types") class ProcessTemplateQuery(BaseQuery[ProcessTemplateSchema | ProcessTemplateRef]): + """Query process templates and optionally hydrate steps and slots.""" + model = ProcessTemplateSchema default_schema = None def __init__( self, - backend: "Backend", + backend: "ReadBackend", *, shape: ShapeInput = "full", load: LoadInput = "none", + expand: bool | None = None, on_unloaded: OnUnloadedPolicy | None = None, **kwargs, ): shape = _normalize_shape(shape) - load = _normalize_load(load) + load = _normalize_expand(expand, load) if shape == "ref" and load != "none": raise ValueError("load must be 'none' when shape='ref'") self._shape = shape self._load = load - model = ProcessTemplateSchema if shape == "full" else ProcessTemplateRef + model = ProcessTemplateSchema super().__init__( backend, model=model, @@ -766,6 +905,7 @@ def __init__( def _clone(self, **overrides) -> "ProcessTemplateQuery": params = dict( backend=self._backend, + context=self._context, shape=self._shape, load=self._load, filters=dict(self._filters), @@ -777,7 +917,9 @@ def _clone(self, **overrides) -> "ProcessTemplateQuery": property_filters=list(self._property_filters), parent_resource_id=self._parent_resource_id, parameter_filters=list(self._parameter_filters), - campaign_id=self._campaign_id, + include_archived=self._include_archived, + local_metadata_filters=dict(self._local_metadata_filters), + effective_metadata_filters=dict(self._effective_metadata_filters), on_unloaded=self._on_unloaded, ) params.update(overrides) @@ -791,61 +933,52 @@ def include(self, relation_names: str | Sequence[str]) -> "ProcessTemplateQuery" return super().include(relation_names) def include_step_templates(self) -> "ProcessTemplateQuery": + """Return clone preloading process step templates.""" return self.include("step_templates") def include_resource_slots(self) -> "ProcessTemplateQuery": + """Return clone preloading process resource slots.""" return self.include("resource_slots") + def filter_label(self, label: str) -> "ProcessTemplateQuery": + """Return clone matching process-template label.""" + return self.filter(labels__contains=label) + class QueryDSL: + """Factory for namespace-scoped query families.""" + def __init__( self, - backend: "Backend", + backend: "ReadBackend", *, - campaign_id: UUID | None = None, + context: NamespaceContext, on_unloaded: OnUnloadedPolicy = "warn", ): if on_unloaded not in {"silent", "warn", "raise"}: raise ValueError("on_unloaded must be one of: 'silent', 'warn', 'raise'") self.backend = backend - self._campaign_id = campaign_id + self.context = context + self.namespace_path = context.path self._on_unloaded = on_unloaded - def _resolve_campaign_id(self, campaign: UUID | str | Any | None) -> UUID | None: - if campaign is None: - return None - if isinstance(campaign, UUID): - return campaign - if isinstance(campaign, str): - return UUID(campaign) - if hasattr(campaign, "id"): - return campaign.id - raise TypeError("campaign must be a UUID, UUID string, or object with an id") - - def _pick_campaign_id(self, campaign: UUID | str | Any | None) -> UUID | None: - return ( - self._resolve_campaign_id(campaign) - if campaign is not None - else self._campaign_id - ) - - def campaigns(self) -> CampaignQuery: - return CampaignQuery(self.backend) + def namespaces(self) -> NamespaceQuery: + return NamespaceQuery(self.backend, context=self.context) def process_runs( self, *, shape: ShapeInput = "full", load: LoadInput = "none", - campaign: UUID | str | Any | None = None, + expand: bool | None = None, on_unloaded: OnUnloadedPolicy | None = None, ) -> ProcessRunQuery: - campaign_id = self._pick_campaign_id(campaign) return ProcessRunQuery( self.backend, + context=self.context, shape=_normalize_shape(shape), load=_normalize_load(load), - campaign_id=campaign_id, + expand=expand, on_unloaded=self._on_unloaded if on_unloaded is None else on_unloaded, ) @@ -854,12 +987,15 @@ def process_templates( *, shape: ShapeInput = "full", load: LoadInput = "none", + expand: bool | None = None, on_unloaded: OnUnloadedPolicy | None = None, ) -> ProcessTemplateQuery: return ProcessTemplateQuery( self.backend, + context=self.context, shape=_normalize_shape(shape), load=_normalize_load(load), + expand=expand, on_unloaded=self._on_unloaded if on_unloaded is None else on_unloaded, ) @@ -868,27 +1004,15 @@ def resources( *, shape: ShapeInput = "full", load: LoadInput = "none", - campaign: UUID | str | Any | None = None, + expand: bool | None = None, on_unloaded: OnUnloadedPolicy | None = None, ) -> ResourceQuery: - """Start a resource query, optionally scoped to a campaign. - - .. note:: Campaign scoping for resources - - Resources do not have a direct ``campaign_id`` column. When a - campaign is active, the scope is applied by joining through - ``ResourceAssignment`` -> ``ProcessRun`` and filtering on - ``ProcessRun.campaign_id``. Resources that have never been - assigned to a process run in the target campaign will be - excluded. Use ``query_maker(unscoped=True)`` to include all - resources regardless of assignment status. - """ - campaign_id = self._pick_campaign_id(campaign) return ResourceQuery( self.backend, + context=self.context, shape=_normalize_shape(shape), load=_normalize_load(load), - campaign_id=campaign_id, + expand=expand, on_unloaded=self._on_unloaded if on_unloaded is None else on_unloaded, ) @@ -897,11 +1021,14 @@ def resource_templates( *, shape: ShapeInput = "full", load: LoadInput = "none", + expand: bool | None = None, on_unloaded: OnUnloadedPolicy | None = None, ) -> ResourceTemplateQuery: return ResourceTemplateQuery( self.backend, + context=self.context, shape=_normalize_shape(shape), load=_normalize_load(load), + expand=expand, on_unloaded=self._on_unloaded if on_unloaded is None else on_unloaded, ) diff --git a/recap/dsl/resource_builder.py b/recap/dsl/resource_builder.py index 1f9a93f..dfb2a81 100644 --- a/recap/dsl/resource_builder.py +++ b/recap/dsl/resource_builder.py @@ -1,21 +1,43 @@ +import json import warnings -from typing import Any, Literal, Optional -from uuid import UUID +from datetime import UTC, datetime +from typing import Any, Literal, Optional, overload +from uuid import UUID, uuid4 from pydantic import BaseModel, Field, create_model -from recap.adapter import Backend +from recap.client.backend import ClientBackend +from recap.commands.models import ( + CommandContext, + CopyResource, + CreateResource, + CreateResourceTemplate, + SetLifecycleStatus, + UpdateResource, + UpdateResourceTemplate, +) from recap.db.resource import Resource from recap.dsl.attribute_builder import AttributeGroupBuilder +from recap.dsl.builder_state import BuilderChanges, BuilderTransactionState +from recap.dsl.drafts import ( + AttributeDraft, + AttributeGroupDraft, + ResourceTemplateDraft, + detached_model, +) from recap.dsl.query import QuerySpec from recap.exceptions import ( ExistingResourceError, ExistingResourceTemplateError, ExistingResourceTemplateWarning, ExistingResourceWarning, + RecapNotFoundError, ) -from recap.schemas.attribute import AttributeTemplateValidator +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext from recap.schemas.resource import ( + ResourceCopyChanges, + ResourceCopyOptions, ResourceSchema, ResourceTemplateRef, ResourceTemplateSchema, @@ -25,18 +47,25 @@ class ResourceBuilder: - def __init__( + def __init__( # noqa: C901 self, # session: Session, name: str | None, template_name: str | None, template_version: str = "1.0", - backend: Backend | None = None, + *, + backend: ClientBackend, + namespace_context: NamespaceContext, parent: "ResourceBuilder | ResourceSchema | None" = None, resource_id: UUID | None = None, on_existing: Literal["create", "silent", "warn", "raise"] = "warn", + command_context: CommandContext, + transaction: BuilderTransactionState | None = None, ): + if command_context is None: + raise ValueError("ResourceBuilder requires command context") self.name = name + self.namespace_context = namespace_context self._children: list[Resource] = [] self.parent = None self.parent_resource = None @@ -47,88 +76,160 @@ def __init__( "on_existing must be one of: 'create', 'silent', 'warn', 'raise'" ) self.on_existing = on_existing + self._command_context = command_context + self._transaction = transaction or BuilderTransactionState() + self.backend = backend + self._submitted = False + self._last_properties_payload = None + self._initial_properties_payload = None + self._reused_existing = False + self._copy_source_id: UUID | None = None + self._provisional_children = None + self._expected_revision = 1 + self._is_new_resource = resource_id is None self._resource: ResourceSchema | None = None - self._uow = None - self._loaded_in_uow: bool = False + self._draft: ResourceSchema | None = None self._configure_parent(parent) - self._configure_backend(backend) - try: - if resource_id is not None: - self._load_existing_resource(resource_id) - else: - self._create_or_reuse_resource() - self._loaded_in_uow = True # mark resource as fresh in this UoW - if self.parent_resource: - self.backend.add_child_resources(self.parent_resource, [self._resource]) - except Exception: - if self._uow: - self._uow.rollback() - self._uow = None - raise + if resource_id is not None: + self._load_existing_resource(resource_id) + else: + self._prepare_new_resource() def _configure_parent(self, parent: "ResourceBuilder | ResourceSchema | None"): if isinstance(parent, self.__class__): self.parent = parent self.parent_resource = parent._resource if parent else None + self._transaction = parent._transaction elif isinstance(parent, ResourceSchema): self.parent_resource = parent - def _configure_backend(self, backend: Backend | None): - if backend: - self.backend = backend - self._ensure_uow() - return - if self.parent: - self.backend = self.parent.backend - self.parent._ensure_uow() - self._uow = self.parent._uow - return - raise ValueError("backend is required") - def _load_existing_resource(self, resource_id: UUID): self._resource = self._reload_resource(resource_id) + self._draft = self._resource.model_copy(deep=True) + self._expected_revision = self._resource.revision + self._is_new_resource = False + self._last_properties_payload = self._resource_properties_payload() + self._submitted = True self.name = self._resource.name self.template_name = self._resource.template.name self.template_version = self._resource.template.version - def _create_or_reuse_resource(self): + def _prepare_new_resource(self): if self.name is None or self.template_name is None: raise ValueError("name and template_name are required") - template = self.backend.get_resource_template( - name=self.template_name, version=self.template_version + templates = self.backend.query( + ResourceTemplateSchema, + QuerySpec( + filters={ + "name": self.template_name, + "version": self.template_version, + }, + include_mutable=True, + load_mode="eager", + ), + namespace_path=self.namespace_context.path, ) - - # For "create" mode, skip lookup and always insert + if not templates: + raise RecapNotFoundError( + f"Resource template {self.template_name!r} version " + f"{self.template_version!r} not found" + ) + template = templates[0] + self._template_id = template.id + if isinstance(template, ResourceTemplateSchema): + self._resource = self._draft_resource(template) + self._draft = self._resource.model_copy(deep=True) + self._initial_properties_payload = self._resource_properties_payload( + self._draft + ) if self.on_existing != "create": parent_id = self.parent_resource.id if self.parent_resource else None - matches = self.backend.find_resources_by_identity( - self.name, parent_id, template.id + matches = self.backend.query( + ResourceSchema, + QuerySpec( + filters={"name": self.name}, + preloads=["template", "parent", "children", "properties"], + include_mutable=True, + ), + namespace_path=self.namespace_context.path, ) + matches = [ + match + for match in matches + if match.template.id == template.id + and ( + match.parent.id + if match.is_loaded("parent") and match.parent + else getattr(match, "parent_id", None) + ) + == parent_id + ] if matches: - existing = matches[0] if self.on_existing == "raise": raise ExistingResourceError( f"Resource {self.name!r} already exists" ) if self.on_existing == "warn": warnings.warn( - ( - f"Resource {self.name!r} already exists and will be " - "reused; no new resource will be created." - ), + f"Resource {self.name!r} already exists and will be reused; " + "no new resource will be created.", ExistingResourceWarning, stacklevel=2, ) - # silent or warn: reuse - self._resource = ResourceSchema.model_validate(existing) - return - - # No match found (or "create" mode) — create new resource - self._resource = self.backend.create_resource( - self.name, - resource_template=template, - parent_resource=self.parent_resource, - expand=True, + self._resource = ResourceSchema.model_validate(matches[0]) + self._draft = self._resource.model_copy( + deep=True, update={"id": self._resource.id} + ) + self._expected_revision = self._resource.revision + self._is_new_resource = False + self._reused_existing = True + self._last_properties_payload = self._resource_properties_payload() + self._submitted = True + + def _draft_resource(self, template: ResourceTemplateSchema) -> ResourceSchema: + properties = {} + for group in template.attribute_group_templates: + properties[group.name] = { + attribute.name: { + "value": ( + json.loads(attribute.default_value) + if attribute.value_type == "array" + and isinstance(attribute.default_value, str) + else attribute.default_value + ), + "unit": attribute.unit, + "metadata_json": attribute.metadata or {}, + } + for attribute in group.attribute_templates + } + properties[group.name] = { + "template": group, + "values": properties[group.name], + } + resource = ResourceSchema.model_construct( + id=uuid4(), + name=self.name, + template=template, + children={}, + properties={ + group_name: self._property_schema(prop) + for group_name, prop in properties.items() + }, + namespace_id=self.namespace_context.id, + create_date=datetime.now(UTC), + modified_date=datetime.now(UTC), + revision=1, + status=LifecycleStatus.MUTABLE, + ) + return resource.build_property_model() + + @staticmethod + def _property_schema(value): + from recap.schemas.resource import PropertySchema + + now = datetime.now(UTC) + return PropertySchema.model_validate( + {"id": UUID(int=0), "create_date": now, "modified_date": now, **value} ) @classmethod @@ -137,82 +238,259 @@ def create( name: str, template_name: str, template_version: str, - backend: Backend, + *, + backend: ClientBackend, + namespace_context: NamespaceContext, + command_context: CommandContext | None = None, parent=None, on_existing: Literal["create", "silent", "warn", "raise"] = "create", ): - with cls( + builder = cls( name, template_name, template_version, - backend, + backend=backend, + namespace_context=namespace_context, + command_context=command_context, parent=parent, on_existing=on_existing, - ) as rb: - return rb.resource + ) + with builder: + builder.save() + return builder._resource def __enter__(self): - self._ensure_uow() - if self._resource is not None and not self._loaded_in_uow: - # Re-entering after save() or _restart_uow() — reload current state - self._resource = self._reload_resource(self._resource.id) - self.name = self._resource.name - self.template_name = self._resource.template.name - self.template_version = self._resource.template.version + self._transaction.enter() return self def __exit__(self, exc_type, exc, tb): - if exc_type is None: - self.persist() - self.save() - else: - if self._uow: - self._uow.rollback() - self._uow = None - - def _ensure_uow(self): - if self._uow is None: - self._uow = self.backend.begin() - return self._uow - - def _restart_uow(self): - if self._uow: - self._uow.rollback() - self._uow = self.backend.begin() - if self.parent: - self.parent._uow = self._uow - self._loaded_in_uow = False # rollback invalidates loaded state - return self._uow + if self._transaction.exit(exc_type): + self._flush() def save(self): - self._ensure_uow() - self._uow.commit() - self._loaded_in_uow = False # stale after commit; reload on next __enter__ - self._uow = None + if not self._transaction.in_context: + raise RuntimeError("Builder changes require a context manager") + return self._flush() + + def changes(self) -> BuilderChanges: + source = self._draft or self._resource + fields = {} + if source is not None: + properties = self._resource_properties_payload(source) + if properties != self._last_properties_payload: + fields["properties"] = properties + if self._resource is not None and source.name != self._resource.name: + fields["name"] = source.name + return BuilderChanges( + fields=fields, + lifecycle=self._transaction.pending_lifecycle_for(self), + ) + + def _pending_lifecycle(self): + return self._transaction.pending_lifecycle_for(self) + + def _flush_lifecycle(self): + pending = self._pending_lifecycle() + if pending is None: + return + result = self.backend._execute( + SetLifecycleStatus( + object_type="resource", + object_id=self.resource.id, + expected_revision=self._resource.revision, + status=pending.value, + ), + self._command_context, + ) + if isinstance(result, ResourceSchema): + self._resource = result + self._draft = result.model_copy(deep=True) + self._expected_revision = result.revision + self._last_properties_payload = self._resource_properties_payload(result) + self._transaction.clear_lifecycle(owner=self) + + def _flush_children(self): + for child in self._children: + child._flush(flush_lifecycle=False) + self._register_child(child) + + def _flush_lifecycle_tree(self): + for child in self._children: + child._flush_lifecycle_tree() + self._flush_lifecycle() + + def _register_child(self, child: "ResourceBuilder"): + if self._draft is None: + if self._resource is None: + raise RuntimeError("Parent builder has no provisional resource") + self._draft = self._resource.model_copy(deep=True) + if not isinstance(self._draft.children, dict): + raise ValueError("Parent resource has invalid children schema") + self._draft.children[child.resource.name] = child.resource + + def _flush(self, *, flush_lifecycle: bool = True): # noqa: C901 + if self._draft is not None and self._resource is not None: + source = ( + self._draft + if isinstance(self._resource, ResourceSchema) + else self._resource + ) + properties = self._resource_properties_payload(source) + else: + properties = self._resource_properties_payload() + unchanged = ( + self._submitted + and properties == self._last_properties_payload + and ( + self._draft is None + or self._resource is None + or getattr(self._draft, "name", None) == getattr(self._resource, "name", None) + ) + ) + if unchanged and self._pending_lifecycle() is None: + self._flush_children() + return self + if unchanged: + self._flush_children() + if flush_lifecycle: + self._flush_lifecycle_tree() + return self + if self._is_new_resource: + if self._copy_source_id is not None: + command = CopyResource( + source_resource_id=self._copy_source_id, + destination_namespace_path=self.namespace_context.path, + options=ResourceCopyOptions( + parent_id=self._parent_id(), + ), + ) + else: + if properties == self._initial_properties_payload: + properties = None + command = CreateResource( + id=getattr(self._resource, "id", None), + namespace_path=self.namespace_context.path, + name=self.name, + template_id=self._template_id, + parent_id=self._parent_id(), + properties=properties, + ) + elif self._resource.status is not LifecycleStatus.MUTABLE: + command = CopyResource( + source_resource_id=self._resource.id, + destination_namespace_path=self.namespace_context.path, + options=ResourceCopyOptions( + name=self._draft.name + if self._draft is not None + else self._resource.name, + changes=ResourceCopyChanges(properties=properties), + ), + ) + else: + command = UpdateResource( + resource_id=self._resource.id, + expected_revision=self._expected_revision, + name=self._draft.name + if self._draft is not None + else self._resource.name, + properties=properties, + ) + result = self.backend._execute(command, self._command_context) + if not isinstance(result, ResourceSchema): + self._submitted = True + self._last_properties_payload = self._resource_properties_payload( + self._draft or self._resource + ) + return self + previous_children = self._provisional_children or ( + self._draft.children if self._draft is not None else {} + ) + if self._copy_source_id is not None and not result.children: + result = self._reload_resource(result.id) + if previous_children: + result.children = {**previous_children, **result.children} + self._resource = result + self._draft = result.model_copy(deep=True) + self._expected_revision = result.revision + self._is_new_resource = False + self._last_properties_payload = self._resource_properties_payload(result) + self._submitted = True + self._flush_children() + if not flush_lifecycle: + return self + self._flush_lifecycle_tree() + return self + + def _resource_properties_payload(self, resource=None): + resource = resource or self._resource + if resource is None: + return None + payload = {} + properties = ( + resource.properties + if isinstance(resource.properties, dict) + else { + name: getattr(resource.properties, name) + for name in type(resource.properties).model_fields + } + ) + for group_name, prop in properties.items(): + value_names = ( + type(prop.values).model_fields + if isinstance(prop.values, BaseModel) + else vars(prop.values) + ) + payload[group_name] = { + value_name: { + "value": getattr(prop.values, value_name).value, + "unit": getattr(prop.values, value_name).unit, + "metadata_json": getattr(prop.values, value_name).metadata_json, + } + for value_name in value_names + } + return payload + + def _parent_id(self) -> UUID | None: + if self.parent is None and self.parent_resource is None: + return None + parent = ( + self.parent._draft or self.parent._resource + if self.parent + else self.parent_resource + ) + if parent is None: + raise RuntimeError("Child builder requires initialized parent resource") + return parent.id + + def finalize(self): + self._transaction.request_lifecycle(LifecycleStatus.ACTIVE, owner=self) return self - def persist(self): - if self._resource is None: - raise RuntimeError("Resource not initialized") - self._resource = self.backend.update_resource(self._resource) + def archive(self): + self._transaction.request_lifecycle(LifecycleStatus.ARCHIVED, owner=self) return self def _reload_resource(self, resource_id: UUID) -> ResourceSchema: resources = self.backend.query( ResourceSchema, - QuerySpec(filters={"id": resource_id}, preloads=["children", "properties"]), + QuerySpec( + filters={"id": resource_id}, + preloads=["template", "parent", "children", "properties"], + include_mutable=True, + ), + namespace_path=self.namespace_context.path, ) if not resources: - raise ValueError(f"Resource with id {resource_id} not found") + raise RecapNotFoundError(f"Resource with id {resource_id} not found") return resources[0] @property def resource(self) -> ResourceSchema: - if self._resource is None: + if self._draft is None: raise RuntimeError( "Call .save() first or construct resource via builder methods" ) - return self._resource + return self._draft def get_model(self, *, update: bool = False) -> ResourceSchema: """ @@ -221,7 +499,9 @@ def get_model(self, *, update: bool = False) -> ResourceSchema: """ if update and self._resource: self._resource = self._reload_resource(self._resource.id) - model = (self._resource or self.resource).model_copy(deep=True) + self._expected_revision = self._resource.revision + self._draft = self._resource.model_copy(deep=True) + model = (self._draft or self.resource).model_copy(deep=True) return lock_instance_fields( model, {"id", "create_date", "modified_date", "slug", "template"} ) @@ -231,17 +511,105 @@ def set_model(self, model: ResourceSchema): raise ValueError( "ID for this Resource does not match the builder's resource" ) - self._resource = model + self._draft = detached_model(model) + self._submitted = False + @overload def add_child( self, name: str, template_name: str, template_version: str = "1.0" + ) -> "ResourceBuilder": ... + + @overload + def add_child(self, source: UUID | ResourceSchema) -> "ResourceBuilder": ... + + @overload + def add_child( + self, + *, + source: UUID | ResourceSchema, + ) -> "ResourceBuilder": ... + + def add_child( + self, + name: str | UUID | ResourceSchema | None = None, + template_name: str | None = None, + template_version: str = "1.0", + *, + source: UUID | ResourceSchema | None = None, ) -> "ResourceBuilder": + if isinstance(name, (UUID, ResourceSchema)): + if source is not None: + raise TypeError("Provide either positional source or source=, not both") + source = name + name = None + if source is not None: + if name is not None: + raise TypeError("Copied child cannot include name") + if template_name is not None or template_version != "1.0": + raise TypeError("Copied child accepts exactly one source argument") + source_id = ( + source.id if isinstance(source, ResourceSchema) else source + ) + source = self._reload_resource(source_id) + if not isinstance(source, ResourceSchema) or not isinstance( + source.template, ResourceTemplateSchema + ): + raise ValueError("Copied child requires valid resource schema") + copied = source.model_copy(deep=True) + copied.id = uuid4() + copied.parent = self.resource + copied.copied_from_id = source_id + if not copied.children: + def clone_children(parent, children): + result = {} + for child_name, child in children.items(): + clone = child.model_copy(deep=True) + clone.id = uuid4() + clone.parent = parent + clone.copied_from_id = None + clone.children = clone_children(clone, clone.children) + result[child_name] = clone + return result + + copied.children = clone_children(copied, source.children) + child_builder = ResourceBuilder( + name=copied.name, + template_name=copied.template.name, + backend=self.backend, + namespace_context=self.namespace_context, + command_context=self._command_context, + parent=self, + resource_id=None, + transaction=self._transaction, + ) + child_builder._resource = copied + child_builder._draft = copied.model_copy(deep=True) + child_builder._is_new_resource = True + child_builder._submitted = False + child_builder._template_id = copied.template.id + child_builder._copy_source_id = source_id + child_builder._provisional_children = copied.children + child_builder.name = copied.name + child_builder.template_name = copied.template.name + child_builder.template_version = copied.template.version + child_builder._initial_properties_payload = child_builder._resource_properties_payload(copied) + self._register_child(child_builder) + self._children.append(child_builder) + return child_builder + if name is None or template_name is None: + raise TypeError("New child requires name and template_name") child_builder = ResourceBuilder( name=name, template_name=template_name, template_version=template_version, + namespace_context=self.namespace_context, + backend=self.backend, + command_context=self._command_context, parent=self, + transaction=self._transaction, ) + self._register_child(child_builder) + self._children.append(child_builder) return child_builder def close_child(self): @@ -289,7 +657,7 @@ def get_props(self) -> type[BaseModel]: def set_props(self, filled_props): if self.resource is None: raise ValueError("Resource not setup") - for prop in self.resource.properties.values(): + for prop in self._draft.properties.values(): filled_prop = filled_props.get(prop.template.name) for value_name in self.resource.properties[prop.template.name].values: self.resource.properties[prop.template.name].values[value_name] = ( @@ -298,17 +666,28 @@ def set_props(self, filled_props): class ResourceTemplateBuilder: - def __init__( + def __init__( # noqa: C901 self, name: str | None, - type_names: list[str] | None, + type_names: list[str] | None = None, version: str = "1.0", parent: Optional["ResourceTemplateBuilder"] = None, - backend: Backend | None = None, + *, + backend: ClientBackend, + namespace_context: NamespaceContext, resource_template_id: UUID | None = None, on_existing: Literal["silent", "warn", "raise"] = "warn", + command_context: CommandContext, ): - self._uow = None + self.namespace_context = namespace_context + self._command_context = command_context + self.backend = backend + self._submitted = False + self._last_draft = None + self._transaction = BuilderTransactionState() + self._expected_revision = 1 + self._draft_groups: list[AttributeGroupDraft] = [] + self._draft_children: list[ResourceTemplateBuilder] = [] self.name = name self.type_names = type_names self._children: list[ResourceTemplateRef] = [] @@ -319,118 +698,121 @@ def __init__( raise ValueError("on_existing must be one of: 'silent', 'warn', 'raise'") self.on_existing = on_existing self._template: ResourceTemplateRef | ResourceTemplateSchema | None = None - self._configure_backend(backend) - try: - if resource_template_id is not None: - self._load_existing_template(resource_template_id) - else: - self._create_or_reuse_template() - except Exception: - if self._uow: - self._uow.rollback() - self._uow = None - raise - - def _configure_backend(self, backend: Backend | None): - if backend: - self.backend = backend - self._ensure_uow() - return - if self.parent: - self.backend = self.parent.backend - self.parent._ensure_uow() - self._uow = self.parent._uow - return - raise ValueError("No parent builder or backend provided") - - def _load_existing_template(self, resource_template_id: UUID): - tmpl = self.backend.get_resource_template( - name=None, version=None, id=resource_template_id, expand=True - ) - self.name = tmpl.name - self.type_names = [rt.name for rt in tmpl.types] - self.version = tmpl.version - self._template = tmpl - for rt_schema in tmpl.types: - self.resource_types[rt_schema.name] = rt_schema - - def _create_or_reuse_template(self): - if self.name is None or self.type_names is None: - raise ValueError("name and type_names are required") - for rt_schema in self.backend.add_resource_types(self.type_names): - self.resource_types[rt_schema.name] = rt_schema - try: - self._template = self._create_template() - except Exception as exc: - self._handle_existing_template(exc) - - def _create_template(self) -> ResourceTemplateRef: - if self.parent: - return self.backend.add_child_resource_template( - self.name, - [rt for rt in self.resource_types.values()], - version=self.version, - parent_resource_template=self.parent._template, + self._is_new_template = resource_template_id is None + self._draft_model: ResourceTemplateSchema | None = None + if resource_template_id is not None: + self._initialize_command_update(resource_template_id) + elif name is None or type_names is None: + raise ValueError( + "name and type_names are required to create a resource template" ) - return self.backend.add_resource_template( - self.name, - list(self.resource_types.values()), - version=self.version, - ) - - def _handle_existing_template(self, create_error: Exception): - self._restart_uow() - if self.on_existing == "raise": - raise ExistingResourceTemplateError( - f"Resource template {self.name!r} version {self.version!r} already exists" - ) from create_error - self._template = self._fetch_existing_template() - if self.on_existing == "warn": - warnings.warn( - ( - f"Resource template {self.name!r} version {self.version!r} " - "already exists and will be reused; no new template " - "will be created. If you want a new template, bump the version." - ), - ExistingResourceTemplateWarning, - stacklevel=2, + else: + self._template = ResourceTemplateRef.model_construct( + id=uuid4(), + create_date=None, + modified_date=None, + namespace_id=self.namespace_context.id, + status=LifecycleStatus.MUTABLE, + revision=1, + name=name, + slug=None, + version=version, + labels=[], + types=[], ) - - def _fetch_existing_template(self): - if self.parent: - return self.backend.get_resource_template( - self.name, - version=self.version, - parent=self.parent._template, - expand=True, + existing = self.backend.query( + ResourceTemplateSchema, + QuerySpec( + filters={"name": name, "version": version}, + include_mutable=True, + load_mode="eager", + ), + namespace_path=self.namespace_context.path, ) - return self.backend.get_resource_template( - self.name, - version=self.version, - expand=True, - ) + if existing: + if on_existing == "raise": + raise ExistingResourceTemplateError( + f"Resource template {name!r} version {version!r} already exists" + ) + if on_existing == "warn": + warnings.warn( + f"Resource template {name!r} version {version!r} already exists and will be reused; bump the version", + ExistingResourceTemplateWarning, + stacklevel=2, + ) + self._initialize_command_update(existing[0].id) def __enter__(self): - self._ensure_uow() - if self._template is not None: - self._reload_template() - self.name = self._template.name - self.type_names = [rt.name for rt in self._template.types] - self.version = self._template.version + self._transaction.enter() return self def __exit__(self, exc_type, exc, tb): - if exc_type is None: - self.save() - else: - if self._uow: - self._uow.rollback() - self._uow = None + if self._transaction.exit(exc_type): + self._flush() def save(self): - self._ensure_uow() - self._uow.commit() - self._uow = None + if not self._transaction.in_context: + raise RuntimeError("Builder changes require a context manager") + return self._flush() + + def changes(self) -> BuilderChanges: + draft = self._build_draft() + return BuilderChanges( + fields={"draft": draft.model_dump(mode="json")} + if draft != self._last_draft + else {}, + lifecycle=self._transaction.pending_lifecycle, + ) + + def _flush(self): + draft = self._build_draft() + if draft != self._last_draft: + if self._is_new_template: + command = CreateResourceTemplate( + namespace_path=self.namespace_context.path, draft=draft + ) + else: + command = UpdateResourceTemplate( + template_id=self._template.id, + expected_revision=self._expected_revision, + draft=draft, + ) + result = self.backend._execute(command, self._command_context) + if result is None: + return self + self._template = result + self._expected_revision = result.revision + self._last_draft = draft + self._submitted = True + elif self._template is not None: + self._last_draft = draft + + pending = self._transaction.pending_lifecycle + if pending is None: + return self + if self._template is None: + return self + result = self.backend._execute( + SetLifecycleStatus( + object_type="resource_template", + object_id=self.template.id, + expected_revision=self._template.revision, + status=pending.value, + ), + self._command_context, + ) + if isinstance(result, ResourceTemplateSchema): + self._template = result + self._expected_revision = result.revision + self._transaction.clear_lifecycle() + return self + + def finalize(self): + self._transaction.request_lifecycle(LifecycleStatus.ACTIVE) + return self + + def archive(self): + self._transaction.request_lifecycle(LifecycleStatus.ARCHIVED) return self @property @@ -444,11 +826,12 @@ def template(self) -> ResourceTemplateRef: def prop_group( self, group_name: str ) -> AttributeGroupBuilder["ResourceTemplateBuilder"]: - self._ensure_uow() - agb: AttributeGroupBuilder[ResourceTemplateBuilder] = AttributeGroupBuilder( - group_name=group_name, parent=self + existing = next( + (draft for draft in self._draft_groups if draft.name == group_name), None ) - return agb + if existing is not None: + return _DraftResourceAttributeGroupBuilder(group_name, self, existing) + return _DraftResourceAttributeGroupBuilder(group_name, self) def add_properties( self, prop_def: dict[str, list[dict[str, Any]]] @@ -472,56 +855,66 @@ def add_properties( Returns: ``self``, to allow method chaining. """ - self._ensure_uow() - - for group_key, props in prop_def.items(): - agb = AttributeGroupBuilder(group_name=group_key, parent=self) - for prop in props: - attr = AttributeTemplateValidator.model_validate(prop) - agb.add_attribute( - attr.name, - attr.type, - attr.unit, - attr.default, - metadata=attr.metadata, - ) - agb.close_group() + self._draft_groups.extend( + AttributeGroupDraft( + name=group_key, + attributes=[ + AttributeDraft( + name=prop["name"], + type=prop["type"], + unit=prop.get("unit", ""), + default=prop.get("default"), + metadata=prop.get("metadata", {}), + ) + for prop in props + ], + ) + for group_key, props in prop_def.items() + ) return self def add_child( self, name: str, type_names: list[str], version: str = "1.0" ) -> "ResourceTemplateBuilder": - self._ensure_uow() child_builder = ResourceTemplateBuilder( - name=name, type_names=type_names, version=version, parent=self + name=name, + type_names=type_names, + version=version, + parent=self, + namespace_context=self.namespace_context, + backend=self.backend, + command_context=self._command_context, ) + self._draft_children.append(child_builder) return child_builder def _reload_template(self): - self._ensure_uow() - self._template = self.backend.get_resource_template( - self.name, - version=self.version, - id=self._template.id if self._template else None, - expand=True, + templates = self.backend.query( + ResourceTemplateSchema, + QuerySpec( + filters={"id": self._template.id if self._template else None}, + include_mutable=True, + load_mode="eager", + ), + namespace_path=self.namespace_context.path, ) + if not templates: + raise RecapNotFoundError("Resource template not found") + self._template = templates[0] def get_model(self, *, update: bool = False) -> ResourceTemplateSchema: """ Return a pydantic model for the resource template, optionally reloading from the backend first. Critical fields are locked against mutation. """ - self._ensure_uow() - if update and self._template: - self._reload_template() - model = self.backend.get_resource_template( - self.name, - version=self.version, - id=self._template.id if self._template else None, - expand=True, - ) + if (update and self._transaction.in_context) or ( + self._template is None and self._transaction.in_context + ): + self.save() + if not isinstance(self._template, ResourceTemplateSchema): + raise RuntimeError("Command backend did not return resource template") return lock_instance_fields( - model.model_copy(deep=True), + self._template.model_copy(deep=True), {"id", "create_date", "modified_date", "version"}, ) @@ -532,23 +925,165 @@ def set_model(self, model: ResourceTemplateSchema | ResourceTemplateRef): raise ValueError( "ID for this ResourceTemplate does not match the builder's template" ) - self._template = model - - def _ensure_uow(self): - if self._uow is None: - self._uow = self.backend.begin() - return self._uow - - def _restart_uow(self): - if self._uow: - self._uow.rollback() - self._uow = self.backend.begin() - if self.parent: - self.parent._uow = self._uow - return self._uow + self._draft_model = detached_model(model) + if isinstance(model, ResourceTemplateSchema): + self.name = model.name + self.version = model.version + self.type_names = [resource_type.name for resource_type in model.types] + self._submitted = False def close_child(self): if self.parent: return self.parent else: return self + + def _build_draft(self) -> ResourceTemplateDraft: + if self._draft_model is not None: + model = self._draft_model + return ResourceTemplateDraft( + id=model.id, + name=model.name, + version=model.version, + labels=model.labels, + type_names=[resource_type.name for resource_type in model.types], + property_groups=[ + AttributeGroupDraft( + name=group.name, + attributes=[ + AttributeDraft( + name=attribute.name, + type=attribute.value_type, + unit=attribute.unit or "", + default=attribute.default_value, + metadata=attribute.metadata or {}, + ) + for attribute in group.attribute_templates + ], + ) + for group in model.attribute_group_templates + ], + children=[ + ResourceTemplateBuilder._draft_from_model(child) + for child in model.children.values() + ], + ) + return ResourceTemplateDraft( + id=self._template.id, + name=self.name, + version=self.version, + type_names=self.type_names or (), + property_groups=self._draft_groups, + children=[child._build_draft() for child in self._draft_children], + ) + + @staticmethod + def _draft_from_model(model: ResourceTemplateSchema) -> ResourceTemplateDraft: + return ResourceTemplateDraft( + id=model.id, + name=model.name, + version=model.version, + labels=model.labels, + type_names=[resource_type.name for resource_type in model.types], + property_groups=[ + AttributeGroupDraft( + name=group.name, + attributes=[ + AttributeDraft( + name=attribute.name, + type=attribute.value_type, + unit=attribute.unit or "", + default=attribute.default_value, + metadata=attribute.metadata or {}, + ) + for attribute in group.attribute_templates + ], + ) + for group in model.attribute_group_templates + ], + children=[ + ResourceTemplateBuilder._draft_from_model(child) + for child in model.children.values() + ], + ) + + def _initialize_command_update(self, resource_template_id: UUID) -> None: + self._is_new_template = False + templates = self.backend.query( + ResourceTemplateSchema, + QuerySpec( + filters={"id": resource_template_id}, + include_mutable=True, + load_mode="eager", + ), + namespace_path=self.namespace_context.path, + ) + if not templates: + raise RecapNotFoundError( + f"ResourceTemplate with id {resource_template_id} not found" + ) + template = templates[0] + self._template = template + self.name = template.name + self.version = template.version + self._expected_revision = template.revision + self.type_names = [resource_type.name for resource_type in template.types] + self._draft_groups = [ + AttributeGroupDraft( + name=group.name, + attributes=[ + AttributeDraft( + name=attribute.name, + type=attribute.value_type, + unit=attribute.unit or "", + default=attribute.default_value, + metadata=attribute.metadata or {}, + ) + for attribute in group.attribute_templates + ], + ) + for group in template.attribute_group_templates + ] + self._last_draft = self._build_draft() + self._submitted = True + + +class _DraftResourceAttributeGroupBuilder: + def __init__(self, group_name: str, parent: ResourceTemplateBuilder, draft=None): + self.parent = parent + self._draft = draft or AttributeGroupDraft(name=group_name, attributes=[]) + + def add_attribute( + self, + attr_name: str, + value_type: str, + unit: str, + default: Any, + metadata: dict[str, Any] | None = None, + ): + if any(attribute.name == attr_name for attribute in self._draft.attributes): + return self + self._draft = self._draft.model_copy( + update={ + "attributes": self._draft.attributes + + ( + AttributeDraft( + name=attr_name, + type=value_type, + unit=unit, + default=default, + metadata=metadata or {}, + ), + ) + } + ) + return self + + def close_group(self): + for index, draft in enumerate(self.parent._draft_groups): + if draft.name == self._draft.name: + self.parent._draft_groups[index] = self._draft + break + else: + self.parent._draft_groups.append(self._draft) + return self.parent diff --git a/recap/exceptions.py b/recap/exceptions.py index df3a8ca..13b2fd6 100644 --- a/recap/exceptions.py +++ b/recap/exceptions.py @@ -60,14 +60,102 @@ class UnloadedFieldError(RuntimeError): """Raised when unloaded relationship access policy is set to 'raise'.""" -class RecapConnectionError(Exception): - """Raised when RecapClient cannot connect to a recap server.""" +class RecapError(Exception): + code = "recap_error" def __init__( - self, url: str, status_code: int | None = None, message: str | None = None - ): + self, + message: str, + *, + url: str | None = None, + status_code: int | None = None, + request_id: str | None = None, + ) -> None: + self.message = message self.url = url self.status_code = status_code - detail = f" (HTTP {status_code})" if status_code else "" - extra = f": {message}" if message else "" - super().__init__(f"Cannot connect to recap server at {url}{detail}{extra}") + self.request_id = request_id + details = [message] + if status_code is not None: + details.append(f"HTTP {status_code}") + if request_id is not None: + details.append(f"request_id={request_id}") + super().__init__("; ".join(details)) + + +class RecapConnectionError(RecapError): + code = "connection_error" + + +class RecapProtocolError(RecapError): + code = "protocol_error" + + +class RecapRequestError(RecapError): + code = "request_error" + + +class RecapAuthenticationError(RecapRequestError): + code = "authentication_required" + + +class RecapPermissionDeniedError(RecapRequestError): + code = "permission_denied" + + +class AuthorizationDenied(RecapPermissionDeniedError): + """Signal an authorization denial without carrying sensitive context.""" + + def __init__(self, *, conceal: bool = False) -> None: + super().__init__("Permission denied") + self.conceal = conceal + + +class RecapNotFoundError(RecapRequestError): + code = "not_found" + + +class RecapValidationError(RecapRequestError): + code = "validation_error" + + +class RecapConflictError(RecapRequestError): + code = "conflict" + + +class RecapServiceUnavailableError(RecapRequestError): + code = "service_unavailable" + + +class RecapInternalError(RecapRequestError): + code = "internal_error" + + +ERROR_TYPES = { + "authentication_required": RecapAuthenticationError, + "permission_denied": RecapPermissionDeniedError, + "not_found": RecapNotFoundError, + "validation_error": RecapValidationError, + "conflict": RecapConflictError, + "service_unavailable": RecapServiceUnavailableError, + "internal_error": RecapInternalError, + "request_error": RecapRequestError, +} + + +def error_from_code( + code: str, + message: str, + *, + url: str | None = None, + status_code: int | None = None, + request_id: str | None = None, +) -> RecapRequestError: + error_type = ERROR_TYPES.get(code, RecapRequestError) + error = error_type( + message, + url=url, + status_code=status_code, + request_id=request_id, + ) + return error diff --git a/recap/exporters/__init__.py b/recap/exporters/__init__.py new file mode 100644 index 0000000..5549993 --- /dev/null +++ b/recap/exporters/__init__.py @@ -0,0 +1,9 @@ +from .protocol import ExportContext, Exporter +from .registry import ExporterRegistry, default_exporter_registry + +__all__ = [ + "ExportContext", + "Exporter", + "ExporterRegistry", + "default_exporter_registry", +] diff --git a/recap/exporters/protocol.py b/recap/exporters/protocol.py new file mode 100644 index 0000000..d321271 --- /dev/null +++ b/recap/exporters/protocol.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import IO, TYPE_CHECKING, Protocol + +from pydantic import BaseModel + +if TYPE_CHECKING: + from recap.dsl.query import BaseQuery + + +@dataclass(frozen=True, slots=True) +class ExportContext: + query: BaseQuery + items: Sequence[BaseModel] + + +class Exporter(Protocol): + def export( + self, + context: ExportContext, + destination: Path | IO | None, + ) -> object: ... diff --git a/recap/exporters/registry.py b/recap/exporters/registry.py new file mode 100644 index 0000000..bfb0499 --- /dev/null +++ b/recap/exporters/registry.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from .protocol import Exporter + + +class ExporterRegistry: + def __init__(self) -> None: + self._exporters: dict[str, Exporter] = {} + + def register(self, name: str, exporter: Exporter) -> None: + if name in self._exporters: + raise ValueError(f"Exporter already registered for format {name!r}") + self._exporters[name] = exporter + + def get(self, name: str) -> Exporter: + try: + return self._exporters[name] + except KeyError as exc: + raise KeyError(f"Unknown export format {name!r}") from exc + + +default_exporter_registry = ExporterRegistry() diff --git a/recap/lifecycle.py b/recap/lifecycle.py new file mode 100644 index 0000000..faf86a7 --- /dev/null +++ b/recap/lifecycle.py @@ -0,0 +1,35 @@ +from enum import Enum + + +class LifecycleStatus(str, Enum): + """Monotonic lifecycle state for persisted RECAP entities.""" + + MUTABLE = "MUTABLE" + ACTIVE = "ACTIVE" + ARCHIVED = "ARCHIVED" + + +_ALLOWED = { + LifecycleStatus.MUTABLE: {LifecycleStatus.ACTIVE, LifecycleStatus.ARCHIVED}, + LifecycleStatus.ACTIVE: {LifecycleStatus.ARCHIVED}, + LifecycleStatus.ARCHIVED: set(), +} + + +def validate_transition(source: LifecycleStatus, target: LifecycleStatus) -> None: + """Validate lifecycle transition, allowing idempotent same-state updates. + + Parameters + ---------- + source, target + Current and requested lifecycle states. + + Raises + ------ + ValueError + If transition would move an entity backwards or out of ``ARCHIVED``. + """ + if source == target: + return + if target not in _ALLOWED[source]: + raise ValueError(f"Invalid lifecycle transition: {source} -> {target}") diff --git a/recap/schemas/__init__.py b/recap/schemas/__init__.py index ad92fc4..4f96399 100644 --- a/recap/schemas/__init__.py +++ b/recap/schemas/__init__.py @@ -1 +1,74 @@ -# Pydantic schema models for RECAP domain objects. +__all__ = [ + "AttributeGroupRef", + "AttributeGroupTemplate", + "AttributeGroupTemplateRef", + "AttributeGroupTemplateSchema", + "AttributeTemplateSchema", + "AttributeTemplateValidator", + "AttributeValueSchema", + "NamespaceContext", + "NamespaceRef", + "NamespaceSchema", + "ParameterSchema", + "ProcessRunRef", + "ProcessRunCopyChanges", + "ProcessRunCopyOptions", + "ProcessRunSchema", + "ProcessTemplateRef", + "ProcessTemplateSchema", + "ResourceAssignmentSchema", + "ResourceCopyChanges", + "ResourceCopyOptions", + "ResourceRef", + "ResourceSchema", + "ResourceTemplateRef", + "ResourceTemplateSchema", + "ResourceTypeSchema", + "StepSchema", + "StepTemplateRef", + "StepTemplate", + "StepTemplateSchema", +] + +_MODULES = { + "AttributeGroupRef": ".attribute", + "AttributeGroupTemplate": ".attribute", + "AttributeGroupTemplateRef": ".attribute", + "AttributeGroupTemplateSchema": ".attribute", + "AttributeTemplateSchema": ".attribute", + "AttributeTemplateValidator": ".attribute", + "AttributeValueSchema": ".attribute", + "NamespaceContext": ".namespace", + "NamespaceRef": ".namespace", + "NamespaceSchema": ".namespace", + "ParameterSchema": ".step", + "ProcessRunRef": ".process", + "ProcessRunCopyChanges": ".process", + "ProcessRunCopyOptions": ".process", + "ProcessRunSchema": ".process", + "ProcessTemplateRef": ".process", + "ProcessTemplateSchema": ".process", + "ResourceAssignmentSchema": ".resource", + "ResourceCopyChanges": ".resource", + "ResourceCopyOptions": ".resource", + "ResourceRef": ".resource", + "ResourceSchema": ".resource", + "ResourceTemplateRef": ".resource", + "ResourceTemplateSchema": ".resource", + "ResourceTypeSchema": ".resource", + "StepSchema": ".step", + "StepTemplateRef": ".step", + "StepTemplate": ".step", + "StepTemplateSchema": ".step", +} + + +def __getattr__(name): + module_name = _MODULES.get(name) + if module_name is None: + raise AttributeError(name) + from importlib import import_module + + value = getattr(import_module(module_name, __name__), name) + globals()[name] = value + return value diff --git a/recap/schemas/attribute.py b/recap/schemas/attribute.py index be7850b..188f5cb 100644 --- a/recap/schemas/attribute.py +++ b/recap/schemas/attribute.py @@ -25,7 +25,7 @@ model_validator, ) -from recap.schemas.common import SIMPLE_FIELD, CommonFields +from recap.schemas.common import SIMPLE_FIELD, CommonFields, LoadAwareMixin from recap.utils.general import CONVERTERS TypeName = Literal["int", "float", "bool", "str", "datetime", "array", "enum"] @@ -81,7 +81,7 @@ class AttributeGroupRef(CommonFields): slug: Annotated[str, SIMPLE_FIELD] -class AttributeGroupTemplateSchema(CommonFields): +class AttributeGroupTemplateSchema(LoadAwareMixin, CommonFields): """A named group of :class:`AttributeTemplateSchema` instances. Property groups organise related attributes together. For example, a @@ -98,7 +98,12 @@ class AttributeGroupTemplateSchema(CommonFields): name: Annotated[str, SIMPLE_FIELD] slug: Annotated[str, SIMPLE_FIELD] - attribute_templates: list[AttributeTemplateSchema] + attribute_templates: list[AttributeTemplateSchema] = [] + _relation_fields = frozenset({"attribute_templates"}) + + def set_loaded_relations(self, loaded_relations, *, on_unloaded="warn"): + LoadAwareMixin.set_loaded_relations(self, loaded_relations, on_unloaded=on_unloaded) + return self class AttributeValueSchema(BaseModel): @@ -124,6 +129,7 @@ class AttributeValueSchema(BaseModel): model_config = ConfigDict(extra="forbid", validate_assignment=True) value: Any = None unit: str | None = None + metadata_json: dict[str, Any] = Field(default_factory=dict) def __str__(self) -> str: """Return ``""`` when a unit is set, otherwise ``str(value)``.""" @@ -145,6 +151,11 @@ def coerce_scalar_value(cls, data): return {"value": data} +AttributeGroupRef = AttributeGroupTemplateSchema # noqa: F811 +AttributeGroupTemplate = AttributeGroupTemplateSchema +AttributeGroupTemplateRef = AttributeGroupTemplateSchema + + class AttributeTemplateValidator(BaseModel): """Transient validator for a raw attribute definition supplied via the builder API. diff --git a/recap/schemas/common.py b/recap/schemas/common.py index 129cdcf..8768660 100644 --- a/recap/schemas/common.py +++ b/recap/schemas/common.py @@ -7,14 +7,103 @@ model inherits. """ +import warnings from datetime import datetime from enum import Enum -from typing import Annotated +from typing import Annotated, ClassVar, Literal, Protocol, runtime_checkable from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, model_validator + +from recap.exceptions import UnloadedFieldError, UnloadedFieldWarning +from recap.lifecycle import LifecycleStatus +from recap.utils.general import make_slug + + +@runtime_checkable +class LoadAware(Protocol): + """Protocol for persisted models with selectively loaded relations.""" + + def is_loaded(self, relation: str) -> bool: ... + + def require_loaded(self, relation: str) -> None: ... + + def set_loaded_relations( + self, + loaded_relations: dict[str, bool], + *, + on_unloaded: Literal["silent", "warn", "raise"] = "warn", + ) -> "LoadAware": ... + + +class LoadAwareMixin: + """Track selectively loaded relation fields and guard their access.""" + + _relation_fields: ClassVar[frozenset[str]] = frozenset() + + @property + def _loaded_relations(self) -> dict[str, bool]: + return (getattr(self, "__pydantic_private__", None) or {}).get("_loaded_relations", {}) + + @property + def _on_unloaded(self) -> Literal["silent", "warn", "raise"]: + return (getattr(self, "__pydantic_private__", None) or {}).get("_on_unloaded", "warn") + + @property + def _warned_unloaded(self) -> set[str]: + return (getattr(self, "__pydantic_private__", None) or {}).setdefault("_warned_unloaded", set()) + + def set_loaded_relations( + self, + loaded_relations: dict[str, bool], + *, + on_unloaded: Literal["silent", "warn", "raise"] = "warn", + ): + private = object.__getattribute__(self, "__pydantic_private__") + if private is None: + private = {} + object.__setattr__(self, "__pydantic_private__", private) + private["_loaded_relations"] = dict(loaded_relations) + private["_on_unloaded"] = on_unloaded + private["_warned_unloaded"] = set() + return self + + def is_loaded(self, relation: str) -> bool: + private = getattr(self, "__pydantic_private__", None) or {} + return private.get("_loaded_relations", {}).get(relation, False) + + def require_loaded(self, relation: str) -> None: + self._handle_unloaded(relation, f"include('{relation}')") + + def _handle_unloaded(self, field_name: str, include_hint: str) -> None: + private = getattr(self, "__pydantic_private__", None) or {} + loaded_relations = private.get("_loaded_relations", {}) + if loaded_relations.get(field_name, True): + return + message = ( + f"'{field_name}' was not loaded for {type(self).__name__}; " + f"use {include_hint} or load='eager'." + ) + on_unloaded = private.get("_on_unloaded", "warn") + warned = private.setdefault("_warned_unloaded", set()) + if on_unloaded == "raise": + raise UnloadedFieldError(message) + if on_unloaded == "warn" and field_name not in warned: + warnings.warn(message, UnloadedFieldWarning, stacklevel=3) + warned.add(field_name) + + def __getattribute__(self, name: str): + relation_fields = object.__getattribute__(self, "_relation_fields") + if name in relation_fields: + object.__getattribute__(self, "_handle_unloaded")(name, f"include('{name}')") + return super().__getattribute__(name) SIMPLE_FIELD = "simple_field" +NormalizedLabels = Annotated[ + list[str], + SIMPLE_FIELD, + BeforeValidator(lambda labels: [make_slug(x) for x in labels]), +] class ValueType(str, Enum): @@ -134,3 +223,9 @@ class CommonFields(BaseModel): modified_date: Annotated[datetime, SIMPLE_FIELD] = Field(repr=False) model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class NamespaceOwnedFields(CommonFields): + namespace_id: Annotated[UUID, SIMPLE_FIELD] + status: Annotated[LifecycleStatus, SIMPLE_FIELD] + revision: Annotated[int, SIMPLE_FIELD] diff --git a/recap/schemas/namespace.py b/recap/schemas/namespace.py new file mode 100644 index 0000000..e236a0a --- /dev/null +++ b/recap/schemas/namespace.py @@ -0,0 +1,50 @@ +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from recap.lifecycle import LifecycleStatus +from recap.schemas.common import CommonFields + + +class NamespaceRef(BaseModel): + """Lightweight namespace identity used in relationships and query results.""" + + id: UUID + path: str + + model_config = ConfigDict(from_attributes=True) + + +class NamespaceContext(NamespaceRef): + """Active namespace scope attached to client queries and writes.""" + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + metadata: dict[str, Any] = Field( + default_factory=dict, + validation_alias="metadata_json", + serialization_alias="metadata", + ) + status: LifecycleStatus | None = None + revision: int | None = None + etag: str | None = None + + @model_validator(mode="after") + def populate_etag(self): + if self.etag is None and self.revision is not None: + self.etag = f'"{self.revision}"' + return self + +class NamespaceSchema(CommonFields): + """Persisted namespace with lifecycle, hierarchy, and metadata fields.""" + + path: str + parent_id: UUID | None = None + status: LifecycleStatus + revision: int + metadata: dict[str, Any] = Field( + default_factory=dict, + validation_alias="metadata_json", + serialization_alias="metadata", + ) diff --git a/recap/schemas/process.py b/recap/schemas/process.py index bc46ebd..fabc5c9 100644 --- a/recap/schemas/process.py +++ b/recap/schemas/process.py @@ -1,10 +1,7 @@ -"""Pydantic schemas for campaigns, process templates, and process runs. +"""Pydantic schemas for process templates and process runs. This module defines the top-level provenance objects: -* :class:`CampaignSchema` — the root grouping for a set of related - :class:`ProcessRunSchema` instances (corresponds to a beamtime, project, - or experimental campaign). * :class:`ProcessTemplateSchema` — the workflow blueprint that declares ordered steps and resource slots. * :class:`ProcessRunSchema` — a concrete execution of a template, carrying @@ -14,18 +11,23 @@ """ import warnings -from typing import Annotated, Any, Literal +from typing import Annotated, Literal from uuid import UUID -from pydantic import ConfigDict, PrivateAttr, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from recap.exceptions import UnloadedFieldError, UnloadedFieldWarning -from recap.schemas.common import SIMPLE_FIELD, CommonFields +from recap.schemas.common import ( + SIMPLE_FIELD, + LoadAwareMixin, + NamespaceOwnedFields, + NormalizedLabels, +) from recap.schemas.resource import ResourceAssignmentSchema, ResourceSlotSchema from recap.schemas.step import StepSchema, StepTemplateSchema -class ProcessTemplateRef(CommonFields): +class ProcessTemplateRef(NamespaceOwnedFields): """Lightweight reference to a process template. Used inside :class:`ProcessRunRef` and similar contexts where the full @@ -39,9 +41,10 @@ class ProcessTemplateRef(CommonFields): name: Annotated[str, SIMPLE_FIELD] version: Annotated[str, SIMPLE_FIELD] + labels: NormalizedLabels = [] -class ProcessTemplateSchema(CommonFields): +class ProcessTemplateSchema(LoadAwareMixin, NamespaceOwnedFields): """Blueprint for a workflow, defining its ordered steps and resource slots. A :class:`ProcessTemplateSchema` is created once and reused across @@ -67,12 +70,17 @@ class ProcessTemplateSchema(CommonFields): name: Annotated[str, SIMPLE_FIELD] version: Annotated[str, SIMPLE_FIELD] - is_active: Annotated[bool, SIMPLE_FIELD] - step_templates: dict[str, StepTemplateSchema] - resource_slots: list["ResourceSlotSchema"] + labels: NormalizedLabels = [] + step_templates: dict[str, StepTemplateSchema] = {} + resource_slots: list["ResourceSlotSchema"] = [] + _relation_fields = frozenset({"step_templates", "resource_slots"}) + + def set_loaded_relations(self, loaded_relations, *, on_unloaded="warn"): + LoadAwareMixin.set_loaded_relations(self, loaded_relations, on_unloaded=on_unloaded) + return self -class ProcessRunRef(CommonFields): +class ProcessRunRef(NamespaceOwnedFields): """Lightweight reference to a process run. Used in list views or parent-link contexts where the full @@ -82,18 +90,27 @@ class ProcessRunRef(CommonFields): Attributes: name: Display name of the run. description: Free-text description. - campaign_id: UUID of the owning :class:`CampaignSchema`. + namespace_id: UUID of the owning Namespace. template: Lightweight :class:`ProcessTemplateRef` identifying which template was used. """ name: Annotated[str, SIMPLE_FIELD] - description: Annotated[str, SIMPLE_FIELD] - campaign_id: Annotated[UUID, SIMPLE_FIELD] - template: ProcessTemplateRef + description: Annotated[str, SIMPLE_FIELD] = "" + template: ProcessTemplateSchema | None = None + + +class ProcessRunCopyChanges(BaseModel): + description: str | None = None + assignments: dict[str, UUID] | None = None + steps: dict[str, dict[str, dict[str, object]]] | None = None -class ProcessRunSchema(CommonFields): +class ProcessRunCopyOptions(BaseModel): + changes: ProcessRunCopyChanges = Field(default_factory=ProcessRunCopyChanges) + + +class ProcessRunSchema(LoadAwareMixin, NamespaceOwnedFields): """A concrete execution of a :class:`ProcessTemplateSchema`. A :class:`ProcessRunSchema` is the primary provenance record. It links @@ -102,7 +119,7 @@ class ProcessRunSchema(CommonFields): * The workflow that was executed (``template``). * The resources that were used (``assigned_resources``). * The parameter values captured at each step (``steps``). - * The campaign it belongs to (``campaign_id``). + * The namespace it belongs to (``namespace_id``). Chain multiple process runs by using the output resource of one run as the input of the next, creating a queryable provenance graph. @@ -110,7 +127,7 @@ class ProcessRunSchema(CommonFields): Attributes: name: Display name for this run (e.g. ``"Run 001"``). description: Free-text description of what this run represents. - campaign_id: UUID of the owning :class:`CampaignSchema`. + namespace_id: UUID of the owning Namespace. template: The :class:`ProcessTemplateSchema` this run instantiates. steps: Mapping of step name → :class:`~recap.schemas.step.StepSchema` with live parameter values. @@ -123,15 +140,13 @@ class ProcessRunSchema(CommonFields): """ name: Annotated[str, SIMPLE_FIELD] - description: Annotated[str, SIMPLE_FIELD] - campaign_id: Annotated[UUID, SIMPLE_FIELD] - template: ProcessTemplateSchema - steps: dict[str, StepSchema] - assigned_resources: dict[str, ResourceAssignmentSchema] + description: Annotated[str, SIMPLE_FIELD] = "" + copied_from_id: Annotated[UUID | None, SIMPLE_FIELD] = None + template: ProcessTemplateSchema | None = None + steps: dict[str, StepSchema] = {} + assigned_resources: dict[str, ResourceAssignmentSchema] = {} model_config = ConfigDict(arbitrary_types_allowed=True, from_attributes=True) - _loaded_relations: dict[str, bool] = PrivateAttr(default_factory=dict) - _on_unloaded: Literal["silent", "warn", "raise"] = PrivateAttr(default="warn") - _warned_unloaded: set[str] = PrivateAttr(default_factory=set) + _relation_fields = frozenset({"template", "steps", "assigned_resources"}) @field_validator("assigned_resources", mode="before") @classmethod @@ -147,59 +162,40 @@ def set_loaded_relations( *, on_unloaded: Literal["silent", "warn", "raise"] = "warn", ) -> "ProcessRunSchema": - self._loaded_relations = loaded_relations - self._on_unloaded = on_unloaded - self._warned_unloaded = set() + LoadAwareMixin.set_loaded_relations(self, loaded_relations, on_unloaded=on_unloaded) return self + def is_loaded(self, relation: str) -> bool: + private = getattr(self, "__pydantic_private__", None) or {} + return private.get("_loaded_relations", {}).get(relation, False) + + def require_loaded(self, relation: str) -> None: + self._handle_unloaded(relation, f"include('{relation}')") + def _handle_unloaded(self, field_name: str, include_hint: str) -> None: - if self._loaded_relations.get(field_name, True): + private = getattr(self, "__pydantic_private__", None) or {} + if private.get("_loaded_relations", {}).get(field_name, True): return message = ( f"'{field_name}' was not loaded for ProcessRunSchema; " f"use {include_hint} or load='eager'." ) - if self._on_unloaded == "raise": + on_unloaded = private.get("_on_unloaded", "warn") + warned = private.setdefault("_warned_unloaded", set()) + if on_unloaded == "raise": raise UnloadedFieldError(message) - if self._on_unloaded == "warn" and field_name not in self._warned_unloaded: + if on_unloaded == "warn" and field_name not in warned: warnings.warn(message, UnloadedFieldWarning, stacklevel=3) - self._warned_unloaded.add(field_name) + warned.add(field_name) def __getattribute__(self, name: str): - if name == "assigned_resources": - self._handle_unloaded("assigned_resources", "include('resources')") - elif name == "steps": - self._handle_unloaded("steps", "include('steps')") + if name in object.__getattribute__(self, "_relation_fields"): + hint = "resources" if name == "assigned_resources" else name + self._handle_unloaded(name, f"include('{hint}')") return super().__getattribute__(name) -class CampaignSchema(CommonFields): - """Top-level grouping of process runs for a single experimental campaign. - - A campaign corresponds to a discrete period or project of experimental - work — for example, a synchrotron beamtime allocation or a drug-screening - campaign. All :class:`ProcessRunSchema` instances belong to exactly one - campaign. - - Create a campaign via - :meth:`~recap.client.base_client.RecapClient.create_campaign` and activate - an existing one via - :meth:`~recap.client.base_client.RecapClient.set_campaign` before creating - process runs. - - Attributes: - name: Human-readable campaign name. - proposal: Proposal or project identifier (e.g. ``"MX-2026-001"``). - saf: Safety Approval Form or equivalent authorisation reference. - ``None`` when not applicable. - meta_data: Arbitrary JSON-serialisable key/value pairs stored with - the campaign. - process_runs: List of :class:`ProcessRunSchema` instances that belong - to this campaign. - """ - - name: Annotated[str, SIMPLE_FIELD] - proposal: Annotated[str, SIMPLE_FIELD] - saf: Annotated[str | None, SIMPLE_FIELD] - meta_data: Annotated[dict[str, Any] | None, SIMPLE_FIELD] - process_runs: list["ProcessRunSchema"] +ProcessTemplateRef = ProcessTemplateSchema # noqa: F811 +ProcessRunRef = ProcessRunSchema # noqa: F811 +ProcessTemplateSchema.model_rebuild(force=True) +ProcessRunSchema.model_rebuild(force=True) diff --git a/recap/schemas/resource.py b/recap/schemas/resource.py index d6cd374..f0b0a94 100644 --- a/recap/schemas/resource.py +++ b/recap/schemas/resource.py @@ -22,7 +22,7 @@ from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from recap.db.resource import Property from recap.exceptions import UnloadedFieldError, UnloadedFieldWarning @@ -31,7 +31,13 @@ AttributeTemplateValidator, AttributeValueSchema, ) -from recap.schemas.common import SIMPLE_FIELD, CommonFields +from recap.schemas.common import ( + SIMPLE_FIELD, + CommonFields, + LoadAwareMixin, + NamespaceOwnedFields, + NormalizedLabels, +) from recap.utils.dsl import build_param_values_model, build_property_groups_model from recap.utils.general import Direction @@ -103,7 +109,11 @@ def coerce_from_orm_or_dict(cls, data): ) values_model = build_param_values_model(tmpl.slug or tmpl.name, tmpl_key) raw_values = { - av.template.name: {"value": av.value, "unit": av.unit} + av.template.name: { + "value": av.value, + "unit": av.unit, + "metadata_json": av.metadata_json, + } for av in data._values.values() } return { @@ -163,9 +173,11 @@ def validate_and_coerce_values(self) -> "PropertySchema": attr_tmpl = tmpl_by_name[name] if isinstance(raw_value, dict): raw_unit = raw_value.get("unit") + raw_metadata = raw_value.get("metadata_json", {}) raw_value = raw_value.get("value") else: raw_unit = None + raw_metadata = {} validator = AttributeTemplateValidator( name=attr_tmpl.name, @@ -175,8 +187,9 @@ def validate_and_coerce_values(self) -> "PropertySchema": default=raw_value, ) coerced[name] = { - "value": validator.default, + "value": raw_value if attr_tmpl.value_type == "array" else validator.default, "unit": attr_tmpl.unit if raw_unit is None else raw_unit, + "metadata_json": raw_metadata, } self.values = self.values.__class__.model_validate(coerced) @@ -285,7 +298,21 @@ class ResourceTypeSchema(CommonFields): name: Annotated[str, SIMPLE_FIELD] -class ResourceTemplateRef(CommonFields): +class ResourceCopyChanges(BaseModel): + """Property changes applied while copying a resource.""" + + properties: dict[str, dict[str, Any]] = Field(default_factory=dict) + + +class ResourceCopyOptions(BaseModel): + """Name, parent, and property overrides for a resource copy operation.""" + + name: str | None = None + parent_id: UUID | None = None + changes: ResourceCopyChanges = Field(default_factory=ResourceCopyChanges) + + +class ResourceTemplateRef(NamespaceOwnedFields): """Lightweight reference to a resource template, without child or property detail. Used when a full :class:`ResourceTemplateSchema` is not required, e.g. @@ -304,11 +331,12 @@ class ResourceTemplateRef(CommonFields): name: Annotated[str, SIMPLE_FIELD] slug: Annotated[str | None, SIMPLE_FIELD] version: Annotated[str, SIMPLE_FIELD] + labels: NormalizedLabels parent: Self | None = Field(default=None, exclude=True) types: list[ResourceTypeSchema] = Field(default_factory=list) -class ResourceTemplateSchema(CommonFields): +class ResourceTemplateSchema(LoadAwareMixin, NamespaceOwnedFields): """Full blueprint for a category of resources. Defines the complete structure of a resource: its type tags, optional @@ -329,12 +357,37 @@ class ResourceTemplateSchema(CommonFields): """ name: Annotated[str, SIMPLE_FIELD] - slug: Annotated[str | None, SIMPLE_FIELD] + slug: Annotated[str | None, SIMPLE_FIELD] = None version: Annotated[str, SIMPLE_FIELD] + labels: NormalizedLabels = Field(default_factory=list) types: list[ResourceTypeSchema] = Field(default_factory=list) parent: ResourceTemplateRef | None = Field(default=None, exclude=True) children: dict[str, Self] = Field(default_factory=dict) - attribute_group_templates: list[AttributeGroupTemplateSchema] + attribute_group_templates: list[AttributeGroupTemplateSchema] = Field(default_factory=list) + _relation_fields = frozenset({"parent", "children", "types", "attribute_group_templates"}) + + def set_loaded_relations(self, loaded_relations, *, on_unloaded="warn"): + LoadAwareMixin.set_loaded_relations(self, loaded_relations, on_unloaded=on_unloaded) + return self + + @field_validator("parent", mode="before") + @classmethod + def _coerce_parent_reference(cls, value): + if value is None or isinstance(value, dict): + return value + return { + "id": value.id, + "create_date": value.create_date, + "modified_date": value.modified_date, + "namespace_id": value.namespace_id, + "status": value.status, + "revision": value.revision, + "name": value.name, + "slug": value.slug, + "version": value.version, + "labels": value.labels, + "types": list(value.types), + } ResourceTypeSchema.model_rebuild() @@ -361,7 +414,7 @@ class ResourceSlotSchema(CommonFields): required: Annotated[bool, SIMPLE_FIELD] = True -class ResourceSchema(CommonFields): +class ResourceSchema(LoadAwareMixin, NamespaceOwnedFields): """A concrete resource instance created from a :class:`ResourceTemplateSchema`. Resources are the primary trackable entities in RECAP. They can represent @@ -394,14 +447,49 @@ class ResourceSchema(CommonFields): """ name: Annotated[str, SIMPLE_FIELD] - template: ResourceTemplateSchema - parent: "ResourceRef | None" = Field(default=None, exclude=True) - children: dict[str, Self] - properties: BaseModel | dict[str, PropertySchema] + copied_from_id: Annotated[UUID | None, SIMPLE_FIELD] = None + template: ResourceTemplateSchema | None = None + parent: "ResourceSchema | None" = Field(default=None, exclude=True) + children: dict[str, Self] = Field(default_factory=dict) + properties: BaseModel | dict[str, PropertySchema] = Field(default_factory=dict) model_config = ConfigDict(arbitrary_types_allowed=True, from_attributes=True) - _loaded_relations: dict[str, bool] = PrivateAttr(default_factory=dict) - _on_unloaded: Literal["silent", "warn", "raise"] = PrivateAttr(default="warn") - _warned_unloaded: set[str] = PrivateAttr(default_factory=set) + _relation_fields = frozenset({"template", "parent", "children", "properties"}) + + @field_validator("parent", mode="before") + @classmethod + def _coerce_parent_reference(cls, value): + if value is None or isinstance(value, (dict, ResourceSchema)): + return value + return { + "id": value.id, + "create_date": value.create_date, + "modified_date": value.modified_date, + "namespace_id": value.namespace_id, + "status": value.status, + "revision": value.revision, + "name": value.name, + "copied_from_id": value.copied_from_id, + "template": value.template, + } + + @field_validator("template", mode="before") + @classmethod + def _coerce_template_reference(cls, value): + if value is None or isinstance(value, (dict, ResourceTemplateSchema)): + return value + return { + "id": value.id, + "create_date": value.create_date, + "modified_date": value.modified_date, + "namespace_id": value.namespace_id, + "status": value.status, + "revision": value.revision, + "name": value.name, + "slug": value.slug, + "version": value.version, + "labels": value.labels, + "types": list(value.types), + } def set_loaded_relations( self, @@ -409,29 +497,36 @@ def set_loaded_relations( *, on_unloaded: Literal["silent", "warn", "raise"] = "warn", ) -> "ResourceSchema": - self._loaded_relations = loaded_relations - self._on_unloaded = on_unloaded - self._warned_unloaded = set() + LoadAwareMixin.set_loaded_relations(self, loaded_relations, on_unloaded=on_unloaded) return self + def is_loaded(self, relation: str) -> bool: + private = getattr(self, "__pydantic_private__", None) or {} + return private.get("_loaded_relations", {}).get(relation, False) + + def require_loaded(self, relation: str) -> None: + self._handle_unloaded(relation, f"include('{relation}')") + def _handle_unloaded(self, field_name: str, include_hint: str) -> None: - if self._loaded_relations.get(field_name, True): + private = getattr(self, "__pydantic_private__", None) or {} + loaded_relations = private.get("_loaded_relations", {}) + if loaded_relations.get(field_name, True): return message = ( f"'{field_name}' was not loaded for ResourceSchema; " f"use {include_hint} or load='eager'." ) - if self._on_unloaded == "raise": + on_unloaded = private.get("_on_unloaded", "warn") + warned = private.setdefault("_warned_unloaded", set()) + if on_unloaded == "raise": raise UnloadedFieldError(message) - if self._on_unloaded == "warn" and field_name not in self._warned_unloaded: + if on_unloaded == "warn" and field_name not in warned: warnings.warn(message, UnloadedFieldWarning, stacklevel=3) - self._warned_unloaded.add(field_name) + warned.add(field_name) def __getattribute__(self, name: str): - if name == "properties": - self._handle_unloaded("properties", "include('properties')") - elif name == "children": - self._handle_unloaded("children", "include('children')") + if name in object.__getattribute__(self, "_relation_fields"): + self._handle_unloaded(name, f"include('{name}')") return super().__getattribute__(name) @model_validator(mode="after") @@ -449,6 +544,7 @@ def build_property_model(self) -> "ResourceSchema": if isinstance(self.properties, BaseModel): return self + prop_fields: list[tuple[str, str]] = [] prop_values: dict[str, PropertySchema] = {} for prop in self.properties.values(): @@ -462,12 +558,16 @@ def build_property_model(self) -> "ResourceSchema": self.template.slug or self.template.name, tuple(prop_fields), ) - self.properties = model.model_validate(prop_values) + # PropertySchema instances were already validated while being + # hydrated. Revalidating them here can coerce mutable array values + # repeatedly instead of preserving them. + self.properties = model.model_construct(**prop_values) + return self -class ResourceRef(CommonFields): +class ResourceRef(NamespaceOwnedFields): """Lightweight reference to a resource, containing only identity and template info. Used to represent parent/ancestor relationships without embedding the full @@ -480,6 +580,7 @@ class ResourceRef(CommonFields): """ name: Annotated[str, SIMPLE_FIELD] + copied_from_id: Annotated[UUID | None, SIMPLE_FIELD] = None template: ResourceTemplateRef @@ -501,3 +602,11 @@ class ResourceAssignmentSchema(BaseModel): slot: ResourceSlotSchema resource: ResourceSchema step_id: Annotated[UUID | None, SIMPLE_FIELD] = None + + +# References remain import-compatible names, but no second persisted model is +# created. Partial payloads use canonical classes with unloaded relations. +ResourceTemplateRef = ResourceTemplateSchema +ResourceRef = ResourceSchema # noqa: F811 +ResourceTemplateSchema.model_rebuild(force=True) +ResourceSchema.model_rebuild(force=True) diff --git a/recap/schemas/step.py b/recap/schemas/step.py index df298d0..778e352 100644 --- a/recap/schemas/step.py +++ b/recap/schemas/step.py @@ -25,7 +25,7 @@ AttributeTemplateValidator, AttributeValueSchema, ) -from recap.schemas.common import SIMPLE_FIELD, CommonFields, StepStatus +from recap.schemas.common import SIMPLE_FIELD, CommonFields, LoadAwareMixin, StepStatus from recap.schemas.resource import ResourceSchema, ResourceSlotSchema from recap.utils.dsl import ( build_param_values_model, @@ -50,21 +50,7 @@ def _attr_metadata(vt: Any) -> dict | None: return {} -class StepTemplateRef(CommonFields): - """Lightweight reference to a step template, containing only identity fields. - - Used in serialisation contexts where the full - :class:`StepTemplateSchema` is not needed to avoid embedding repeated - attribute group data. - - Attributes: - name: Human-readable step template name. - """ - - name: Annotated[str, SIMPLE_FIELD] - - -class StepTemplateSchema(CommonFields): +class StepTemplateSchema(LoadAwareMixin, CommonFields): """Blueprint for a single workflow step within a process template. Declares the parameter groups (attribute group templates) that capture @@ -83,8 +69,13 @@ class StepTemplateSchema(CommonFields): """ name: Annotated[str, SIMPLE_FIELD] - attribute_group_templates: list[AttributeGroupTemplateSchema] - resource_slots: dict[str, ResourceSlotSchema] + attribute_group_templates: list[AttributeGroupTemplateSchema] = [] + resource_slots: dict[str, ResourceSlotSchema] = {} + _relation_fields = frozenset({"attribute_group_templates", "resource_slots"}) + + def set_loaded_relations(self, loaded_relations, *, on_unloaded="warn"): + LoadAwareMixin.set_loaded_relations(self, loaded_relations, on_unloaded=on_unloaded) + return self class ParameterSchema(CommonFields): @@ -133,7 +124,11 @@ def coerce_from_orm(cls, data): ) values_model = build_param_values_model(tmpl.slug or tmpl.name, tmpl_key) raw_values = { - av.template.name: {"value": av.value, "unit": av.unit} + av.template.name: { + "value": av.value, + "unit": av.unit, + "metadata_json": av.metadata_json, + } for av in data._values.values() } return { @@ -232,9 +227,11 @@ def validate_and_coerce_values(self) -> "ParameterSchema": attr_tmpl = tmpl_by_name[name] if isinstance(raw_value, dict): raw_unit = raw_value.get("unit") + raw_metadata = raw_value.get("metadata_json", {}) raw_value = raw_value.get("value") else: raw_unit = None + raw_metadata = {} # Reuse your validator to perform type coercion & checks # Note: we shove `raw_value` into 'default' to leverage coerce_default() @@ -248,6 +245,7 @@ def validate_and_coerce_values(self) -> "ParameterSchema": coerced[name] = { "value": validator.default, # already converted by coerce_default "unit": attr_tmpl.unit if raw_unit is None else raw_unit, + "metadata_json": raw_metadata, } self.values = self.values.__class__.model_validate(coerced) @@ -338,7 +336,7 @@ def __repr__(self) -> str: return f"ParameterSchema(template={self.template.name!r}, {attrs})" -class StepSchema(CommonFields): +class StepSchema(LoadAwareMixin, CommonFields): """A concrete step instance within a :class:`~recap.schemas.process.ProcessRunSchema`. Each step corresponds to one :class:`StepTemplateSchema` from the parent @@ -360,13 +358,18 @@ class StepSchema(CommonFields): """ name: Annotated[str, SIMPLE_FIELD] - template: StepTemplateSchema - parameters: BaseModel | dict[str, ParameterSchema] - state: Annotated[StepStatus, SIMPLE_FIELD] - process_run_id: Annotated[UUID, SIMPLE_FIELD] + template: StepTemplateSchema | None = None + parameters: BaseModel | dict[str, ParameterSchema] = {} + state: Annotated[StepStatus, SIMPLE_FIELD] = StepStatus.PENDING + process_run_id: Annotated[UUID | None, SIMPLE_FIELD] = None parent_id: Annotated[UUID | None, SIMPLE_FIELD] = None children: list["StepSchema"] = Field(default_factory=list) resources: dict[str, "ResourceSchema"] = Field(default_factory=dict) + _relation_fields = frozenset({"template", "parameters", "children", "resources"}) + + def set_loaded_relations(self, loaded_relations, *, on_unloaded="warn"): + LoadAwareMixin.set_loaded_relations(self, loaded_relations, on_unloaded=on_unloaded) + return self def generate_child(self): return self.model_copy(deep=True, update={"id": None, "parent_id": self.id}) @@ -385,8 +388,11 @@ def build_parameter_model(self) -> "StepSchema": param_values[field_name] = param if param_fields: + template = self.__dict__.get("template") + if template is None: + return self model = build_step_parameters_model( - self.template.name, + template.name, tuple(param_fields), ) self.parameters = model.model_validate(param_values) @@ -394,4 +400,7 @@ def build_parameter_model(self) -> "StepSchema": return self -StepSchema.model_rebuild() +StepTemplateRef = StepTemplateSchema +StepTemplate = StepTemplateSchema +StepTemplateSchema.model_rebuild(force=True) +StepSchema.model_rebuild(force=True) diff --git a/recap/server/__init__.py b/recap/server/__init__.py index ff5b511..66a02a6 100644 --- a/recap/server/__init__.py +++ b/recap/server/__init__.py @@ -1 +1 @@ -"""recap GraphQL server package.""" +"""recap REST server package.""" diff --git a/recap/server/__main__.py b/recap/server/__main__.py index f82f18f..44032cc 100644 --- a/recap/server/__main__.py +++ b/recap/server/__main__.py @@ -1,4 +1,4 @@ -"""CLI entrypoint for the recap GraphQL server. +"""CLI entrypoint for the recap REST server. Usage: python -m recap.server --db /path/to/recap.db @@ -15,7 +15,7 @@ def main() -> None: parser = argparse.ArgumentParser( prog="recap-server", - description="recap GraphQL read API server", + description="recap REST query and command API server", ) parser.add_argument("--db", metavar="PATH", help="Path to SQLite database file") parser.add_argument("--config", metavar="PATH", help="Path to YAML config file") @@ -56,7 +56,11 @@ def main() -> None: from recap.server.app import create_app - app = create_app(cfg.db_path) + app = create_app( + db_path=cfg.db_path, + database_uri=cfg.database_uri, + api_key=cfg.api_key, + ) uvicorn.run(app, host=cfg.host, port=cfg.port, log_level=cfg.log_level) diff --git a/recap/server/app.py b/recap/server/app.py index 75af51a..907fa99 100644 --- a/recap/server/app.py +++ b/recap/server/app.py @@ -1,44 +1,132 @@ -"""FastAPI application factory for the recap GraphQL server.""" +"""FastAPI application factory for the recap REST server.""" from __future__ import annotations +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass from pathlib import Path +from uuid import uuid4 -from fastapi import FastAPI +from fastapi import Depends, FastAPI, Request +from fastapi.exceptions import RequestValidationError +from pydantic import SecretStr +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.responses import Response -from recap.client import RecapClient -from recap.server.strawberry_schema import build_router +from recap.authentication.api_key import ApiKeyRequestAuthenticator +from recap.db.engine import create_engine_and_session_factory +from recap.server.error_handlers import register_command_error_handlers +from recap.server.errors import ( + ErrorCode, + request_id_from, + safe_error_response, + safe_http_error, +) +from recap.server.rest import router as rest_router +from recap.server.security import authenticate_request +from recap.utils.migrations import apply_migrations -def create_app(db_path: str | Path) -> FastAPI: +@dataclass(frozen=True, slots=True) +class _DatabaseConfiguration: + database_url: str + + +def _select_database( + db_path: str | Path | None, database_uri: str | SecretStr | None +) -> tuple[Path | None, str]: + if (db_path is None) == (database_uri is None): + raise ValueError("exactly one of db_path or database_uri is required") + if db_path is not None: + path = Path(db_path) + path.parent.mkdir(parents=True, exist_ok=True) + return path, f"sqlite:///{path}" + if isinstance(database_uri, SecretStr): + return None, database_uri.get_secret_value() + assert database_uri is not None + return None, database_uri + + +def create_app( + db_path: str | Path | None = None, + *, + database_uri: str | SecretStr | None = None, + api_key: str | SecretStr | None = None, +) -> FastAPI: """Create the recap FastAPI application. Args: db_path: Path to the SQLite database file. Created if it doesn't exist. + database_uri: PostgreSQL SQLAlchemy URL. Mutually exclusive with db_path. + api_key: Optional key that enables authentication on every route. Returns: - Configured FastAPI application with /graphql and /db_path endpoints. + Configured FastAPI application with REST command endpoints. """ - db_path = Path(db_path) - client = RecapClient.from_sqlite(db_path) - backend = client.backend - graphql_router = build_router(backend) + _, database_url = _select_database(db_path, database_uri) + database_config = _DatabaseConfiguration(database_url) + apply_migrations(database_url) + engine, session_factory = create_engine_and_session_factory(database_config) + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + try: + yield + finally: + engine.dispose() app = FastAPI( - title="recap GraphQL server", - description="Read-only GraphQL API for recap experiment provenance data.", + title="recap REST server", + description="REST API for recap experiment provenance data.", version="1.0.0", + dependencies=[Depends(authenticate_request)] if api_key is not None else None, + lifespan=lifespan, ) + app.state.session_factory = session_factory + register_command_error_handlers(app) + + if api_key is not None: + app.state.request_authenticator = ApiKeyRequestAuthenticator(api_key) + + @app.middleware("http") + async def secure_request_context(request: Request, call_next) -> Response: + request.state.request_id = str(uuid4()) + try: + response = await call_next(request) + except Exception: + response = safe_error_response( + status_code=500, + code=ErrorCode.INTERNAL_ERROR, + message="Internal server error", + request_id=request_id_from(request), + ) + response.headers["X-Request-ID"] = request_id_from(request) + return response - app.include_router(graphql_router, prefix="/graphql") + @app.exception_handler(StarletteHTTPException) + async def handle_http_error( + request: Request, error: StarletteHTTPException + ) -> Response: + code, message = safe_http_error(error.status_code) + return safe_error_response( + status_code=error.status_code, + code=code, + message=message, + request_id=request_id_from(request), + headers=error.headers, + ) - @app.get("/db_path", summary="Get database path") - def get_db_path() -> dict[str, str]: - """Return the path to the SQLite database file used by this server. + @app.exception_handler(RequestValidationError) + async def handle_validation_error( + request: Request, error: RequestValidationError + ) -> Response: + return safe_error_response( + status_code=422, + code=ErrorCode.VALIDATION_ERROR, + message="Request validation failed", + request_id=request_id_from(request), + ) - Used by RecapClient.from_url() to wire direct SQLite writes in Phase 1. - Requires shared filesystem between client and server. - """ - return {"db_path": str(db_path.resolve())} + app.include_router(rest_router) return app diff --git a/recap/server/audit.py b/recap/server/audit.py new file mode 100644 index 0000000..235fcf7 --- /dev/null +++ b/recap/server/audit.py @@ -0,0 +1,57 @@ +"""Sanitized mutation audit contract.""" + +from __future__ import annotations + +from enum import Enum +from typing import Protocol, runtime_checkable +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, model_validator + +from recap.client.permissions import DenialCode +from recap.server.errors import ErrorCode + + +class AuditOutcome(str, Enum): + SUCCESS = "success" + DENIED = "denied" + ERROR = "error" + + +class AuditRecord(BaseModel): + """Mutation metadata safe for audit storage. + + Payload values, request headers, credentials, and authorization grant details + have no representation in this closed model. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + request_id: UUID + actor_id: str | None + mutation: str + resource_type: str + resource_id: str | None + outcome: AuditOutcome + reason_code: DenialCode | ErrorCode | None = None + + @model_validator(mode="after") + def validate_reason_code(self) -> AuditRecord: + if self.outcome is AuditOutcome.SUCCESS and self.reason_code is not None: + raise ValueError("Successful audit records cannot have a reason code") + if self.outcome is AuditOutcome.DENIED and not isinstance( + self.reason_code, DenialCode + ): + raise ValueError("Denied audit records require a denial code") + if self.outcome is AuditOutcome.ERROR and not isinstance( + self.reason_code, ErrorCode + ): + raise ValueError("Error audit records require an error code") + return self + + +@runtime_checkable +class AuditSink(Protocol): + """Destination for sanitized mutation audit records.""" + + def emit(self, record: AuditRecord) -> None: ... diff --git a/recap/server/config.py b/recap/server/config.py index f302feb..28dba45 100644 --- a/recap/server/config.py +++ b/recap/server/config.py @@ -1,39 +1,74 @@ from __future__ import annotations from pathlib import Path +from typing import Literal import yaml -from pydantic import field_validator +from pydantic import Field, SecretStr, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class ServerConfig(BaseSettings): - """Configuration for the recap GraphQL server. + """Configuration for the recap REST server. - Values can be set via: - 1. Environment variables prefixed with RECAP_ (e.g. RECAP_PORT=9000) - 2. YAML config file via ServerConfig.from_yaml() - 3. Direct instantiation kwargs + Configuration precedence is direct kwargs > environment variables > YAML + values. Environment variables use the ``RECAP_`` prefix. """ model_config = SettingsConfigDict(env_prefix="RECAP_") - db_path: Path + db_path: Path | None = Field( + default=None, + description="SQLite database path; mutually exclusive with database_uri.", + ) + database_uri: SecretStr | None = None host: str = "127.0.0.1" port: int = 8000 log_level: str = "info" + authentication_mode: Literal["single-user", "multi-user"] = "single-user" + api_key: SecretStr | None = None + entitlement_snapshot_path: Path | None = None + entitlement_snapshot_max_age_seconds: int = 300 + audit_log_path: Path = Path("recap-audit.jsonl") - @field_validator("db_path", mode="before") + @field_validator( + "db_path", "entitlement_snapshot_path", "audit_log_path", mode="before" + ) @classmethod - def coerce_db_path(cls, v: object) -> Path: + def coerce_path(cls, v: object) -> Path | None: + if v is None: + return None return Path(str(v)) + @model_validator(mode="after") + def validate_configuration(self) -> ServerConfig: + if (self.db_path is None) == (self.database_uri is None): + raise ValueError("exactly one of db_path or database_uri is required") + if self.authentication_mode == "single-user": + if self.api_key is None or not self.api_key.get_secret_value(): + raise ValueError("API key is required for single-user authentication") + elif self.entitlement_snapshot_path is None: + raise ValueError( + "Entitlement snapshot path is required for multi-user authentication" + ) + if self.entitlement_snapshot_max_age_seconds <= 0: + raise ValueError("Entitlement snapshot maximum age must be positive") + return self + + @property + def database_url(self) -> str: + """Return SQLAlchemy URL derived from the configured database source.""" + if self.db_path is not None: + return f"sqlite:///{self.db_path}" + assert self.database_uri is not None + return self.database_uri.get_secret_value() + @classmethod def from_yaml(cls, path: str | Path) -> ServerConfig: """Load ServerConfig from a YAML file. - YAML must have a top-level 'server:' key. db_path is required. - CLI/env vars still override YAML values when set. + YAML must have a top-level ``server:`` key. Exactly one database source + is required; environment variables and direct kwargs override YAML. Raises FileNotFoundError if the config file does not exist. """ with open(path) as f: diff --git a/recap/server/dependencies.py b/recap/server/dependencies.py new file mode 100644 index 0000000..e723212 --- /dev/null +++ b/recap/server/dependencies.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from collections.abc import Iterator + +from fastapi import Request + +from recap.adapter.local import LocalBackend + + +def get_local_backend(request: Request) -> Iterator[LocalBackend]: + """Provide backend whose reads use short-lived sessions.""" + + backend = LocalBackend(request.app.state.session_factory) + yield backend diff --git a/recap/server/error_handlers.py b/recap/server/error_handlers.py new file mode 100644 index 0000000..3118fee --- /dev/null +++ b/recap/server/error_handlers.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from fastapi import FastAPI, Request + +from recap.commands.errors import ( + CommandConflictError, + CommandError, + CommandNotFoundError, + CommandValidationError, +) +from recap.exceptions import RecapRequestError +from recap.server.errors import ( + AuthorizationDenied, + ErrorCode, + request_id_from, + safe_error_response, + safe_http_error, +) + + +def register_command_error_handlers(app: FastAPI) -> None: + @app.exception_handler(CommandError) + async def handle_command_error(request: Request, error: CommandError): + if isinstance(error, CommandNotFoundError): + status_code, code, message = 404, ErrorCode.NOT_FOUND, "Not found" + elif isinstance(error, CommandConflictError): + status_code, code, message = 409, ErrorCode.CONFLICT, "Conflict" + elif isinstance(error, CommandValidationError): + status_code, code, message = ( + 422, + ErrorCode.VALIDATION_ERROR, + error.public_message or "Request validation failed", + ) + else: + status_code, code, message = 400, ErrorCode.REQUEST_ERROR, "Request failed" + return safe_error_response( + status_code=status_code, + code=code, + message=message, + request_id=request_id_from(request), + ) + + @app.exception_handler(RecapRequestError) + async def handle_public_error(request: Request, error: RecapRequestError): + public_errors = { + ErrorCode.VALIDATION_ERROR: ( + 422, + ErrorCode.VALIDATION_ERROR, + "Request validation failed", + ), + ErrorCode.CONFLICT: (409, ErrorCode.CONFLICT, "Conflict"), + ErrorCode.INTERNAL_ERROR: ( + 500, + ErrorCode.INTERNAL_ERROR, + "Internal server error", + ), + } + status_code = { + ErrorCode.AUTHENTICATION_REQUIRED: 401, + ErrorCode.PERMISSION_DENIED: 403, + ErrorCode.NOT_FOUND: 404, + ErrorCode.SERVICE_UNAVAILABLE: 503, + ErrorCode.VALIDATION_ERROR: 422, + ErrorCode.CONFLICT: 409, + ErrorCode.INTERNAL_ERROR: 500, + }.get(error.code, 400) + concealed = isinstance(error, AuthorizationDenied) and error.conceal + if concealed: + status_code = 404 + if concealed: + code, message = safe_http_error(status_code) + elif error.code in public_errors: + _, code, message = public_errors[error.code] + elif status_code == 503: + code, message = ErrorCode.SERVICE_UNAVAILABLE, "Service unavailable" + else: + code, message = safe_http_error(status_code) + return safe_error_response( + status_code=status_code, + code=code, + message=message, + request_id=request_id_from(request), + ) diff --git a/recap/server/errors.py b/recap/server/errors.py new file mode 100644 index 0000000..6ed602a --- /dev/null +++ b/recap/server/errors.py @@ -0,0 +1,75 @@ +"""Safe HTTP error contracts for the REST API.""" + +from __future__ import annotations + +from collections.abc import Mapping +from enum import Enum +from http import HTTPStatus +from typing import Any + +from pydantic import BaseModel, ConfigDict +from starlette.responses import JSONResponse + +from recap.exceptions import AuthorizationDenied # noqa: F401 + + +class ErrorCode(str, Enum): + AUTHENTICATION_REQUIRED = "authentication_required" + PERMISSION_DENIED = "permission_denied" + NOT_FOUND = "not_found" + SERVICE_UNAVAILABLE = "service_unavailable" + VALIDATION_ERROR = "validation_error" + CONFLICT = "conflict" + INTERNAL_ERROR = "internal_error" + REQUEST_ERROR = "request_error" + + +class ErrorDetail(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + code: ErrorCode + message: str + request_id: str + + +class ErrorEnvelope(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + error: ErrorDetail + + +def safe_error_response( + *, + status_code: int, + code: ErrorCode, + message: str, + request_id: str, + headers: Mapping[str, str] | None = None, +) -> JSONResponse: + envelope = ErrorEnvelope( + error=ErrorDetail(code=code, message=message, request_id=request_id) + ) + return JSONResponse( + status_code=status_code, + content=envelope.model_dump(mode="json"), + headers=dict(headers or {}), + ) + + +def safe_http_error(status_code: int) -> tuple[ErrorCode, str]: + errors: dict[int, tuple[ErrorCode, str]] = { + 401: (ErrorCode.AUTHENTICATION_REQUIRED, "Authentication required"), + 403: (ErrorCode.PERMISSION_DENIED, "Permission denied"), + 404: (ErrorCode.NOT_FOUND, "Not found"), + } + if status_code in errors: + return errors[status_code] + try: + message = HTTPStatus(status_code).phrase + except ValueError: + message = "Request failed" + return ErrorCode.REQUEST_ERROR, message + + +def request_id_from(scope: Any) -> str: + return str(scope.state.request_id) diff --git a/recap/server/query_models.py b/recap/server/query_models.py new file mode 100644 index 0000000..11adea4 --- /dev/null +++ b/recap/server/query_models.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + + +class QueryRPCRequest(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + entity: str + projection: Literal["full", "ref"] + namespace_path: str + spec: dict[str, Any] diff --git a/recap/server/query_service.py b/recap/server/query_service.py new file mode 100644 index 0000000..933c96c --- /dev/null +++ b/recap/server/query_service.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from types import UnionType +from typing import Any, Union, get_args, get_origin +from uuid import UUID + +from pydantic import BaseModel, TypeAdapter, ValidationError + +from recap.adapter import AuthorizedReadBackend +from recap.adapter.schema_registry import ( + SCHEMA_ENTITY_KEYS, + SCHEMA_PROJECTIONS, + SCHEMA_REGISTRY, + SCHEMAS_BY_NAME, +) +from recap.adapter.transport import QueryRequest, QueryResult, serialize_model +from recap.authorization.query import AuthorizedQuery +from recap.client.permissions import ActorPermissions +from recap.dsl.query import QuerySpec +from recap.exceptions import RecapProtocolError, RecapValidationError + + +class QueryService: + def __init__(self, backend: AuthorizedReadBackend) -> None: + self.backend = backend + + @classmethod + def legacy_request(cls, schema_name: str, namespace_path: str, spec: Any) -> QueryRequest: + try: + schema = SCHEMAS_BY_NAME[schema_name] + except KeyError as exc: + raise RecapValidationError("Unknown query schema") from exc + return QueryRequest( + entity=SCHEMA_ENTITY_KEYS[schema], + projection=SCHEMA_PROJECTIONS[schema], + namespace_path=namespace_path, + spec=spec, + schema_name=schema_name, + ) + + def query(self, request: QueryRequest, *, actor, policy) -> QueryResult: + self._schema(request) + items = self.query_models(request, actor=actor, policy=policy) + return QueryResult( + entity=request.entity, + projection=request.projection, + items=[self._serialize_item(item, request.entity, request.projection) for item in items], + ) + + @staticmethod + def _serialize_item(item, entity, projection): + payload = serialize_model(item) + if projection != "ref": + return payload + fields = { + "namespace": {"id", "path"}, + "resource_template": {"id", "name", "slug", "version", "labels", "types", "namespace_id", "status", "revision", "create_date", "modified_date"}, + "resource": {"id", "name", "copied_from_id", "template", "namespace_id", "status", "revision", "create_date", "modified_date"}, + "process_template": {"id", "name", "version", "labels", "namespace_id", "status", "revision", "create_date", "modified_date"}, + "process_run": {"id", "name", "description", "template", "namespace_id", "status", "revision", "create_date", "modified_date"}, + }[entity] + return {key: value for key, value in payload.items() if key in fields} + + def query_models(self, request: QueryRequest, *, actor, policy): + schema = self._schema(request) + spec = self._spec(request, schema) + if policy is None: + items = self.backend.query(schema, spec, namespace_path=request.namespace_path) + else: + authorization = AuthorizedQuery.from_policy( + policy, actor, namespace_path=request.namespace_path + ) + if not spec.include_mutable: + authorization = authorization.for_read() + items = self.backend.query_authorized(schema, spec, authorization=authorization) + return items + + def legacy_query(self, schema_name: str, namespace_path: str, spec: Any, *, backend=None) -> QueryResult: + request = self.legacy_request(schema_name, namespace_path, spec) + service = self if backend is None else QueryService(backend) + return service.query(request, actor=None, policy=None).model_copy( + update={"schema_name": schema_name} + ) + + def count(self, request: QueryRequest, *, actor, policy) -> int: + schema = self._schema(request) + spec = self._spec(request, schema) + if policy is None: + value = self.backend.count(schema, spec, namespace_path=request.namespace_path) + else: + authorization = AuthorizedQuery.from_policy( + policy, actor, namespace_path=request.namespace_path + ) + if not spec.include_mutable: + authorization = authorization.for_read() + value = self.backend.count_authorized(schema, spec, authorization=authorization) + return self.parse_count(value) + + def legacy_count(self, schema_name: str, namespace_path: str, spec: Any, *, backend=None) -> int: + request = self.legacy_request(schema_name, namespace_path, spec) + service = self if backend is None else QueryService(backend) + return service.count(request, actor=None, policy=None) + + def permissions(self, namespace_path: str, *, actor, policy) -> ActorPermissions: + permissions = policy.permissions_for(actor, namespace_path) + return ActorPermissions( + identities=permissions.identities, + snapshot_generation=permissions.snapshot_generation, + effective_scopes=permissions.effective_scopes, + matched_namespace_paths=permissions.matched_namespace_paths, + groups=tuple(sorted({grant.group for grant in permissions.grants})), + roles=tuple(sorted({grant.role for grant in permissions.grants})), + ) + + @staticmethod + def parse_result(body: Any) -> QueryResult: + if not body: + raise RecapProtocolError("Empty query response body") + try: + return QueryResult.model_validate(body) + except ValidationError as exc: + raise RecapProtocolError("Malformed query response") from exc + + @staticmethod + def parse_count(value: Any) -> int: + if not isinstance(value, int) or isinstance(value, bool): + raise RecapProtocolError("Malformed query count") + return value + + @staticmethod + def _schema(request: QueryRequest) -> type[BaseModel]: + try: + registration = SCHEMA_REGISTRY.by_key(request.entity) + if request.projection not in registration.projections: + raise ValueError( + f"Unknown query entity/projection: {request.entity}/{request.projection}" + ) + return registration.model + except ValueError as exc: + raise RecapValidationError(str(exc)) from exc + except KeyError as exc: + raise RecapValidationError(str(exc)) from exc + + def _spec(self, request: QueryRequest, schema: type[BaseModel]) -> QuerySpec: + try: + spec = QuerySpec.model_validate(request.spec) + filters = dict(spec.filters) + for key, value in filters.items(): + annotation = QueryService._terminal_annotation(schema, key.split("__")) + if value is not None and annotation is not None and QueryService._contains_uuid(annotation): + filters[key] = TypeAdapter(annotation).validate_python(value) + spec = spec.model_copy(update={"filters": filters}) + validate_query = getattr(self.backend, "validate_query", None) + if validate_query is not None: + validate_query(schema, spec) + return spec + except (ValidationError, ValueError, TypeError) as exc: + raise RecapValidationError("Invalid query specification") from exc + + @staticmethod + def _contains_uuid(annotation: Any) -> bool: + return annotation is UUID or any(QueryService._contains_uuid(arg) for arg in get_args(annotation)) + + @staticmethod + def _terminal_annotation(annotation: Any, path: list[str]) -> Any | None: + if not path: + return annotation + origin = get_origin(annotation) + if origin in (list, set, tuple): + args = get_args(annotation) + return QueryService._terminal_annotation(args[0], path) if args else None + if origin in (UnionType, Union): + return next( + (result for arg in get_args(annotation) if arg is not type(None) + for result in [QueryService._terminal_annotation(arg, path)] if result is not None), + None, + ) + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + field = annotation.model_fields.get(path[0]) + return QueryService._terminal_annotation(field.annotation, path[1:]) if field else None + return None diff --git a/recap/server/resolvers.py b/recap/server/resolvers.py deleted file mode 100644 index a5e58b0..0000000 --- a/recap/server/resolvers.py +++ /dev/null @@ -1,254 +0,0 @@ -"""GraphQL resolver functions. Call LocalBackend directly — no RecapClient, no QueryDSL.""" - -from __future__ import annotations - -from collections.abc import Mapping -from types import MappingProxyType -from uuid import UUID - -import strawberry -from pydantic import BaseModel, ValidationError -from strawberry.scalars import JSON - -from recap.adapter.local import LocalBackend -from recap.adapter.transport import QueryResult, serialize_model -from recap.dsl.query import QuerySpec -from recap.schemas.process import ( - CampaignSchema, - ProcessRunRef, - ProcessRunSchema, - ProcessTemplateRef, - ProcessTemplateSchema, -) -from recap.schemas.resource import ( - ResourceRef, - ResourceSchema, - ResourceTemplateRef, - ResourceTemplateSchema, -) -from recap.server.strawberry_types import ( - CampaignType, - ProcessRunType, - ProcessTemplateType, - ResourceTemplateType, - ResourceType, -) - -_DEFAULT_LIMIT = 1000 -_MAX_LIMIT = 10_000 - -_SCHEMA_REGISTRY: Mapping[str, type[BaseModel]] = MappingProxyType( - { - "ResourceSchema": ResourceSchema, - "ResourceRef": ResourceRef, - "ResourceTemplateSchema": ResourceTemplateSchema, - "ResourceTemplateRef": ResourceTemplateRef, - "ProcessRunSchema": ProcessRunSchema, - "ProcessRunRef": ProcessRunRef, - "ProcessTemplateSchema": ProcessTemplateSchema, - "ProcessTemplateRef": ProcessTemplateRef, - "CampaignSchema": CampaignSchema, - } -) - - -def _resolve_schema(schema_name: str) -> type[BaseModel]: - try: - return _SCHEMA_REGISTRY[schema_name] - except KeyError as exc: - raise strawberry.exceptions.StrawberryGraphQLError( - "Unknown query schema" - ) from exc - - -def _validate_query_spec(spec: JSON) -> QuerySpec: - try: - return QuerySpec.model_validate(spec) - except ValidationError as exc: - raise strawberry.exceptions.StrawberryGraphQLError( - "Invalid query specification" - ) from exc - - -def resolve_execute_query( - info: strawberry.types.Info, schema_name: str, spec: JSON -) -> JSON: - backend: LocalBackend = info.context["backend"] - schema = _resolve_schema(schema_name) - query_spec = _validate_query_spec(spec) - items = [serialize_model(item) for item in backend.query(schema, query_spec)] - return QueryResult(schema_name=schema_name, items=items).model_dump(mode="json") - - -def resolve_execute_count( - info: strawberry.types.Info, schema_name: str, spec: JSON -) -> int: - backend: LocalBackend = info.context["backend"] - schema = _resolve_schema(schema_name) - return backend.count(schema, _validate_query_spec(spec)) - - -def _resource_schema_to_type(r: ResourceSchema) -> ResourceType: - return ResourceType( - id=strawberry.ID(str(r.id)), - name=r.name, - create_date=r.create_date, - modified_date=r.modified_date, - ) - - -def _process_run_schema_to_type(pr: ProcessRunSchema) -> ProcessRunType: - return ProcessRunType( - id=strawberry.ID(str(pr.id)), - name=pr.name, - description=getattr(pr, "description", None), - create_date=pr.create_date, - modified_date=pr.modified_date, - ) - - -def _campaign_schema_to_type(c: CampaignSchema) -> CampaignType: - return CampaignType( - id=strawberry.ID(str(c.id)), - name=c.name, - proposal=getattr(c, "proposal", None), - create_date=c.create_date, - modified_date=c.modified_date, - ) - - -def _process_template_schema_to_type(pt: ProcessTemplateSchema) -> ProcessTemplateType: - return ProcessTemplateType( - id=strawberry.ID(str(pt.id)), - name=pt.name, - version=getattr(pt, "version", "1.0"), - create_date=pt.create_date, - modified_date=pt.modified_date, - ) - - -def _resource_template_schema_to_type( - r: ResourceTemplateSchema, -) -> ResourceTemplateType: - return ResourceTemplateType( - id=strawberry.ID(str(r.id)), - name=r.name, - version=getattr(r, "version", "1.0"), - create_date=r.create_date, - modified_date=r.modified_date, - ) - - -def _check_limit(limit: int | None) -> int: - effective = limit if limit is not None else _DEFAULT_LIMIT - if effective > _MAX_LIMIT: - raise strawberry.exceptions.StrawberryGraphQLError( - f"Requested limit {effective} exceeds maximum allowed {_MAX_LIMIT}" - ) - return effective - - -def resolve_resources( - info: strawberry.types.Info, - campaign_id: strawberry.ID | None = None, - limit: int | None = None, - offset: int | None = None, -) -> list[ResourceType]: - backend: LocalBackend = info.context["backend"] - effective_limit = _check_limit(limit) - spec = QuerySpec( - campaign_id=UUID(str(campaign_id)) if campaign_id else None, - limit=effective_limit, - offset=offset, - ) - results = backend.query(ResourceSchema, spec) - return [_resource_schema_to_type(r) for r in results] - - -def resolve_resources_count( - info: strawberry.types.Info, - campaign_id: strawberry.ID | None = None, -) -> int: - backend: LocalBackend = info.context["backend"] - spec = QuerySpec(campaign_id=UUID(str(campaign_id)) if campaign_id else None) - return backend.count(ResourceSchema, spec) - - -def resolve_resource_templates( - info: strawberry.types.Info, - limit: int | None = None, - offset: int | None = None, -) -> list[ResourceTemplateType]: - backend: LocalBackend = info.context["backend"] - effective_limit = _check_limit(limit) - spec = QuerySpec(limit=effective_limit, offset=offset) - results = backend.query(ResourceTemplateSchema, spec) - return [_resource_template_schema_to_type(r) for r in results] - - -def resolve_process_runs( - info: strawberry.types.Info, - campaign_id: strawberry.ID | None = None, - limit: int | None = None, - offset: int | None = None, -) -> list[ProcessRunType]: - backend: LocalBackend = info.context["backend"] - effective_limit = _check_limit(limit) - spec = QuerySpec( - campaign_id=UUID(str(campaign_id)) if campaign_id else None, - limit=effective_limit, - offset=offset, - ) - results = backend.query(ProcessRunSchema, spec) - return [_process_run_schema_to_type(pr) for pr in results] - - -def resolve_process_runs_count( - info: strawberry.types.Info, - campaign_id: strawberry.ID | None = None, -) -> int: - backend: LocalBackend = info.context["backend"] - spec = QuerySpec(campaign_id=UUID(str(campaign_id)) if campaign_id else None) - return backend.count(ProcessRunSchema, spec) - - -def resolve_process_templates( - info: strawberry.types.Info, - limit: int | None = None, - offset: int | None = None, -) -> list[ProcessTemplateType]: - backend: LocalBackend = info.context["backend"] - effective_limit = _check_limit(limit) - spec = QuerySpec(limit=effective_limit, offset=offset) - results = backend.query(ProcessTemplateSchema, spec) - return [_process_template_schema_to_type(pt) for pt in results] - - -def resolve_campaigns( - info: strawberry.types.Info, - limit: int | None = None, - offset: int | None = None, -) -> list[CampaignType]: - backend: LocalBackend = info.context["backend"] - effective_limit = _check_limit(limit) - spec = QuerySpec(limit=effective_limit, offset=offset) - results = backend.query(CampaignSchema, spec) - return [_campaign_schema_to_type(c) for c in results] - - -def resolve_campaigns_count(info: strawberry.types.Info) -> int: - backend: LocalBackend = info.context["backend"] - spec = QuerySpec() - return backend.count(CampaignSchema, spec) - - -def resolve_resource_templates_count(info: strawberry.types.Info) -> int: - backend = info.context["backend"] - spec = QuerySpec() - return backend.count(ResourceTemplateSchema, spec) - - -def resolve_process_templates_count(info: strawberry.types.Info) -> int: - backend = info.context["backend"] - spec = QuerySpec() - return backend.count(ProcessTemplateSchema, spec) diff --git a/recap/server/rest.py b/recap/server/rest.py new file mode 100644 index 0000000..8cb2383 --- /dev/null +++ b/recap/server/rest.py @@ -0,0 +1,498 @@ +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, Request, Response + +from recap.adapter.local import LocalBackend +from recap.adapter.transport import QueryRequest, QueryResult, serialize_model +from recap.authentication.models import RequestActor +from recap.authorization.policy import ( + SnapshotNamespacePolicy, + UnrestrictedNamespacePolicy, +) +from recap.client.permissions import ActorPermissions +from recap.commands.context import DiscardAuditSink +from recap.commands.models import CommandContext +from recap.commands.registry import COMMAND_REGISTRY, CommandRegistration +from recap.commands.service import CommandService +from recap.dsl.drafts import ( + ProcessRunDraft, + ProcessTemplateDraft, + ResourceTemplateDraft, +) +from recap.exceptions import AuthorizationDenied, RecapValidationError +from recap.schemas.namespace import NamespaceContext, NamespaceSchema +from recap.schemas.process import ProcessRunSchema, ProcessTemplateSchema +from recap.schemas.resource import ( + ResourceSchema, + ResourceTemplateSchema, +) +from recap.server.errors import request_id_from +from recap.server.query_models import QueryRPCRequest +from recap.server.query_service import QueryService +from recap.server.rest_models import ( + CopyProcessRunRequest, + CopyResourceRequest, + CreateNamespaceRequest, + CreateResourceRequest, + SetLifecycleStatusRequest, + UpdateNamespaceRequest, + UpdateProcessRunRequest, + UpdateResourceRequest, +) +from recap.server.security import authenticate_request +from recap.utils.namespace import canonicalize_namespace_path + +router = APIRouter(prefix="/api/v1") + + +def command_registration(name: str): + def resolve() -> CommandRegistration: + return COMMAND_REGISTRY.by_name(name) + + return resolve + + +def command_service(request: Request) -> CommandService: + return CommandService(request.app.state.session_factory) + + +def _context(request: Request, actor: RequestActor, idempotency_key: str): + return CommandContext( + actor=actor, + request_id=request_id_from(request), + policy=_policy(request), + audit_sink=DiscardAuditSink(), + authorization_generation=None, + idempotency_key=idempotency_key, + ) + + +def _policy(request: Request): + provider = getattr(request.app.state, "authorization_snapshot_provider", None) + return ( + SnapshotNamespacePolicy(provider.acquire()) + if provider is not None + else getattr( + request.app.state, "namespace_policy", UnrestrictedNamespacePolicy() + ) + ) + + +def _set_etag(response: Response, entity): + response.headers["ETag"] = f'"{entity.revision}"' + return serialize_model(entity) if isinstance(entity, ResourceSchema) else entity + + +def _query_service(request: Request) -> QueryService: + return QueryService(LocalBackend(request.app.state.session_factory)) + + +def _canonical_namespace_path(path: str) -> str: + try: + return canonicalize_namespace_path(path) + except (TypeError, ValueError) as exc: + raise RecapValidationError("Invalid namespace path") from exc + + +def _query_request(body: QueryRPCRequest) -> QueryRequest: + try: + return QueryRequest( + entity=body.entity, + projection=body.projection, + namespace_path=_canonical_namespace_path(body.namespace_path), + spec=body.spec, + ) + except (TypeError, ValueError) as exc: + raise RecapValidationError("Invalid query request") from exc + + +@router.post("/query", response_model=QueryResult) +def query( + body: QueryRPCRequest, + request: Request, + actor: Annotated[RequestActor, Depends(authenticate_request)], +) -> QueryResult: + return _query_service(request).query( + _query_request(body), actor=actor, policy=_policy(request) + ) + + +@router.post("/query/count") +def query_count( + body: QueryRPCRequest, + request: Request, + actor: Annotated[RequestActor, Depends(authenticate_request)], +) -> int: + return _query_service(request).count( + _query_request(body), actor=actor, policy=_policy(request) + ) + + +@router.get("/permissions", response_model=ActorPermissions) +def permissions( + request: Request, + namespace_path: str, + actor: Annotated[RequestActor, Depends(authenticate_request)], +) -> ActorPermissions: + return _query_service(request).permissions( + _canonical_namespace_path(namespace_path), actor=actor, policy=_policy(request) + ) + + +@router.get( + "/namespaces/context/{namespace_path:path}", response_model=NamespaceContext +) +def namespace_context( + namespace_path: str, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], +) -> NamespaceContext: + path = _canonical_namespace_path(namespace_path) + if not _policy(request).can_discover_namespace(actor, path): + raise AuthorizationDenied(conceal=True) + context = _query_service(request).backend.get_namespace_context( + path + ) + response.headers["ETag"] = f'"{context.revision}"' + return context + + +@router.put( + "/namespaces/{namespace_path:path}", + response_model=NamespaceSchema, + status_code=201, +) +def create_namespace( + namespace_path: str, + body: CreateNamespaceRequest, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + registration: Annotated[ + CommandRegistration, Depends(command_registration("create_namespace")) + ], +) -> NamespaceSchema: + command = registration.decode_command( + {"namespace_path": namespace_path}, {}, body + ) + result = CommandService(request.app.state.session_factory).execute( + command, _context(request, actor, idempotency_key) + ) + return _set_etag(response, result) + + +@router.patch("/namespaces/{namespace_id}", response_model=NamespaceSchema) +def update_namespace( + namespace_id: UUID, + body: UpdateNamespaceRequest, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + if_match: Annotated[str, Header(alias="If-Match")], + registration: Annotated[ + CommandRegistration, Depends(command_registration("update_namespace")) + ], +) -> NamespaceSchema: + command = registration.decode_command( + {"namespace_id": namespace_id}, {"If-Match": if_match}, body + ) + result = CommandService(request.app.state.session_factory).execute( + command, _context(request, actor, idempotency_key) + ) + return _set_etag(response, result) + + +@router.get("/namespaces/children", response_model=list[str]) +@router.get("/namespaces/children/{namespace_path:path}", response_model=list[str]) +def list_child_namespaces( + request: Request, + actor: Annotated[RequestActor, Depends(authenticate_request)], + namespace_path: str = "", +) -> list[str]: + path = canonicalize_namespace_path(namespace_path) + provider = getattr(request.app.state, "authorization_snapshot_provider", None) + policy = ( + SnapshotNamespacePolicy(provider.acquire()) + if provider is not None + else getattr( + request.app.state, "namespace_policy", UnrestrictedNamespacePolicy() + ) + ) + backend = LocalBackend(request.app.state.session_factory) + child_paths = backend.list_child_namespace_paths(path) + visible_paths = [ + child_path + for child_path in child_paths + if policy.can_discover_namespace(actor, child_path) + ] + prefix = f"{path}/" if path else "" + return [child_path[len(prefix) :] for child_path in visible_paths] + + +@router.post( + "/process-templates/{namespace_path:path}", + response_model=ProcessTemplateSchema, + status_code=201, +) +def create_process_template( + namespace_path: str, + body: ProcessTemplateDraft, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + registration: Annotated[ + CommandRegistration, Depends(command_registration("create_process_template")) + ], +) -> ProcessTemplateSchema: + command = registration.decode_command( + {"namespace_path": namespace_path}, {}, body + ) + result = CommandService(request.app.state.session_factory).execute( + command, _context(request, actor, idempotency_key) + ) + return _set_etag(response, result) + + +@router.patch("/process-templates/{template_id}", response_model=ProcessTemplateSchema) +def update_process_template( + template_id: UUID, + body: ProcessTemplateDraft, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + if_match: Annotated[str, Header(alias="If-Match")], + registration: Annotated[ + CommandRegistration, Depends(command_registration("update_process_template")) + ], +) -> ProcessTemplateSchema: + command = registration.decode_command( + {"template_id": template_id}, {"If-Match": if_match}, body + ) + result = CommandService(request.app.state.session_factory).execute( + command, _context(request, actor, idempotency_key) + ) + return _set_etag(response, result) + + +@router.post( + "/resource-templates/{namespace_path:path}", + response_model=ResourceTemplateSchema, + status_code=201, +) +def create_resource_template( + namespace_path: str, + body: ResourceTemplateDraft, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + registration: Annotated[ + CommandRegistration, Depends(command_registration("create_resource_template")) + ], +) -> ResourceTemplateSchema: + command = registration.decode_command( + {"namespace_path": namespace_path}, {}, body + ) + result = CommandService(request.app.state.session_factory).execute( + command, _context(request, actor, idempotency_key) + ) + return _set_etag(response, result) + + +@router.patch( + "/resource-templates/{template_id}", response_model=ResourceTemplateSchema +) +def update_resource_template( + template_id: UUID, + body: ResourceTemplateDraft, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + if_match: Annotated[str, Header(alias="If-Match")], + registration: Annotated[ + CommandRegistration, Depends(command_registration("update_resource_template")) + ], +) -> ResourceTemplateSchema: + command = registration.decode_command( + {"template_id": template_id}, {"If-Match": if_match}, body + ) + result = CommandService(request.app.state.session_factory).execute( + command, _context(request, actor, idempotency_key) + ) + return _set_etag(response, result) + + +@router.post( + "/resources/{source_resource_id}/copies", + response_model=None, + status_code=201, +) +def copy_resource( + source_resource_id: UUID, + body: CopyResourceRequest, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + registration: Annotated[ + CommandRegistration, Depends(command_registration("copy_resource")) + ], +) -> ResourceSchema: + command = registration.decode_command( + {"source_resource_id": source_resource_id}, {}, body + ) + result = CommandService(request.app.state.session_factory).execute( + command, + _context(request, actor, idempotency_key), + ) + return _set_etag(response, result) + + +@router.post("/resources/{namespace_path:path}", response_model=None, status_code=201) +def create_resource( + namespace_path: str, + body: CreateResourceRequest, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + registration: Annotated[ + CommandRegistration, Depends(command_registration("create_resource")) + ], +) -> ResourceSchema: + command = registration.decode_command( + {"namespace_path": namespace_path}, {}, body + ) + result = CommandService(request.app.state.session_factory).execute( + command, + _context(request, actor, idempotency_key), + ) + return _set_etag(response, result) + + +@router.patch("/resources/{resource_id}", response_model=None) +def update_resource( + resource_id: UUID, + body: UpdateResourceRequest, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + if_match: Annotated[str, Header(alias="If-Match")], + registration: Annotated[ + CommandRegistration, Depends(command_registration("update_resource")) + ], +) -> ResourceSchema: + command = registration.decode_command( + path_params={"resource_id": resource_id}, + headers={"If-Match": if_match}, + body=body, + ) + result = CommandService(request.app.state.session_factory).execute( + command, + _context(request, actor, idempotency_key), + ) + return _set_etag(response, result) + + +@router.post( + "/process-runs/{namespace_path:path}", response_model=None, status_code=201 +) +def create_process_run( + namespace_path: str, + body: ProcessRunDraft, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + registration: Annotated[ + CommandRegistration, Depends(command_registration("create_process_run")) + ], +) -> ProcessRunSchema: + command = registration.decode_command( + {"namespace_path": namespace_path}, {}, body + ) + result = CommandService(request.app.state.session_factory).execute( + command, _context(request, actor, idempotency_key) + ) + return _set_etag(response, result) + + +@router.post( + "/process-runs/{source_process_run_id}/copies", response_model=None, status_code=201 +) +def copy_process_run( + source_process_run_id: UUID, + body: CopyProcessRunRequest, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + registration: Annotated[ + CommandRegistration, Depends(command_registration("copy_process_run")) + ], +) -> ProcessRunSchema: + command = registration.decode_command( + {"source_process_run_id": source_process_run_id}, {}, body + ) + result = CommandService(request.app.state.session_factory).execute( + command, _context(request, actor, idempotency_key) + ) + return _set_etag(response, result) + + +@router.patch("/process-runs/{process_run_id}", response_model=None) +def update_process_run( + process_run_id: UUID, + body: UpdateProcessRunRequest, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + if_match: Annotated[str, Header(alias="If-Match")], + registration: Annotated[ + CommandRegistration, Depends(command_registration("update_process_run")) + ], +) -> ProcessRunSchema: + command = registration.decode_command( + {"process_run_id": process_run_id}, {"If-Match": if_match}, body + ) + result = CommandService(request.app.state.session_factory).execute( + command, _context(request, actor, idempotency_key) + ) + return _set_etag(response, result) + + +@router.post("/lifecycle/{object_type}/{object_id}", response_model=None) +def set_lifecycle_status( + object_type: str, + object_id: UUID, + body: SetLifecycleStatusRequest, + request: Request, + response: Response, + actor: Annotated[RequestActor, Depends(authenticate_request)], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + if_match: Annotated[str, Header(alias="If-Match")], + registration: Annotated[ + CommandRegistration, Depends(command_registration("set_lifecycle_status")) + ], +): + command = registration.decode_command( + {"object_type": object_type, "object_id": object_id}, + {"If-Match": if_match}, + body, + ) + result = CommandService(request.app.state.session_factory).execute( + command, + _context(request, actor, idempotency_key), + ) + return _set_etag(response, result) diff --git a/recap/server/rest_models.py b/recap/server/rest_models.py new file mode 100644 index 0000000..02858a5 --- /dev/null +++ b/recap/server/rest_models.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from recap.lifecycle import LifecycleStatus +from recap.schemas.process import ProcessRunCopyOptions +from recap.schemas.resource import ResourceCopyOptions + + +class RestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class CreateNamespaceRequest(RestModel): + metadata: dict[str, Any] = Field(default_factory=dict) + + +class UpdateNamespaceRequest(RestModel): + metadata: dict[str, Any] | None = None + status: LifecycleStatus | None = None + + @model_validator(mode="after") + def require_change(self): + if self.metadata is None and self.status is None: + raise ValueError("Namespace update is empty") + return self + + +class CreateResourceRequest(RestModel): + id: UUID | None = None + name: str + template_id: str + parent_id: str | None = None + properties: dict[str, dict[str, Any]] | None = None + + +class UpdateResourceRequest(RestModel): + name: str | None = None + properties: dict[str, dict[str, Any]] | None = None + + @model_validator(mode="after") + def require_change(self): + if self.name is None and not self.properties: + raise ValueError("Resource update is empty") + return self + + +class UpdateProcessRunRequest(RestModel): + description: str | None = None + status: str | None = None + assignments: dict[str, UUID] | None = None + steps: dict[str, dict[str, dict[str, object]]] | None = None + + @model_validator(mode="after") + def require_change(self): + if ( + self.description is None + and self.status is None + and self.assignments is None + and self.steps is None + ): + raise ValueError("Process run update is empty") + return self + + +class SetLifecycleStatusRequest(RestModel): + status: LifecycleStatus + + +class CopyResourceRequest(RestModel, ResourceCopyOptions): + destination_namespace: str + + +class CopyProcessRunRequest(RestModel, ProcessRunCopyOptions): + destination_namespace: str diff --git a/recap/server/security.py b/recap/server/security.py new file mode 100644 index 0000000..166f90e --- /dev/null +++ b/recap/server/security.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import re +from typing import Annotated + +from fastapi import Header, HTTPException, Request, status + +from recap.authentication.errors import AuthenticationError +from recap.authentication.models import RequestActor + +_API_KEY_HEADER = re.compile(r"Apikey ([^\s]+)") + + +def _unauthorized() -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API key", + headers={"WWW-Authenticate": "Apikey"}, + ) + + +async def authenticate_request( + request: Request, + authorization: Annotated[str | None, Header()] = None, +) -> RequestActor: + """Authenticate an exact ``Apikey `` authorization header.""" + match = _API_KEY_HEADER.fullmatch(authorization or "") + if match is None: + raise _unauthorized() + + try: + return await request.app.state.request_authenticator.authenticate( + match.group(1) + ) + except AuthenticationError: + raise _unauthorized() from None diff --git a/recap/server/strawberry_schema.py b/recap/server/strawberry_schema.py deleted file mode 100644 index 1e39642..0000000 --- a/recap/server/strawberry_schema.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Root GraphQL Query type and schema builder.""" - -import strawberry -from strawberry.fastapi import GraphQLRouter -from strawberry.scalars import JSON -from strawberry.schema.config import StrawberryConfig - -from recap.adapter.local import LocalBackend -from recap.server.resolvers import ( - resolve_campaigns, - resolve_campaigns_count, - resolve_execute_count, - resolve_execute_query, - resolve_process_runs, - resolve_process_runs_count, - resolve_process_templates, - resolve_process_templates_count, - resolve_resource_templates, - resolve_resource_templates_count, - resolve_resources, - resolve_resources_count, -) -from recap.server.strawberry_types import ( - CampaignType, - ProcessRunType, - ProcessTemplateType, - ResourceTemplateType, - ResourceType, -) - - -@strawberry.type -class Query: - execute_query: JSON = strawberry.field(resolver=resolve_execute_query) - execute_count: int = strawberry.field(resolver=resolve_execute_count) - - # List fields - resources: list[ResourceType] = strawberry.field(resolver=resolve_resources) - resource_templates: list[ResourceTemplateType] = strawberry.field( - resolver=resolve_resource_templates - ) - process_runs: list[ProcessRunType] = strawberry.field(resolver=resolve_process_runs) - process_templates: list[ProcessTemplateType] = strawberry.field( - resolver=resolve_process_templates - ) - campaigns: list[CampaignType] = strawberry.field(resolver=resolve_campaigns) - - # Count fields - resources_count: int = strawberry.field(resolver=resolve_resources_count) - process_runs_count: int = strawberry.field(resolver=resolve_process_runs_count) - campaigns_count: int = strawberry.field(resolver=resolve_campaigns_count) - resource_templates_count: int = strawberry.field( - resolver=resolve_resource_templates_count - ) - process_templates_count: int = strawberry.field( - resolver=resolve_process_templates_count - ) - - -def build_schema(backend: LocalBackend) -> strawberry.Schema: - """Build a Strawberry schema for introspection or testing only. - - For serving with FastAPI, use ``build_router()`` which properly injects - context via ``context_getter``. This function returns a bare schema without - context injection — resolvers accessing ``info.context["backend"]`` will - fail at runtime unless context is supplied externally. - """ - return strawberry.Schema( - query=Query, config=StrawberryConfig(auto_camel_case=False) - ) - - -def build_router(backend: LocalBackend) -> GraphQLRouter: - """Build a GraphQLRouter (for mounting in FastAPI) with backend in context.""" - - async def get_context() -> dict: - return {"backend": backend} - - schema = strawberry.Schema( - query=Query, config=StrawberryConfig(auto_camel_case=False) - ) - return GraphQLRouter(schema, context_getter=get_context) diff --git a/recap/server/strawberry_types.py b/recap/server/strawberry_types.py deleted file mode 100644 index 4b5cb18..0000000 --- a/recap/server/strawberry_types.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Strawberry GraphQL types derived from recap Pydantic schemas.""" - -from __future__ import annotations - -from datetime import datetime - -import strawberry -from strawberry.scalars import JSON - - -@strawberry.type -class AttributeValueType: - id: strawberry.ID - name: str - slug: str - value: JSON | None - create_date: datetime - modified_date: datetime - - -@strawberry.type -class AttributeGroupType: - id: strawberry.ID - name: str - attributes: list[AttributeValueType] - - -@strawberry.type -class ResourceTemplateType: - id: strawberry.ID - name: str - version: str - create_date: datetime - modified_date: datetime - - -@strawberry.type -class ResourceType: - id: strawberry.ID - name: str - create_date: datetime - modified_date: datetime - template: ResourceTemplateType | None = None - attribute_groups: list[AttributeGroupType] = strawberry.field(default_factory=list) - - -@strawberry.type -class ParameterType: - name: str - value: JSON | None - - -@strawberry.type -class StepType: - id: strawberry.ID - name: str - status: str - create_date: datetime - modified_date: datetime - parameters: list[ParameterType] = strawberry.field(default_factory=list) - - -@strawberry.type -class ProcessTemplateType: - id: strawberry.ID - name: str - version: str - create_date: datetime - modified_date: datetime - - -@strawberry.type -class ResourceAssignmentType: - resource: ResourceType - slot_name: str - direction: str - - -@strawberry.type -class ProcessRunType: - id: strawberry.ID - name: str - description: str | None - create_date: datetime - modified_date: datetime - process_template: ProcessTemplateType | None = None - steps: list[StepType] = strawberry.field(default_factory=list) - resources: list[ResourceAssignmentType] = strawberry.field(default_factory=list) - - -@strawberry.type -class CampaignType: - id: strawberry.ID - name: str - proposal: str | None - create_date: datetime - modified_date: datetime - process_runs: list[ProcessRunType] = strawberry.field(default_factory=list) diff --git a/recap/tests/conftest.py b/recap/tests/conftest.py index 7ea2bca..06c4295 100644 --- a/recap/tests/conftest.py +++ b/recap/tests/conftest.py @@ -1,14 +1,47 @@ -from contextlib import contextmanager +from collections.abc import Callable +from contextlib import ExitStack, contextmanager +from functools import partial +from pathlib import Path +from shutil import copy2 +from uuid import uuid4 +import httpx2 import pytest +from fastapi.testclient import TestClient from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool +from recap.adapter.http_transport import HTTPTransport +from recap.adapter.rest import RESTAdapter +from recap.client.backend import ClientBackend from recap.client.base_client import RecapClient from recap.db.base import Base +from recap.db.namespace import Namespace +from recap.server.app import create_app +from recap.utils.general import Direction from recap.utils.migrations import apply_migrations as upgrade_database -from recap.utils.migrations import downgrade_migrations + + +def _loopback_request( + app_client: TestClient, request: httpx2.Request +) -> httpx2.Response: + response = app_client.request( + request.method, + str(request.url), + headers=dict(request.headers), + content=request.content, + ) + return httpx2.Response( + response.status_code, + headers=response.headers, + content=response.content, + request=request, + ) + + +def _loopback_transport(app_client: TestClient) -> httpx2.MockTransport: + return httpx2.MockTransport(partial(_loopback_request, app_client)) @contextmanager @@ -24,11 +57,14 @@ def count_statements(target): Example:: - with count_statements(client) as counter: - client.set_campaign(existing_id) + with count_statements(client) as counter: + client.namespace("existing") assert counter["n"] == 0 """ - engine = getattr(target, "engine", target) + connection_state = getattr(target, "connection_state", None) + engine = getattr(connection_state, "engine", None) + if engine is None: + engine = getattr(target, "engine", target) counter = {"n": 0} def _before(conn, cursor, statement, parameters, context, executemany): @@ -47,25 +83,260 @@ def statement_counter(): return count_statements +@pytest.fixture +def api_client(tmp_path): + with TestClient(create_app(tmp_path / "rest.db", api_key="secret")) as client: + yield client + + +@pytest.fixture +def auth_header(): + return {"Authorization": "Apikey secret"} + + +@pytest.fixture +def rest_loopback_client(tmp_path): + """Build remote client over FastAPI TestClient transport, not HTTP mocks.""" + db_path = tmp_path / "rest-loopback.db" + api_key = "loopback-secret" + with TestClient(create_app(db_path, api_key=api_key)) as app_client: + transport = HTTPTransport(api_key) + transport._client.close() + transport._client = httpx2.Client(transport=_loopback_transport(app_client)) + rest = RESTAdapter("http://recap.test", _transport=transport) + backend = ClientBackend( + reader=rest, + writer=rest, + namespaces=rest, + namespace_writer=rest, + context_resolver=rest, + permissions=rest, + ) + with RecapClient._from_backends(backend, namespace="") as client: + yield client + + +@pytest.fixture +def idempotency_headers(auth_header): + def make(key, **extra): + return {**auth_header, "Idempotency-Key": key, **extra} + + return make + + +@pytest.fixture +def create_namespace(api_client, idempotency_headers): + def create(path="beamline/amx", metadata=None): + metadata = {} if metadata is None else metadata + parts = path.split("/") + prefix = [] + for index, part in enumerate(parts): + prefix.append(part) + response = api_client.put( + f"/api/v1/namespaces/{'/'.join(prefix)}", + headers=idempotency_headers(f"namespace-{index}"), + json={"metadata": metadata if index == len(parts) - 1 else {}}, + ) + assert response.status_code == 201 + return response + + return create + + +@pytest.fixture(params=["local", "remote"], ids=["local", "remote"]) +def read_client(request, read_client_pair): + local, remote = read_client_pair + return local if request.param == "local" else remote + + +@pytest.fixture(scope="session") +def blank_database_path(tmp_path_factory) -> Path: + path = tmp_path_factory.mktemp("seed-databases") / "blank-migrated.db" + with RecapClient.from_sqlite(path): + pass + return path + + +@pytest.fixture +def copy_database() -> Callable[[Path, Path], Path]: + def copy(source: Path, destination: Path) -> Path: + destination.parent.mkdir(parents=True, exist_ok=True) + copy2(source, destination) + return destination + + return copy + + +def _seed_query_namespace(client: RecapClient) -> str: + return client.create_namespace("test/namespace").path + + +def _seed_query_resource_tree(client: RecapClient) -> str: + namespace_path = client.create_namespace("test/resource-tree").path + with client.build_resource_template( + name="Parent", type_names=["container"] + ) as builder: + builder.close_child() + with client.build_resource_template(name="Child", type_names=["sample"]) as builder: + builder.close_child() + with client.build_resource("root", "Parent") as builder: + nested = builder.add_child("nested", "Child").resource + root = client.get_resource("root", "Parent") + with client.build_resource(resource_id=nested.id) as builder: + builder.finalize() + with client.build_resource(resource_id=root.id) as builder: + builder.finalize() + return namespace_path + + +def _seed_parity_graph(client: RecapClient) -> str: + namespace_path = "test/mx-parity" + client.create_namespace(namespace_path, metadata={"beamline": "AMX"}) + client = client.namespace(namespace_path) + with client.build_resource_template( + name="Parity plate", type_names=["container", "plate"] + ) as template: + template.add_properties( + {"metrics": [{"name": "rating", "type": "int", "default": 1}]} + ) + ( + template.add_child("sample", ["sample"]) + .add_properties( + {"contents": [{"name": "mass", "type": "float", "default": 2.5}]} + ) + .close_child() + ) + template.finalize() + + first_plate = client.create_resource("plate-1", "Parity plate") + second_plate = client.create_resource("plate-2", "Parity plate") + for plate, rating in ((first_plate, 12), (second_plate, 3)): + with client.build_resource(resource_id=plate.id) as builder: + model = builder.get_model() + model.properties.metrics.rating = rating + builder.set_model(model) + sample = client.create_resource("sample-1", "sample", parent=first_plate) + with client.build_resource(resource_id=sample.id) as builder: + builder.finalize() + with client.build_resource(resource_id=first_plate.id) as builder: + builder.finalize() + with client.build_resource(resource_id=second_plate.id) as builder: + builder.finalize() + + with client.build_process_template("Parity workflow", "1.0") as template: + template.add_resource_slot("plate", "container", Direction.input) + ( + template.add_step("Collect") + .add_parameters( + {"exposure": [{"name": "dwell", "type": "int", "default": 1}]} + ) + .bind_slot("source", "plate") + .close_step() + ) + template.finalize() + + for name, plate, dwell in ( + ("run-high", first_plate, 15), + ("run-low", second_plate, 5), + ): + with client.build_process_run( + name, "REST parity run", "Parity workflow", "1.0" + ) as run: + run.assign_resource("plate", plate) + parameters = run.get_params("Collect") + parameters.exposure.dwell = dwell + run.set_params(parameters) + run.finalize() + + return namespace_path + + +@pytest.fixture(scope="session") +def integration_seed_path(blank_database_path, tmp_path_factory) -> Path: + path = tmp_path_factory.mktemp("seed-databases") / "integration-seed.db" + copy2(blank_database_path, path) + with RecapClient.from_sqlite(path) as client: + client.create_namespace("test") + _seed_query_namespace(client) + _seed_query_resource_tree(client) + _seed_parity_graph(client) + return path + + +@pytest.fixture +def integration_database_path(integration_seed_path, copy_database, tmp_path) -> Path: + return copy_database(integration_seed_path, tmp_path / "test.db") + + +@pytest.fixture +def query_namespace_path() -> str: + return "test/namespace" + + +@pytest.fixture +def query_resource_tree_path() -> str: + return "test/resource-tree" + + +@pytest.fixture +def read_client_pair(integration_seed_path, copy_database, tmp_path): + """Create isolated local and remote clients over a composite seed copy.""" + db_path = copy_database(integration_seed_path, tmp_path / "parity.db") + with ExitStack() as stack: + local = stack.enter_context( + RecapClient.from_sqlite(db_path, namespace="test/mx-parity") + ) + api_key = "parity-secret" + app_client = stack.enter_context( + TestClient(create_app(db_path, api_key=api_key)) + ) + + transport = HTTPTransport(api_key) + transport._client.close() + transport._client = httpx2.Client(transport=_loopback_transport(app_client)) + rest = RESTAdapter("http://recap.test", _transport=transport) + remote = stack.enter_context( + RecapClient._from_backends( + ClientBackend( + reader=rest, + writer=rest, + namespaces=rest, + namespace_writer=rest, + context_resolver=rest, + permissions=rest, + ), + namespace="test/mx-parity", + ) + ) + yield local, remote + + +@pytest.fixture +def parity_clients(read_client_pair): + """Preserve paired-client fixture name for parity tests.""" + return read_client_pair + + @pytest.fixture(scope="session") -def db_url(tmp_path_factory): +def db_path(tmp_path_factory): db_dir = tmp_path_factory.mktemp("data") - db_path = db_dir / "test.db" + return db_dir / "test.db" + + +@pytest.fixture(scope="session") +def db_url(db_path): return f"sqlite:///{db_path}" @pytest.fixture(scope="session") def apply_migrations(db_url): """ - Run alembic migrations once before all tests. + Run Alembic migrations once before all tests. """ upgrade_database(db_url) yield - # Optional cleanup for SQLite or temporary DB - downgrade_migrations(db_url) - @pytest.fixture(scope="session") def engine(db_url): @@ -83,7 +354,6 @@ def setup_database(engine): """Create all tables""" Base.metadata.create_all(bind=engine) yield - Base.metadata.drop_all(bind=engine) @pytest.fixture(scope="function") @@ -93,6 +363,21 @@ def db_session(apply_migrations, engine): transaction = connection.begin() TestingSessionLocal = sessionmaker(bind=connection) session = TestingSessionLocal(bind=connection) + default_namespace = Namespace(path=f"test/{uuid4()}", metadata_json={}) + session.add(default_namespace) + + def assign_namespace(session, flush_context, instances): + for obj in session.new: + if not hasattr(obj, "namespace_id") or obj.namespace_id is not None: + continue + if getattr(obj, "namespace", None) is not None: + continue + parent = getattr(obj, "parent", None) + obj.namespace = ( + parent.namespace if parent is not None else default_namespace + ) + + event.listen(session, "before_flush", assign_namespace) # Add default start and end actionTypes """ start_action_type = StepTemplate(name="Start") @@ -105,16 +390,29 @@ def db_session(apply_migrations, engine): if transaction.is_active: transaction.rollback() + event.remove(session, "before_flush", assign_namespace) session.close() connection.close() -@pytest.fixture(scope="function") -def client(db_url, apply_migrations): - # client = RecapClient(url=db_url) - # try: - # yield client - # finally: - # client.close() - with RecapClient(url=db_url) as client: - yield client +@pytest.fixture +def namespaced_session(db_session): + """Adapt legacy graph fixtures by assigning one explicit namespace.""" + namespace = Namespace(path=f"test/{uuid4()}", metadata_json={}) + db_session.add(namespace) + + def assign_namespace(session, flush_context, instances): + for obj in session.new: + if hasattr(obj, "namespace_id"): + obj.namespace = namespace + + event.listen(db_session, "before_flush", assign_namespace) + yield db_session + event.remove(db_session, "before_flush", assign_namespace) + + +@pytest.fixture +def client(blank_database_path, copy_database, tmp_path): + db_path = copy_database(blank_database_path, tmp_path / "client.db") + with RecapClient.from_sqlite(db_path) as recap_client: + yield recap_client diff --git a/recap/tests/fixtures/authorization.yml b/recap/tests/fixtures/authorization.yml new file mode 100644 index 0000000..3c73496 --- /dev/null +++ b/recap/tests/fixtures/authorization.yml @@ -0,0 +1,30 @@ +source_revision: revision-42 +roles: + reader: + scopes: + - namespace:read + - resource:read + operator: + scopes: + - namespace:read + - process-run:write +groups: + scientists: + identities: + - provider: pam + subject: alice + - provider: oidc + subject: bob@example.com + operators: + identities: + - provider: pam + subject: carol +namespaces: + beamline/amx: + groups: + - name: scientists + role: reader + beamline/amx/proposal/312345: + groups: + - name: operators + role: operator diff --git a/recap/tests/lix/test_api_soln_scattering.py b/recap/tests/lix/test_api_soln_scattering.py deleted file mode 100644 index e1409a9..0000000 --- a/recap/tests/lix/test_api_soln_scattering.py +++ /dev/null @@ -1,132 +0,0 @@ -from itertools import product - -from recap.dsl.process_builder import ProcessRunBuilder -from recap.utils.general import Direction - - -def test_client(client): - with client.build_process_template("Test", "0.0.1") as ed: - ed.add_resource_slot( - "Input plate 1", "container", Direction.input, create_resource_type=True - ).add_resource_slot( - "Input plate 2", "container", Direction.input - ).add_resource_slot( - "Liquid transfer operator", - "operator", - Direction.input, - create_resource_type=True, - ).add_step("Transfer").bind_slot("source", "Input plate 1").bind_slot( - "dest", "Input plate 2" - ).bind_slot("operator", "Liquid transfer operator").param_group( - "volume transfer" - ).add_attribute( - attr_name="volume", value_type="float", unit="uL", default=0.0 - ).add_attribute( - attr_name="rate", value_type="float", unit="uL/sec", default=0.0 - ).close_group().close_step().add_step("Heat plate").bind_slot( - "target", "Input plate 2" - ).param_group("heat to").add_attribute( - "temperature", "float", "degC", "0.0" - ).close_group().close_step() - - with client.build_resource_template( - name="96 well plate", type_names=["container", "plate"] - ) as rt: - rt.prop_group("dimensions").add_attribute("rows", "float", "", 8).add_attribute( - "columns", "float", "", 12 - ) - well_cols = "ABCDEFGH" - well_rows = [i for i in range(1, 13)] - well_names = [f"{wn[0]}{wn[1]}" for wn in product(well_cols, well_rows)] - for well_name in well_names: - rt.add_child(well_name, ["container", "well"]).prop_group( - group_name="well_data" - ).add_attribute( - attr_name="sample_name", value_type="str", unit="", default="" - ).add_attribute( - attr_name="buffer_name", - value_type="str", - unit="", - default="", - ).add_attribute( - attr_name="volume", - value_type="int", - unit="uL", - default="0", - ).add_attribute( - attr_name="mixing", - value_type="str", - unit="", - default="", - ).add_attribute( - attr_name="stock", - value_type="bool", - unit="", - default="False", - ).add_attribute( - attr_name="notes", - value_type="str", - unit="", - default="", - ).close_group().close_child() - - with client.build_resource_template( - name="sample holder", type_names=["container", "plate"] - ) as rt: - rt.prop_group("dimensions").add_attribute("rows", "int", "", 2).add_attribute( - "columns", "int", "", 9 - ) - for well_num in range(1, 19): - rt.add_child(str(well_num), ["container", "well"]).prop_group( - group_name="sample_holder_well_data" - ).add_attribute( - attr_name="sample_name", - value_type="str", - unit="", - default="", - ).add_attribute( - attr_name="buffer_name", - value_type="str", - unit="", - default="", - ).add_attribute( - attr_name="volume", - value_type="float", - unit="uL", - default="0", - ).close_group().close_child() - - with client.build_resource_template( - name="robot", type_names=["robot", "liquid_transfer", "operator"] - ) as rt: - rt.prop_group("details").add_attribute( - "serial_no", "str", "", "xyz" - ).close_group() - - client.create_campaign(name="Test campaign", proposal="1") - - sample_holder = client.create_resource("Test destination plate", "sample holder") - source_plate = client.create_resource("96 well plate", "96 well plate") - robot = client.create_resource("LHR", "robot") - - with client.build_process_run( - name="test_run", - description="This is a test", - template_name="Test", - version="0.0.1", - ) as run: - run: ProcessRunBuilder - run.assign_resource( - resource_slot_name="Input plate 1", resource=source_plate - ).assign_resource( - resource_slot_name="Input plate 2", resource=sample_holder - ).assign_resource("Liquid transfer operator", resource=robot) - - transfer_params = run.get_params("Transfer") - transfer_params.volume_transfer.volume = 50 - transfer_params.volume_transfer.rate = 1 - run.set_params(transfer_params) - - heat_params = run.get_params("Heat plate") - heat_params.heat_to.temperature = 100 - run.set_params(heat_params) diff --git a/recap/tests/lix/test_solution_scattering.py b/recap/tests/lix/test_solution_scattering.py deleted file mode 100644 index 1335f19..0000000 --- a/recap/tests/lix/test_solution_scattering.py +++ /dev/null @@ -1,205 +0,0 @@ -from itertools import product - -from sqlalchemy import select - -from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate -from recap.db.campaign import Campaign -from recap.db.process import ProcessRun, ProcessTemplate, ResourceSlot -from recap.db.resource import Resource, ResourceTemplate, ResourceType -from recap.db.step import StepTemplate -from recap.utils.database import get_or_create -from recap.utils.general import Direction - - -def test_solution_scattering_sample_prep(db_session): - """ - Steps for sample prep - - 96 well sample plate, each column consists of 12 wells (A1-A12) - Cols A1-A9 contain buffers or samples - Cols A10-A12 contain stock solutions always. A1-A9 could be - used to hold stock solutions - - Each well would have a bool to indicate stock solution - - Mixing column indicates how sample is mixed with stock solution - if no mixing instruction, there must be a volume value - Both cannot be empty - - - """ - - container_resource_type = ResourceType(name="container1") - - sample_plate_96_well = ResourceTemplate( - name="96-well sample template", - types=[container_resource_type], - ) - num_rows_attr_val = AttributeTemplate( - name="rows", value_type="int", default_value=8 - ) - num_cols_attr_val = AttributeTemplate( - name="cols", value_type="int", default_value=12 - ) - plate_dimensions_attr = AttributeGroupTemplate( - name="96_well_plate_dimesions", - attribute_templates=[num_rows_attr_val, num_cols_attr_val], - ) - sample_plate_96_well.attribute_group_templates.append(plate_dimensions_attr) - db_session.add(sample_plate_96_well) - db_session.commit() - - statement = select(ResourceTemplate).where( - ResourceTemplate.name == "96-well sample template" - ) - sample_plate_96_well: ResourceTemplate = db_session.scalars(statement).one() - assert ( - sample_plate_96_well.attribute_group_templates[0] - .attribute_templates[0] - .default_value - == "8" - ) - - # Well attributes - sample_name = AttributeTemplate( - name="sample_name", value_type="str", default_value="" - ) - buffer_name = AttributeTemplate( - name="buffer_name", value_type="str", default_value="" - ) - volume = AttributeTemplate(name="volume", value_type="int", default_value=0) - mixing_instruction = AttributeTemplate( - name="mixing", value_type="str", default_value="" - ) - stock = AttributeTemplate(name="stock", value_type="bool", default_value="False") - notes = AttributeTemplate(name="notes", value_type="str", default_value="") - db_session.commit() - - well_cols = "ABCDEFGH" - well_rows = [i for i in range(1, 13)] - well_names = [f"{wn[0]}{wn[1]}" for wn in product(well_cols, well_rows)] - # well_resource_type = ResourceType(name="well") - well_resource_type, _ = get_or_create( - db_session, ResourceType, where={"name": "well"} - ) - for well_name in well_names: - well_data = AttributeGroupTemplate( - name="well_data", - attribute_templates=[ - sample_name, - buffer_name, - volume, - mixing_instruction, - stock, - notes, - ], - ) - # db_session.add(well_data) - well_resource_template = ResourceTemplate( - name=well_name, types=[well_resource_type] - ) - db_session.add(well_resource_template) - well_resource_template.attribute_group_templates.append(well_data) - # db_session.add(well_resource_template) - sample_plate_96_well.children[well_resource_template.name] = ( - well_resource_template - ) - db_session.add(sample_plate_96_well) - db_session.commit() - - sample_holder = ResourceTemplate( - name="sample holder template", types=[container_resource_type] - ) - # Well attributes - for well_num in range(1, 19): - well_resource_template = ResourceTemplate( - name=str(well_num), - types=[well_resource_type], - ) - sample_well_data = AttributeGroupTemplate( - name="sample_holder_well_data", - # resource_template=well_resource_template, - attribute_templates=[sample_name, buffer_name, volume], - ) - well_resource_template.attribute_group_templates.append(sample_well_data) - sample_holder.children[well_resource_template.name] = well_resource_template - db_session.add(well_resource_template) - # db_session.flush() - db_session.add(sample_holder) - db_session.commit() - - # Create process - sample_prep_process = ProcessTemplate( - name="Solution Scattering Sample Prep", version="1.0" - ) - sample_plate_resource_slot = ResourceSlot( - name="sample_plate", - process_template=sample_prep_process, - resource_type=container_resource_type, - direction=Direction.input, - ) - holder_resource_slot = ResourceSlot( - name="holder", - process_template=sample_prep_process, - resource_type=container_resource_type, - direction=Direction.output, - ) - db_session.add(sample_plate_resource_slot) - db_session.add(holder_resource_slot) - db_session.commit() - - volume_attr_value = AttributeTemplate( - name="volume", - value_type="float", - unit="uL", - default_value="0", - ) - volume_transferred_template = AttributeGroupTemplate( - name="volume_transferred", - attribute_templates=[volume_attr_value], - ) - - # Transfer action - robot_transfer_action_template = StepTemplate( - name="Robot transfer", - process_template=sample_prep_process, - attribute_group_templates=[volume_transferred_template], - # resource_slots=[(sample_plate_resource_slot, "source_container"), - # (holder_resource_slot, "destination_container")] - ) - robot_transfer_action_template.resource_slots["source_container"] = ( - sample_plate_resource_slot - ) - robot_transfer_action_template.resource_slots["destination_container"] = ( - holder_resource_slot - ) - db_session.add(robot_transfer_action_template) - db_session.commit() - - assert ( - robot_transfer_action_template.resource_slots["source_container"].name - == "sample_plate" - ) - - # Create process instance - source_plate = Resource(name="source_plate1", template=sample_plate_96_well) - destination_plate = Resource(name="CH", template=sample_holder) - db_session.add(destination_plate) - db_session.add(source_plate) - db_session.commit() - campaign = Campaign(name="Test campaign", proposal="12356") - process_run1 = ProcessRun( - name="Test1", - description="This is a test", - template=sample_prep_process, - campaign=campaign, - ) - - db_session.add(process_run1) - # process_run1.resources=[(source_plate, sample_plate_resource_slot), - # (destination_plate, holder_resource_slot)] - process_run1.resources[sample_plate_resource_slot] = source_plate - process_run1.resources[holder_resource_slot] = destination_plate - db_session.commit() - - assert process_run1.steps["Robot transfer"].template.name == "Robot transfer" diff --git a/recap/tests/test_actions.py b/recap/tests/test_actions.py deleted file mode 100644 index c03b587..0000000 --- a/recap/tests/test_actions.py +++ /dev/null @@ -1,89 +0,0 @@ -from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate -from recap.db.campaign import Campaign -from recap.utils.database import get_or_create - - -def test_container(db_session): - from recap.db.process import ProcessRun, ProcessTemplate, ResourceSlot - from recap.db.resource import Resource, ResourceTemplate, ResourceType - from recap.db.step import StepTemplate - - # db_session.commit() - process_template = ProcessTemplate(name="TestProcessTemplate", version="1.0") - db_session.add(process_template) - # container_type = ResourceType(name="container") - container_type, _ = get_or_create( - db_session, ResourceType, where={"name": "container"} - ) - container_1_resource_slot = ResourceSlot( - process_template=process_template, - resource_type=container_type, - name="container1", - direction="input", - ) - container_2_resource_slot = ResourceSlot( - process_template=process_template, - resource_type=container_type, - name="container2", - direction="input", - ) - db_session.add(container_1_resource_slot) - db_session.add(container_2_resource_slot) - process_template.resource_slots.append(container_1_resource_slot) - process_template.resource_slots.append(container_2_resource_slot) - param_type = AttributeGroupTemplate( - name="TestParamType", - ) - param_value_template = AttributeTemplate( - name="volume", value_type="float", unit="uL", default_value="4.0" - ) - param_type.attribute_templates.append(param_value_template) - db_session.add(param_type) - step_template = StepTemplate( - name="TestActionType", - attribute_group_templates=[param_type], - process_template=process_template, - ) - step_template.resource_slots["source_container"] = container_1_resource_slot - step_template.resource_slots["dest_container"] = container_2_resource_slot - - db_session.add(step_template) - db_session.commit() - - child_prop_type = AttributeTemplate( - name="ChildPropTest", value_type="float", unit="mm", default_value="2.2" - ) - child_attr_template = AttributeGroupTemplate(name="Child test") - child_attr_template.attribute_templates.append(child_prop_type) - db_session.add(child_prop_type) - child_container_template = ResourceTemplate( - name="ChildTestContainerType", - types=[container_type], - attribute_group_templates=[child_attr_template], - ) - db_session.add(child_container_template) - db_session.commit() - - child_container_a1 = Resource(name="A1", template=child_container_template) - child_container_a2 = Resource(name="A2", template=child_container_template) - campaign = Campaign(name="Test campaign", proposal="123456") - process_run = ProcessRun( - name="Test Process Run", - description="This is a test", - template=process_template, - campaign=campaign, - ) - process_run.resources[container_1_resource_slot] = child_container_a1 - process_run.resources[container_2_resource_slot] = child_container_a2 - db_session.add(process_run) - db_session.commit() - - result: ProcessRun = ( - db_session.query(ProcessRun).filter_by(name="Test Process Run").first() - ) - - assert result.resources[container_1_resource_slot].name == "A1" - assert ( - result.steps["TestActionType"].parameters["TestParamType"].values["volume"] - == 4.0 - ) diff --git a/recap/tests/test_array_default_value.py b/recap/tests/test_array_default_value.py index 99f180d..9f97ddd 100644 --- a/recap/tests/test_array_default_value.py +++ b/recap/tests/test_array_default_value.py @@ -10,7 +10,6 @@ from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate from recap.db.resource import Resource, ResourceTemplate -from recap.dsl.resource_builder import ResourceTemplateBuilder class TestArrayDefaultViaLocalBackend: @@ -22,6 +21,7 @@ def test_add_attribute_with_empty_list_default(self, client): name="ArrayTest-T1", type_names=["sample"] ) as rtb: rtb.prop_group("data").add_attribute("tags", "array", "", []).close_group() + rtb.finalize() # Verify template was persisted and is queryable tmpl = ( @@ -40,6 +40,7 @@ def test_add_attribute_with_populated_list_default(self, client): rtb.prop_group("meta").add_attribute( "labels", "array", "", ["a", "b", "c"] ).close_group() + rtb.finalize() tmpl = ( client.query_maker() @@ -52,13 +53,16 @@ def test_add_attribute_with_populated_list_default(self, client): def test_array_default_idempotent_on_reopen(self, client): """Reopening a template with an array attribute doesn't create duplicates.""" for _ in range(2): - with ResourceTemplateBuilder( - name="ArrayTest-T3", type_names=["container"], backend=client.backend + with client.build_resource_template( + name="ArrayTest-T3", type_names=["container"] ) as rtb: rtb.prop_group("info").add_attribute( "items", "array", "", [] ).close_group() - + with client.build_resource_template( + name="ArrayTest-T3", type_names=["container"] + ) as rtb: + rtb.finalize() # Should still have exactly one attribute template named "items" tmpl = ( client.query_maker() @@ -122,15 +126,24 @@ def test_full_dsl_array_resource_creation(self, client): "measurements", "array", "mm", [1, 2, 3] ).close_group() - with client.build_resource("ArrayE2E-R", "ArrayE2E-T") as _: - pass # just create with defaults - - res = ( - client.query_maker() - .resources() - .filter(name="ArrayE2E-R") - .include(["template", "properties"]) - .first() - ) + with client.build_resource("ArrayE2E-R", "ArrayE2E-T") as builder: + resource_id = builder.resource.id + with client.build_resource(resource_id=resource_id) as builder: + res = builder.get_model(update=True) assert res is not None assert res.properties.data.values.measurements.value == [1, 2, 3] + + def test_repeated_array_values_are_not_collapsed_to_default(self, client): + """Values repeating an array default remain distinct values.""" + with client.build_resource_template( + name="ArrayRepeatedDefault-T", type_names=["sample"] + ) as rtb: + rtb.prop_group("data").add_attribute( + "measurements", "array", "", [1] + ).close_group() + + with client.build_resource("ArrayRepeatedDefault-R", "ArrayRepeatedDefault-T") as builder: + model = builder.get_model() + model.properties.data.values.measurements.value = [1, 1] + builder.set_model(model) + assert builder.get_model().properties.data.values.measurements.value == [1, 1] diff --git a/recap/tests/test_attribute_value.py b/recap/tests/test_attribute_value.py index 37b8216..35d8615 100644 --- a/recap/tests/test_attribute_value.py +++ b/recap/tests/test_attribute_value.py @@ -1,9 +1,7 @@ import pytest -from recap.adapter.local import LocalBackend from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate, AttributeValue from recap.db.resource import Resource, ResourceTemplate -from recap.schemas.resource import ResourceTemplateSchema def test_attribute_value_coercion_and_json_storage(db_session): @@ -111,17 +109,19 @@ def test_enum_attribute_value_rejects_invalid_choice(db_session): av.value = "x" -def test_add_attr_group_reuses_existing_group(db_session): - tmpl = ResourceTemplate(name="RT") - db_session.add(tmpl) - db_session.flush() - - backend = LocalBackend(lambda: db_session) - # Manually bind the existing session for this test - backend._session = db_session - ref = ResourceTemplateSchema.model_validate(tmpl) +def test_resource_template_builder_reuses_existing_group(client): + scoped = client.namespace("") + with scoped.build_resource_template(name="RT", type_names=["sample"]) as builder: + builder.add_properties( + {"content": [{"name": "serial", "type": "str", "default": ""}]} + ) + template_id = builder.template.id - first = backend.add_attr_group("content", ref) - second = backend.add_attr_group("content", ref) + with scoped.build_resource_template(resource_template_id=template_id) as update: + update.prop_group("content").add_attribute( + "serial", "str", "", "" + ).close_group() - assert first.id == second.id + assert [group.name for group in update.template.attribute_group_templates].count( + "content" + ) == 1 diff --git a/recap/tests/test_audit.py b/recap/tests/test_audit.py new file mode 100644 index 0000000..8498a3e --- /dev/null +++ b/recap/tests/test_audit.py @@ -0,0 +1,100 @@ +import json +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from recap.server.audit import AuditOutcome, AuditRecord, AuditSink + + +@pytest.mark.parametrize( + ("outcome", "reason_code"), + [ + (AuditOutcome.SUCCESS, None), + (AuditOutcome.DENIED, "insufficient_scope"), + (AuditOutcome.ERROR, "service_unavailable"), + ], +) +def test_audit_records_serialize_only_sanitized_mutation_context(outcome, reason_code): + record = AuditRecord( + request_id=uuid4(), + actor_id="actor-1", + mutation="create_resource", + resource_type="resource", + resource_id="resource-1", + outcome=outcome, + reason_code=reason_code, + ) + + serialized = json.dumps(record.model_dump(mode="json"), sort_keys=True) + assert set(record.model_dump()) == { + "request_id", + "actor_id", + "mutation", + "resource_type", + "resource_id", + "outcome", + "reason_code", + } + assert "credential" not in serialized + assert "header" not in serialized + assert "property_value" not in serialized + assert "parameter_value" not in serialized + assert "grant" not in serialized + + +@pytest.mark.parametrize( + "forbidden_field", + [ + "credential", + "headers", + "property_values", + "parameter_values", + "grant_details", + ], +) +def test_audit_record_rejects_forbidden_detail_fields(forbidden_field): + data = { + "request_id": uuid4(), + "actor_id": "actor-1", + "mutation": "create_resource", + "resource_type": "resource", + "resource_id": "resource-1", + "outcome": "success", + forbidden_field: "sensitive-value", + } + + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuditRecord.model_validate(data) + + +def test_audit_outcome_and_reason_are_consistent(): + common = { + "request_id": uuid4(), + "actor_id": "actor-1", + "mutation": "create_resource", + "resource_type": "resource", + "resource_id": "resource-1", + } + + with pytest.raises(ValidationError): + AuditRecord(**common, outcome="success", reason_code="internal-detail") + with pytest.raises(ValidationError): + AuditRecord(**common, outcome="denied") + with pytest.raises(ValidationError): + AuditRecord(**common, outcome="error") + + with pytest.raises(ValidationError): + AuditRecord( + **common, + outcome="error", + reason_code="credential=secret grant=admin", + ) + + +def test_audit_sink_is_structural_protocol(): + class Collector: + def emit(self, record: AuditRecord) -> None: + pass + + assert isinstance(Collector(), AuditSink) diff --git a/recap/tests/test_authentication_models.py b/recap/tests/test_authentication_models.py new file mode 100644 index 0000000..20bf093 --- /dev/null +++ b/recap/tests/test_authentication_models.py @@ -0,0 +1,82 @@ +import inspect + +import pytest +from pydantic import ValidationError + +from recap.authentication.models import ( + ActorKind, + IdentityEvidence, + ProviderIdentity, + RequestActor, +) +from recap.authentication.protocols import ( + ExternalAuthenticator, + InternalAuthenticator, + RequestAuthenticator, +) +from recap.authorization.scopes import DEFAULT_SCOPE_REGISTRY, Scope + + +def test_identity_requires_provider_and_subject(): + assert ProviderIdentity(provider="pam", subject="alice") != ProviderIdentity( + provider="oidc", subject="alice" + ) + + +def test_authentication_models_are_immutable(): + identity = ProviderIdentity(provider="pam", subject="alice") + evidence = IdentityEvidence(identity=identity) + actor = RequestActor( + actor_id="alice", + kind=ActorKind.USER, + identities=(identity,), + credential_scopes=frozenset({Scope.RESOURCE_READ}), + namespace_restrictions=None, + credential_fingerprint="abc", + ) + + with pytest.raises(ValidationError): + identity.subject = "bob" + with pytest.raises(ValidationError): + evidence.identity = ProviderIdentity(provider="pam", subject="bob") + with pytest.raises(ValidationError): + actor.actor_id = "bob" + + +def test_scope_registry_is_exact(): + assert set(DEFAULT_SCOPE_REGISTRY.scopes) == { + Scope.NAMESPACE_READ, + Scope.NAMESPACE_WRITE, + Scope.PROCESS_TEMPLATE_READ, + Scope.PROCESS_TEMPLATE_WRITE, + Scope.RESOURCE_TEMPLATE_READ, + Scope.RESOURCE_TEMPLATE_WRITE, + Scope.RESOURCE_READ, + Scope.RESOURCE_WRITE, + Scope.PROCESS_RUN_READ, + Scope.PROCESS_RUN_WRITE, + } + + +def test_write_scope_does_not_imply_read_scope(): + assert not DEFAULT_SCOPE_REGISTRY.allows( + frozenset({Scope.RESOURCE_WRITE}), Scope.RESOURCE_READ + ) + + +def test_request_actor_preserves_credential_scope(): + actor = RequestActor( + actor_id="alice", + kind=ActorKind.USER, + identities=(ProviderIdentity(provider="pam", subject="alice"),), + credential_scopes=frozenset({Scope.RESOURCE_READ}), + namespace_restrictions=None, + credential_fingerprint="abc", + ) + assert actor.credential_scopes == frozenset({Scope.RESOURCE_READ}) + + +def test_authenticator_protocols_are_async(): + assert inspect.iscoroutinefunction(InternalAuthenticator.authenticate) + assert inspect.iscoroutinefunction(ExternalAuthenticator.authenticate) + assert inspect.iscoroutinefunction(RequestAuthenticator.authenticate) diff --git a/recap/tests/test_authorization_compiler.py b/recap/tests/test_authorization_compiler.py new file mode 100644 index 0000000..6a18628 --- /dev/null +++ b/recap/tests/test_authorization_compiler.py @@ -0,0 +1,199 @@ +import sqlite3 +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from recap.authorization import compiler +from recap.authorization.compiler import compile_authorization_snapshot +from recap.authorization.source import load_authorization_source + +FIXTURE = Path(__file__).parent / "fixtures" / "authorization.yml" + + +def _write_source(tmp_path, text): + source = tmp_path / "authorization.yml" + source.write_text(text) + return source + + +def _valid_source(body=""): + return f"""\ +source_revision: revision-1 +roles: + reader: + scopes: [namespace:read] +groups: + scientists: + identities: + - provider: pam + subject: alice +namespaces: + beamline/amx: + groups: + - name: scientists + role: reader +{body}""" + + +def test_compiles_exact_flattened_grants_with_provenance(tmp_path): + snapshot = tmp_path / "authorization.db" + + compile_authorization_snapshot(FIXTURE, snapshot) + + with sqlite3.connect(snapshot) as connection: + metadata = connection.execute( + "SELECT format_version, source_revision FROM snapshot_metadata" + ).fetchone() + rows = connection.execute( + """ + SELECT i.provider || ':' || i.subject, n.path, s.name, + g.group_name, g.role_name + FROM grants AS g + JOIN identities AS i ON i.id = g.identity_id + JOIN namespace_paths AS n ON n.id = g.namespace_path_id + JOIN scopes AS s ON s.id = g.scope_id + ORDER BY 1, 2, 3, 4, 5 + """ + ).fetchall() + integrity = connection.execute("PRAGMA integrity_check").fetchone() + + assert metadata == (1, "revision-42") + assert rows == [ + ( + "oidc:bob@example.com", + "beamline/amx", + "namespace:read", + "scientists", + "reader", + ), + ( + "oidc:bob@example.com", + "beamline/amx", + "resource:read", + "scientists", + "reader", + ), + ( + "pam:alice", + "beamline/amx", + "namespace:read", + "scientists", + "reader", + ), + ( + "pam:alice", + "beamline/amx", + "resource:read", + "scientists", + "reader", + ), + ( + "pam:carol", + "beamline/amx/proposal/312345", + "namespace:read", + "operators", + "operator", + ), + ( + "pam:carol", + "beamline/amx/proposal/312345", + "process-run:write", + "operators", + "operator", + ), + ] + assert integrity == ("ok",) + + +@pytest.mark.parametrize( + ("source", "message"), + [ + ( + _valid_source().replace("namespace:read", "unknown:scope"), + "unknown:scope", + ), + (_valid_source().replace("role: reader", "role: missing"), "missing"), + ( + _valid_source().replace("name: scientists", "name: missing"), + "missing", + ), + (_valid_source().replace("beamline/amx:", "/beamline/amx:"), "path"), + (_valid_source().replace("scopes: [namespace:read]", "scopes: []"), "scopes"), + ( + _valid_source().replace( + "namespaces:", + """\ + duplicate: + identities: + - provider: pam + subject: alice +namespaces: +""", + ), + "Duplicate identity", + ), + ( + _valid_source().replace( + " - name: scientists\n role: reader", + " - name: scientists\n role: reader\n" + " - name: scientists\n role: reader", + ), + "Duplicate binding", + ), + ], +) +def test_rejects_invalid_authorization_sources(tmp_path, source, message): + with pytest.raises((ValidationError, ValueError), match=message): + load_authorization_source(_write_source(tmp_path, source)) + + +def test_rejects_duplicate_yaml_mapping_keys(tmp_path): + source = _valid_source().replace( + "source_revision: revision-1", + "source_revision: revision-1\nsource_revision: revision-2", + ) + + with pytest.raises(ValueError, match="Duplicate YAML key.*source_revision"): + load_authorization_source(_write_source(tmp_path, source)) + + +def test_rejects_unknown_fields(tmp_path): + with pytest.raises(ValidationError, match="extra_forbidden"): + load_authorization_source( + _write_source(tmp_path, _valid_source("extra: true\n")) + ) + + +def test_failed_compile_preserves_existing_snapshot_and_removes_temp(tmp_path): + snapshot = tmp_path / "authorization.db" + snapshot.write_bytes(b"existing snapshot") + malformed = _write_source( + tmp_path, _valid_source().replace("role: reader", "role: missing") + ) + + with pytest.raises(ValueError, match="missing"): + compile_authorization_snapshot(malformed, snapshot) + + assert snapshot.read_bytes() == b"existing snapshot" + assert list(tmp_path.glob(".authorization.db.*.tmp")) == [] + + +def test_replace_failure_preserves_existing_snapshot_and_removes_temp( + tmp_path, monkeypatch +): + snapshot = tmp_path / "authorization.db" + snapshot.write_bytes(b"existing snapshot") + + def fail_replace(source, destination): + assert Path(source).parent == snapshot.parent + assert Path(destination) == snapshot + raise OSError("replace failed") + + monkeypatch.setattr(compiler.os, "replace", fail_replace) + + with pytest.raises(OSError, match="replace failed"): + compile_authorization_snapshot(FIXTURE, snapshot) + + assert snapshot.read_bytes() == b"existing snapshot" + assert list(tmp_path.glob(".authorization.db.*.tmp")) == [] diff --git a/recap/tests/test_authorization_snapshot.py b/recap/tests/test_authorization_snapshot.py new file mode 100644 index 0000000..ade76dc --- /dev/null +++ b/recap/tests/test_authorization_snapshot.py @@ -0,0 +1,161 @@ +import os +import sqlite3 +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from recap.authentication.models import ProviderIdentity +from recap.authorization.compiler import compile_authorization_snapshot +from recap.authorization.scopes import Scope +from recap.authorization.snapshot import ( + SnapshotProvider, + SnapshotUnavailable, + load_authorization_snapshot, +) + +FIXTURE = Path(__file__).parent / "fixtures" / "authorization.yml" + + +def _compile(snapshot: Path, source_revision: str = "revision-42") -> None: + source = snapshot.with_suffix(".yml") + source.write_text(FIXTURE.read_text().replace("revision-42", source_revision)) + compile_authorization_snapshot(source, snapshot) + + +def test_loads_snapshot_read_only_with_immutable_grants(tmp_path, monkeypatch): + path = tmp_path / "authorization.db" + _compile(path) + real_connect = sqlite3.connect + connections = [] + + def record_connect(database, *args, **kwargs): + connections.append((database, kwargs)) + return real_connect(database, *args, **kwargs) + + monkeypatch.setattr(sqlite3, "connect", record_connect) + + snapshot = load_authorization_snapshot(path, max_age=60, now=path.stat().st_mtime) + + assert connections == [(f"file:{path}?mode=ro", {"uri": True})] + assert snapshot.metadata.format_version == 1 + assert snapshot.metadata.source_revision == "revision-42" + assert len(snapshot.grants) == 6 + assert any( + grant.identity == ProviderIdentity(provider="pam", subject="alice") + and grant.namespace_path == "beamline/amx" + and grant.scope is Scope.RESOURCE_READ + and grant.group == "scientists" + and grant.role == "reader" + for grant in snapshot.grants + ) + with pytest.raises(ValidationError, match="frozen"): + snapshot.metadata.source_revision = "changed" + with pytest.raises(AttributeError): + snapshot.grants.add(next(iter(snapshot.grants))) + + +def test_rejects_missing_snapshot(tmp_path): + with pytest.raises(SnapshotUnavailable, match="unavailable"): + load_authorization_snapshot(tmp_path / "missing.db", max_age=60, now=0) + + +def test_rejects_unsupported_schema_version(tmp_path): + path = tmp_path / "authorization.db" + _compile(path) + with sqlite3.connect(path) as connection: + connection.execute("PRAGMA ignore_check_constraints = ON") + connection.execute("UPDATE snapshot_metadata SET format_version = 2") + + with pytest.raises(SnapshotUnavailable, match="format version"): + load_authorization_snapshot(path, max_age=60, now=path.stat().st_mtime) + + +def test_rejects_corrupt_snapshot(tmp_path): + path = tmp_path / "authorization.db" + path.write_bytes(b"not sqlite") + + with pytest.raises(SnapshotUnavailable, match="invalid"): + load_authorization_snapshot(path, max_age=60, now=path.stat().st_mtime) + + +def test_rejects_failed_integrity_check(tmp_path, monkeypatch): + path = tmp_path / "authorization.db" + _compile(path) + real_connect = sqlite3.connect + + class Cursor: + def fetchall(self): + return [("corrupt",)] + + class Connection: + def __init__(self, connection): + self.connection = connection + + def __enter__(self): + return self + + def __exit__(self, *args): + self.connection.close() + + def execute(self, sql, parameters=()): + if sql == "PRAGMA integrity_check": + return Cursor() + return self.connection.execute(sql, parameters) + + monkeypatch.setattr( + sqlite3, + "connect", + lambda database, *args, **kwargs: Connection( + real_connect(database, *args, **kwargs) + ), + ) + + with pytest.raises(SnapshotUnavailable, match="integrity"): + load_authorization_snapshot(path, max_age=60, now=path.stat().st_mtime) + + +def test_rejects_stale_snapshot(tmp_path): + path = tmp_path / "authorization.db" + _compile(path) + modified = path.stat().st_mtime + + with pytest.raises(SnapshotUnavailable, match="stale"): + load_authorization_snapshot(path, max_age=60, now=modified + 61) + + +def test_provider_atomically_reloads_generation_and_retains_acquired_snapshot( + tmp_path, +): + path = tmp_path / "authorization.db" + _compile(path, "revision-1") + now = path.stat().st_mtime + provider = SnapshotProvider(path, max_age=60, clock=lambda: now) + + request_snapshot = provider.acquire() + assert provider.acquire() is request_snapshot + + replacement = tmp_path / "replacement.db" + _compile(replacement, "revision-2") + os.replace(replacement, path) + reloaded = provider.acquire() + + assert reloaded is not request_snapshot + assert reloaded.metadata.source_revision == "revision-2" + assert request_snapshot.metadata.source_revision == "revision-1" + + +def test_provider_fails_closed_when_replacement_is_corrupt(tmp_path): + path = tmp_path / "authorization.db" + _compile(path) + now = path.stat().st_mtime + provider = SnapshotProvider(path, max_age=60, clock=lambda: now) + provider.acquire() + replacement = tmp_path / "replacement.db" + replacement.write_bytes(b"not sqlite") + os.replace(replacement, path) + + with pytest.raises(SnapshotUnavailable, match="invalid"): + provider.acquire() + with pytest.raises(SnapshotUnavailable, match="invalid"): + provider.acquire() diff --git a/recap/tests/test_backend_canonicalization.py b/recap/tests/test_backend_canonicalization.py new file mode 100644 index 0000000..569f8d9 --- /dev/null +++ b/recap/tests/test_backend_canonicalization.py @@ -0,0 +1,228 @@ +from datetime import datetime +from uuid import UUID, uuid4 + +import pytest + +from recap.client.backend import ClientBackend +from recap.client.base_client import RecapClient +from recap.commands.context import build_local_command_context +from recap.commands.models import UpdateNamespace +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext, NamespaceSchema +from recap.schemas.resource import ResourceRef, ResourceSchema, ResourceTemplateRef + + +def _namespace( + namespace_id: UUID, + *, + revision: int = 1, + path: str = "beamline", + status: LifecycleStatus = LifecycleStatus.ACTIVE, +) -> NamespaceSchema: + return NamespaceSchema.model_construct( + id=namespace_id, + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, revision), + path=path, + parent_id=None, + status=status, + revision=revision, + metadata={}, + ) + + +def _resource( + resource_id: UUID, + *, + child: ResourceSchema | ResourceRef | None = None, + name: str = "root", + namespace_id: UUID | None = None, +): + template = ResourceTemplateRef.model_construct( + id=uuid4(), + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 1), + namespace_id=namespace_id or uuid4(), + status=LifecycleStatus.ACTIVE, + revision=1, + name="Sample", + version="1.0", + ) + return ResourceSchema.model_construct( + id=resource_id, + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 1), + namespace_id=namespace_id or uuid4(), + status=LifecycleStatus.ACTIVE, + revision=1, + name=name, + copied_from_id=None, + template=template, + parent=None, + children={} if child is None else {"child": child}, + properties={}, + ) + + +class _FakeBackendBase: + def __init__(self, result_factory, command_result_factory=None): + self._result_factory = result_factory + self._command_result_factory = command_result_factory + self.query_calls = 0 + + def count(self, schema, spec, *, namespace_path): + return 1 + + def execute(self, command, context, *, etag_override=None): + return self._command_result_factory(command) + + def create_namespace(self, path, metadata, context): + return NamespaceContext(id=uuid4(), path=path) + + def update_namespace(self, namespace_id, expected_revision, metadata, status, context, *, etag=None): + return _namespace(namespace_id, revision=expected_revision + 1) + + def list_child_namespaces(self, parent_path): + return [] + + def get_namespace_context(self, path): + return NamespaceContext(id=uuid4(), path=path) + + +class _LocalFakeBackend(_FakeBackendBase): + """Local-shaped fake: adapter returns already-hydrated domain models.""" + + def query(self, schema, spec, *, namespace_path): + self.query_calls += 1 + return [self._result_factory(schema, self.query_calls, namespace_path)] + + +class _RESTFakeBackend(_FakeBackendBase): + """REST-shaped fake: adapter returns models after a wire round trip.""" + + def query(self, schema, spec, *, namespace_path): + self.query_calls += 1 + result = self._result_factory(schema, self.query_calls, namespace_path) + return [schema.model_validate(result.model_dump(mode="json"))] + + +def _client_backend(reader): + return ClientBackend( + reader=reader, + writer=reader, + namespaces=reader, + namespace_writer=reader, + context_resolver=reader, + ) + + +@pytest.fixture(params=[_LocalFakeBackend, _RESTFakeBackend], ids=["local", "rest"]) +def fake_backend(request): + """Exercise client boundary with distinct local and REST-shaped readers.""" + namespace_id = uuid4() + + def result_factory(schema, _call, _namespace_path): + assert schema is NamespaceSchema + return _namespace(namespace_id) + + return _client_backend(request.param(result_factory)) + + +def test_repeated_local_and_rest_queries_return_same_canonical_object(fake_backend): + first = fake_backend.query(NamespaceSchema, object(), namespace_path="beamline")[0] + second = fake_backend.query(NamespaceSchema, object(), namespace_path="beamline")[0] + + assert first is second + + +def test_nested_resource_and_direct_resource_query_share_identity(): + parent_id = uuid4() + child_id = uuid4() + direct_child = ResourceRef.model_construct( + id=child_id, + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 1), + namespace_id=uuid4(), + status=LifecycleStatus.ACTIVE, + revision=1, + name="child", + template=ResourceTemplateRef.model_construct( + id=uuid4(), + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 1), + namespace_id=uuid4(), + status=LifecycleStatus.ACTIVE, + revision=1, + name="Sample", + version="1.0", + ), + ) + + def result_factory(schema, call, _namespace_path): + assert schema is ResourceSchema + if call == 1: + return _resource(parent_id, child=direct_child) + return _resource(child_id, name="child", namespace_id=direct_child.namespace_id) + + backend = _client_backend(_LocalFakeBackend(result_factory)) + nested = backend.query(ResourceSchema, object(), namespace_path="beamline")[0].children["child"] + direct = backend.query(ResourceSchema, object(), namespace_path="beamline")[0] + + assert nested is direct + + +def test_command_result_updates_object_from_earlier_query(): + namespace_id = uuid4() + + def result_factory(schema, _call, _namespace_path): + assert schema is NamespaceSchema + return _namespace(namespace_id, revision=1) + + def command_result(command): + assert isinstance(command, UpdateNamespace) + return _namespace( + namespace_id, + revision=2, + path="beamline/updated", + status=LifecycleStatus.ARCHIVED, + ) + + backend = _client_backend(_LocalFakeBackend(result_factory, command_result)) + held = backend.query(NamespaceSchema, object(), namespace_path="beamline")[0] + + result = backend._execute( + UpdateNamespace( + namespace_id=namespace_id, + expected_revision=1, + metadata={"owner": "science"}, + status=None, + ), + build_local_command_context(), + ) + + assert result is held + assert held.revision == 2 + assert held.path == "beamline/updated" + assert held.status is LifecycleStatus.ARCHIVED + + +def test_namespace_scoped_view_shares_root_identity(): + namespace_id = uuid4() + + def result_factory(schema, _call, namespace_path): + assert schema is NamespaceSchema + return _namespace(namespace_id) + + reader = _LocalFakeBackend(result_factory) + backend = _client_backend(reader) + root = RecapClient._from_backends(backend, namespace="beamline") + scoped = root.namespace("amx") + + try: + root_result = root.query_maker().namespaces().all()[0] + scoped_result = scoped.query_maker().namespaces().all()[0] + finally: + scoped.close() + root.close() + + assert root_result is scoped_result diff --git a/recap/tests/test_builder_command_count.py b/recap/tests/test_builder_command_count.py new file mode 100644 index 0000000..8384076 --- /dev/null +++ b/recap/tests/test_builder_command_count.py @@ -0,0 +1,112 @@ +from types import SimpleNamespace +from uuid import uuid4 + +from recap.client.backend import ClientBackend +from recap.commands.models import UpdateProcessTemplate +from recap.dsl.process_builder import ProcessTemplateBuilder +from recap.schemas.namespace import NamespaceContext + + +class RecordingBackend: + def __init__(self, existing): + self.existing = existing + self.commands = [] + + def get_process_template(self, *args, **kwargs): + return self.existing + + def query(self, schema, spec, *, namespace_path): + return [self.existing] if "id" in spec.filters else [] + + def execute(self, command, context): + self.commands.append(command) + values = vars(self.existing).copy() + values["revision"] += 1 + self.existing = SimpleNamespace(**values) + return self.existing + + def count(self, *args, **kwargs): + return 0 + + def list_child_namespaces(self, parent_path): + return [] + + def create_namespace(self, path, metadata, context): + return None + + def update_namespace( + self, namespace_id, expected_revision, metadata, status, context, *, etag=None + ): + return None + + def get_namespace_context(self, path: str) -> NamespaceContext: + return NamespaceContext(id=uuid4(), path=path) + + +def client_backend(adapter): + return ClientBackend( + reader=adapter, + writer=adapter, + namespaces=adapter, + namespace_writer=adapter, + context_resolver=adapter, + ) + + +def test_clean_context_flushes_latest_draft_once(): + context = object() + existing = SimpleNamespace( + id=uuid4(), + name="repeat", + version="1.0", + revision=3, + labels=[], + resource_slots=[], + step_templates={}, + ) + backend = RecordingBackend(existing) + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + builder = ProcessTemplateBuilder( + backend=client_backend(backend), + namespace_context=namespace_context, + name=None, + version=None, + process_template_id=existing.id, + command_context=context, + ) + + with builder: + builder.add_resource_slot("input", "container", "input") + + assert len(backend.commands) == 1 + assert all( + isinstance(command, UpdateProcessTemplate) for command in backend.commands + ) + assert backend.commands[0].expected_revision == 3 + + +def test_clean_repeated_save_does_not_duplicate_command(): + existing = SimpleNamespace( + id=uuid4(), + name="clean", + version="1.0", + revision=3, + labels=[], + resource_slots=[], + step_templates={}, + ) + backend = RecordingBackend(existing) + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + builder = ProcessTemplateBuilder( + backend=client_backend(backend), + namespace_context=namespace_context, + name=None, + version=None, + process_template_id=existing.id, + command_context=object(), + ) + + with builder: + pass + + assert len(backend.commands) == 0 diff --git a/recap/tests/test_builder_identity.py b/recap/tests/test_builder_identity.py new file mode 100644 index 0000000..fd15597 --- /dev/null +++ b/recap/tests/test_builder_identity.py @@ -0,0 +1,69 @@ +from uuid import uuid4 + +import pytest + + +def _resource_client(client): + client.create_namespace("builder-identity") + return client + + +def _make_resource(client): + scoped = _resource_client(client) + with scoped.build_resource_template( + name="IdentityRT", type_names=["container"] + ) as template: + template.prop_group("details").add_attribute( + "serial", "str", "", "old" + ).close_group() + return scoped, scoped.create_resource("identity-resource", "IdentityRT") + + +def test_resource_exception_discards_detached_draft(client): + scoped, resource = _make_resource(client) + + with ( + pytest.raises(RuntimeError, match="abort"), + scoped.build_resource(resource_id=resource.id) as builder, + ): + draft = builder.get_model() + draft.name = "unsaved" + builder.set_model(draft) + raise RuntimeError("abort") + + assert resource.name == "identity-resource" + + +def test_resource_save_merges_into_held_canonical_reference(client): + scoped, resource = _make_resource(client) + + with scoped.build_resource(resource_id=resource.id) as builder: + draft = builder.get_model() + draft.properties.details.values.serial.value = "saved" + builder.set_model(draft) + + assert resource.properties.details.values.serial.value == "saved" + + +def test_resource_set_model_rejects_mismatched_uuid(client): + scoped, resource = _make_resource(client) + + with scoped.build_resource(resource_id=resource.id) as builder: + draft = builder.get_model() + draft = draft.model_copy(update={"id": uuid4()}) + with pytest.raises(ValueError, match="ID"): + builder.set_model(draft) + + +def test_new_resource_get_model_is_copy_on_write(client): + scoped = _resource_client(client) + with scoped.build_resource_template(name="CopyRT", type_names=["container"]): + pass + + builder = scoped.build_resource("copy-resource", "CopyRT") + draft = builder.get_model() + with builder: + pass + assert draft.id == builder.resource.id + draft.name = "changed" + assert builder.resource.name == "copy-resource" diff --git a/recap/tests/test_builders_idempotent.py b/recap/tests/test_builders_idempotent.py index c0541e1..6358052 100644 --- a/recap/tests/test_builders_idempotent.py +++ b/recap/tests/test_builders_idempotent.py @@ -2,8 +2,7 @@ import pytest -from recap.dsl.process_builder import ProcessTemplateBuilder -from recap.dsl.resource_builder import ResourceTemplateBuilder +from recap.commands.models import UpdateResource, UpdateResourceTemplate from recap.exceptions import ( ExistingProcessRunError, ExistingProcessRunWarning, @@ -14,13 +13,14 @@ ExistingResourceTemplateWarning, ExistingResourceWarning, ) +from recap.schemas.resource import ResourceSchema from recap.utils.general import Direction def test_resource_builder_reuse_same_resource(client): # create once - with ResourceTemplateBuilder( - name="RB-Template", type_names=["container"], backend=client.backend + with client.build_resource_template( + name="RB-Template", type_names=["container"] ) as rtb: rtb.prop_group("details").add_attribute( "serial", "str", "", "abc" @@ -31,64 +31,103 @@ def test_resource_builder_reuse_same_resource(client): with client.build_resource(resource_id=rb.resource.id) as rb2: rb2.resource.properties["details"].values["serial"] = "xyz2" - refreshed = ( - client.query_maker() - .resources() - .filter(name="RB-1") - .include(["template", "properties"]) - .first() - ) + with client.build_resource(resource_id=rb.resource.id) as verifier: + refreshed = verifier.get_model(update=True) assert refreshed.properties.details.values.serial.value == "xyz2" +def test_reused_resource_clean_exit_skips_update_and_mutation_updates( + client, monkeypatch +): + with client.build_resource_template( + name="RB-Command-Template", type_names=["container"] + ) as template: + template.prop_group("properties").add_attribute( + "serial", "str", "", "" + ).close_group() + client.create_resource("RB-Command-1", "RB-Command-Template") + commands = [] + execute = client.connection_state.backend.writer.execute + + def recording_execute(command, context): + commands.append(command) + return execute(command, context) + + monkeypatch.setattr( + client.connection_state.backend.writer, "execute", recording_execute + ) + with client.build_resource( + "RB-Command-1", "RB-Command-Template", on_existing="silent" + ): + pass + assert not any(isinstance(command, UpdateResource) for command in commands) + + commands.clear() + with client.build_resource( + "RB-Command-1", "RB-Command-Template", on_existing="silent" + ) as builder: + builder.resource.properties["properties"].values["serial"] = "mutated" + assert sum(isinstance(command, UpdateResource) for command in commands) == 1 + + def test_resource_template_builder_reuse_same_template(client): - with ResourceTemplateBuilder( - name="RTB", type_names=["container"], backend=client.backend - ) as rtb: + with client.build_resource_template(name="RTB", type_names=["container"]) as rtb: rtb.prop_group("meta").add_attribute("foo", "str", "", "").close_group() # reopen same template by ref and add another attribute - existing = client.query_maker().resource_templates().filter(name="RTB").first() - with ResourceTemplateBuilder( - name="RTB", - type_names=["container"], - backend=client.backend, - resource_template_id=existing.id, - ) as rtb2: + existing = rtb.template + with client.build_resource_template(resource_template_id=existing.id) as rtb2: rtb2.prop_group("meta").add_attribute("bar", "str", "", "").close_group() - refreshed = client.query_maker().resource_templates().filter(name="RTB").first() + refreshed = rtb2.get_model(update=True) fields = { a.name for a in refreshed.attribute_group_templates[0].attribute_templates } assert {"foo", "bar"} == fields +def test_reused_resource_template_clean_exit_skips_update_and_mutation_updates( + client, monkeypatch +): + with client.build_resource_template( + name="RTB-Command", type_names=["container"] + ) as created: + pass + existing = created.template + commands = [] + execute = client.connection_state.backend.writer.execute + + def recording_execute(command, context): + commands.append(command) + return execute(command, context) + + monkeypatch.setattr( + client.connection_state.backend.writer, "execute", recording_execute + ) + with client.build_resource_template(resource_template_id=existing.id): + pass + assert not any(isinstance(command, UpdateResourceTemplate) for command in commands) + + commands.clear() + with client.build_resource_template(resource_template_id=existing.id) as builder: + builder.prop_group("new").add_attribute("value", "str", "", "").close_group() + assert sum(isinstance(command, UpdateResourceTemplate) for command in commands) == 1 + + def test_process_builder_reuse_same_run(client): - with ProcessTemplateBuilder( - backend=client.backend, name="PTB", version="1.0" - ) as ptb: + with client.build_process_template("PTB", "1.0") as ptb: ptb.add_resource_slot( "slot1", "container", Direction.input, create_resource_type=True ).add_step("S1").param_group("pg").add_attribute( "v", "int", "", 1 ).close_group().close_step() - client.create_campaign("C-reuse", "P-reuse") with client.build_process_run( name="run-reuse", description="desc", template_name="PTB", version="1.0", - ) as _: - pass - - run = ( - client.query_maker() - .process_runs() - .filter(name="run-reuse") - .include_steps(include_parameters=True) - .first() - ) + ) as created: + run_id = created.process_run.id # Create a resource that satisfies the slot and assign it with client.build_resource_template( name="ContainerRT", type_names=["container"] @@ -96,19 +135,14 @@ def test_process_builder_reuse_same_run(client): pass container_res = client.create_resource("SlotRes", "ContainerRT") - with client.build_process_run(process_run_id=run.id) as prb2: + with client.build_process_run(process_run_id=run_id) as prb2: prb2.assign_resource("slot1", container_res) params = prb2.get_params("S1") params.pg.v = 5 prb2.set_params(params) - refreshed = ( - client.query_maker() - .process_runs() - .filter(name="run-reuse") - .include_steps(include_parameters=True) - .first() - ) + with client.build_process_run(process_run_id=run_id) as verifier: + refreshed = verifier.process_run assert refreshed.steps["S1"].parameters.pg.values.v.value == 5 @@ -122,33 +156,21 @@ def test_set_params_persists_after_reopen_by_id(client): 4. Exit context (commit). 5. Load in a fresh query — values must match what was set. """ - with ProcessTemplateBuilder( - backend=client.backend, name="PT-I4", version="1.0" - ) as ptb: + with client.build_process_template("PT-I4", "1.0") as ptb: ptb.add_resource_slot( "src", "container", Direction.input, create_resource_type=True ).add_step("Step-A").param_group("grp").add_attribute( "x", "int", "", 0 ).add_attribute("y", "str", "", "init").close_group().close_step() - client.create_campaign("C-I4", "P-I4") - # 1. Create process run by name with client.build_process_run( name="run-i4", description="issue4", template_name="PT-I4", version="1.0", - ) as _: - pass - - run = ( - client.query_maker() - .process_runs() - .filter(name="run-i4") - .include_steps(include_parameters=True) - .first() - ) + ) as created: + run_id = created.process_run.id # Assign required resource with client.build_resource_template( @@ -158,7 +180,7 @@ def test_set_params_persists_after_reopen_by_id(client): res = client.create_resource("Res-I4", "ContainerRT-I4") # 2-3. Re-open by ID, set_params - with client.build_process_run(process_run_id=run.id) as prb: + with client.build_process_run(process_run_id=run_id) as prb: prb.assign_resource("src", res) params = prb.get_params("Step-A") params.grp.x = 42 @@ -167,13 +189,8 @@ def test_set_params_persists_after_reopen_by_id(client): # 4. Context exited → committed # 5. Verify persistence in fresh query - fresh = ( - client.query_maker() - .process_runs() - .filter(name="run-i4") - .include_steps(include_parameters=True) - .first() - ) + with client.build_process_run(process_run_id=run_id) as verifier: + fresh = verifier.process_run assert fresh.steps["Step-A"].parameters.grp.values.x.value == 42 assert fresh.steps["Step-A"].parameters.grp.values.y.value == "updated" @@ -216,7 +233,8 @@ def test_process_template_builder_reuses_existing_with_warning(client): ): ptb.add_step("step-2") - refreshed = client.query_maker().process_templates().filter(name="ReusePT").first() + with client.build_process_template("ReusePT", "1.0", on_existing="silent") as ptb: + refreshed = ptb.get_model(update=True) assert refreshed is not None @@ -247,6 +265,37 @@ def test_resource_builder_reuses_existing_with_warning(client): client.build_resource("ReuseRes", "ReuseResTemplate") as rb, ): assert rb.resource.id == first.id + assert rb._draft.id == first.id + + +def test_resource_builder_reuse_handles_unloaded_root_parent(client, monkeypatch): + _make_simple_template(client, name="UnloadedParentTemplate") + first = client.create_resource("UnloadedParent", "UnloadedParentTemplate") + backend = client.connection_state.backend + backend.identity_map.clear() + query = backend.query + + def query_with_unloaded_parent(schema, spec, *, namespace_path): + matches = query(schema, spec, namespace_path=namespace_path) + if schema is ResourceSchema: + for match in matches: + loaded = { + relation: match.is_loaded(relation) + for relation in ("template", "parent", "children", "properties") + } + loaded["parent"] = False + match.set_loaded_relations(loaded, on_unloaded="raise") + return matches + + monkeypatch.setattr( + type(backend), + "query", + lambda self, *args, **kwargs: query_with_unloaded_parent(*args, **kwargs), + ) + with client.build_resource( + "UnloadedParent", "UnloadedParentTemplate", on_existing="silent" + ) as reused: + assert reused.resource.id == first.id def test_resource_builder_on_existing_raise_raises(client): @@ -277,7 +326,6 @@ def test_process_run_builder_reuses_existing_with_warning(client): "slot1", "container", Direction.input, create_resource_type=True ).add_step("S1") - client.create_campaign("ReuseCamp", "ReuseProposal") with client.build_process_run("ReuseRun", "desc", "ReuseRunPT", "1.0") as _: pass @@ -286,13 +334,15 @@ def test_process_run_builder_reuses_existing_with_warning(client): client.build_process_run("ReuseRun", "desc", "ReuseRunPT", "1.0") as prb, ): assert prb.process_run.name == "ReuseRun" + assert prb.process_run.id == prb._process_run.id + assert prb._draft_process_run.id == prb._process_run.id + assert [step.name for step in prb._steps] == ["S1"] def test_process_run_builder_on_existing_raise_raises(client): with client.build_process_template("StrictRunPT", "1.0") as ptb: ptb.add_step("S1") - client.create_campaign("StrictCamp", "StrictProposal") with client.build_process_run("StrictRun", "desc", "StrictRunPT", "1.0") as _: pass @@ -347,9 +397,8 @@ def register_template(): # Second registration — must not raise register_template() - refreshed = ( - client.query_maker().process_templates().filter(name="IdempotPT").first() - ) + with client.build_process_template("IdempotPT", "1.0", on_existing="silent") as ptb: + refreshed = ptb.get_model(update=True) assert refreshed is not None step_names = list(refreshed.step_templates.keys()) assert step_names == ["Collect"] @@ -369,7 +418,8 @@ def test_process_template_silent_reuse_adds_new_step(client): ptb.add_step("Step1") # existing — idempotent ptb.add_step("Step2") # new step - refreshed = client.query_maker().process_templates().filter(name="GrowPT").first() + with client.build_process_template("GrowPT", "1.0", on_existing="silent") as ptb: + refreshed = ptb.get_model(update=True) step_names = sorted(refreshed.step_templates.keys()) assert step_names == ["Step1", "Step2"] @@ -391,7 +441,8 @@ def test_process_template_silent_reuse_bind_slot_idempotent(client): step = ptb.add_step("Process") step.bind_slot("input_data", "data") - refreshed = client.query_maker().process_templates().filter(name="BindPT").first() + with client.build_process_template("BindPT", "1.0", on_existing="silent") as ptb: + refreshed = ptb.get_model(update=True) assert refreshed is not None @@ -423,9 +474,7 @@ def test_slot_conflict_or_predicate(client): def _make_simple_template(client, name="OnExist-T"): """Helper: create a minimal resource template for on_existing tests.""" - with ResourceTemplateBuilder( - name=name, type_names=["container"], backend=client.backend - ) as rtb: + with client.build_resource_template(name=name, type_names=["container"]) as rtb: rtb.prop_group("info").add_attribute("val", "str", "", "default").close_group() return rtb @@ -524,13 +573,8 @@ def test_build_resource_with_parent_schema(client): ): pass # Verify child is linked - refreshed = ( - client.query_maker() - .resources() - .filter(name="Parent-1") - .include(["children"]) - .first() - ) + with client.build_resource(resource_id=parent.id) as parent_builder: + refreshed = parent_builder.get_model(update=True) assert "Child-1" in refreshed.children @@ -543,13 +587,8 @@ def test_build_resource_with_parent_uuid(client): "Child-2", "ChildT2", parent=parent.id, on_existing="create" ): pass - refreshed = ( - client.query_maker() - .resources() - .filter(name="Parent-2") - .include(["children"]) - .first() - ) + with client.build_resource(resource_id=parent.id) as parent_builder: + refreshed = parent_builder.get_model(update=True) assert "Child-2" in refreshed.children @@ -589,8 +628,6 @@ def test_optional_output_slot_does_not_block_get_params(client): .close_step() ) - client.create_campaign("C-Opt", "P-Opt") - with client.build_resource_template( name="ContainerRT-Opt", type_names=["container"] ) as _: @@ -627,8 +664,6 @@ def test_all_required_slots_preserves_old_behavior(client): .close_step() ) - client.create_campaign("C-AllReq", "P-AllReq") - with client.build_resource_template( name="ContainerRT-AllReq", type_names=["container"] ) as _: @@ -672,8 +707,6 @@ def test_optional_slot_can_be_assigned_later(client): .close_step() ) - client.create_campaign("C-Late", "P-Late") - with client.build_resource_template( name="ContainerRT-Late", type_names=["container"] ) as _: @@ -691,62 +724,15 @@ def test_optional_slot_can_be_assigned_later(client): prb.assign_resource("src", src_res) params = prb.get_params("Move") assert params.mv.count.value == 0 + run_id = prb.process_run.id # Re-open and assign optional slot too - run = ( - client.query_maker() - .process_runs() - .filter(name="run-late") - .include_steps(include_parameters=True) - .first() - ) - with client.build_process_run(process_run_id=run.id) as prb2: + with client.build_process_run(process_run_id=run_id) as prb2: prb2.assign_resource("dest", dest_res) params = prb2.get_params("Move") params.mv.count = 42 prb2.set_params(params) - fresh = ( - client.query_maker() - .process_runs() - .filter(name="run-late") - .include_steps(include_parameters=True) - .first() - ) + with client.build_process_run(process_run_id=run_id) as verifier: + fresh = verifier.process_run assert fresh.steps["Move"].parameters.mv.values.count.value == 42 - - -# --------------------------------------------------------------------------- -# Issue #6: unscoped query_maker -# --------------------------------------------------------------------------- - - -def test_query_maker_unscoped_returns_cross_campaign(client): - """unscoped=True bypasses the active campaign filter.""" - # Create template - with client.build_resource_template( - name="UnscopedT", type_names=["container"] - ) as _: - pass - - # Campaign 1 - client.create_campaign("Camp-A", "PA") - client.create_resource("Res-Camp-A", "UnscopedT", on_existing="create") - - # Campaign 2 - client.create_campaign("Camp-B", "PB") - client.create_resource("Res-Camp-B", "UnscopedT", on_existing="create") - - # Unscoped should find both resources - unscoped_results = client.query_maker(unscoped=True).resources().all() - unscoped_names = {r.name for r in unscoped_results} - assert "Res-Camp-A" in unscoped_names - assert "Res-Camp-B" in unscoped_names - - -def test_query_maker_unscoped_with_campaign_raises(client): - """Combining campaign with unscoped=True raises ValueError.""" - from uuid import uuid4 - - with pytest.raises(ValueError, match="Cannot combine"): - client.query_maker(campaign=uuid4(), unscoped=True) diff --git a/recap/tests/test_builders_models.py b/recap/tests/test_builders_models.py index 9a6fe8b..d0a4e2e 100644 --- a/recap/tests/test_builders_models.py +++ b/recap/tests/test_builders_models.py @@ -1,5 +1,37 @@ +from uuid import uuid4 + import pytest +from recap.lifecycle import LifecycleStatus + + +def test_resource_context_exception_keeps_property_draft_for_retry(client): + with client.build_resource_template(name="RBM-retry-T", type_names=["container"]) as template: + template.prop_group("details").add_attribute("serial", "str", "", "initial").close_group() + builder = client.build_resource("RBM-retry-R", "RBM-retry-T") + with pytest.raises(RuntimeError): + with builder: + builder.resource.properties.details.values.serial.value = "retry" + builder.finalize() + raise RuntimeError("stop") + + assert builder.changes().fields["properties"]["details"]["serial"]["value"] == "retry" + assert builder.changes().lifecycle is LifecycleStatus.ACTIVE + with builder: + pass + assert builder.resource.status is LifecycleStatus.ACTIVE + assert builder.resource.properties.details.values.serial.value == "retry" + + +def test_resource_finalization_saves_draft_before_copy(client): + with client.build_resource_template(name="RBM-finalize-T", type_names=["container"]) as template: + template.prop_group("details").add_attribute("serial", "str", "", "initial").close_group() + with client.build_resource("RBM-finalize-R", "RBM-finalize-T") as builder: + builder.resource.properties.details.values.serial.value = "new" + builder.finalize() + assert builder.resource.status is LifecycleStatus.ACTIVE + assert builder.resource.properties.details.values.serial.value == "new" + def test_resource_builder_set_model_updates_persisted(client): with client.build_resource_template(name="RBM-T", type_names=["container"]) as rtb: @@ -11,14 +43,10 @@ def test_resource_builder_set_model_updates_persisted(client): model = rb.get_model() model.properties.details.values.serial.value = "updated" rb.set_model(model) + resource_id = rb.resource.id - refreshed = ( - client.query_maker() - .resources() - .filter(name="RBM-R") - .include(["template", "properties"]) - .first() - ) + with client.build_resource(resource_id=resource_id) as builder: + refreshed = builder.get_model(update=True) assert refreshed.properties.details.values.serial.value == "updated" @@ -28,15 +56,9 @@ def test_resource_builder_set_model_rejects_mismatch(client): "serial", "str", "", "abc" ).close_group() res1 = client.create_resource("RBM-R1", "RBM-T2") - client.create_resource("RBM-R2", "RBM-T2") - - res2_model = ( - client.query_maker() - .resources() - .filter(name="RBM-R2") - .include_template() - .first() - ) + res2 = client.create_resource("RBM-R2", "RBM-T2") + with client.build_resource(resource_id=res2.id) as builder: + res2_model = builder.get_model(update=True) def set_mismatched_model(): with client.build_resource(resource_id=res1.id) as rb: @@ -52,8 +74,8 @@ def test_resource_template_builder_set_model_handles_same_and_mismatch(client): with client.build_resource_template(name="RTM-2", type_names=["container"]) as rtb2: rtb2.prop_group("g").add_attribute("b", "str", "", "").close_group() - rt1 = client.query_maker().resource_templates().filter(name="RTM-1").first() - rt2 = client.query_maker().resource_templates().filter(name="RTM-2").first() + rt1 = rtb1.template + rt2 = rtb2.template def set_mismatched_model(): with client.build_resource_template(resource_template_id=rt1.id) as builder: @@ -65,14 +87,31 @@ def set_mismatched_model(): set_mismatched_model() +def test_resource_template_builder_set_model_persists_detached_edits(client): + with client.build_resource_template(name="RTM-edit", type_names=["container"]) as builder: + builder.prop_group("details").add_attribute("serial", "str", "", "old").close_group() + template_id = builder.template.id + + with client.build_resource_template(resource_template_id=template_id) as builder: + model = builder.get_model(update=True) + model.name = "RTM-edited" + model.attribute_group_templates[0].attribute_templates[0].default_value = "new" + builder.set_model(model) + + with client.build_resource_template(resource_template_id=template_id) as builder: + refreshed = builder.get_model(update=True) + assert refreshed.name == "RTM-edited" + assert refreshed.attribute_group_templates[0].attribute_templates[0].default_value == "new" + + def test_process_template_builder_set_model_handles_mismatch(client): with client.build_process_template("PTM-1", "1.0") as ptb1: ptb1.add_step("A") with client.build_process_template("PTM-2", "1.0") as ptb2: ptb2.add_step("B") - pt1 = client.query_maker().process_templates().filter(name="PTM-1").first() - pt2 = client.query_maker().process_templates().filter(name="PTM-2").first() + pt1 = ptb1.template + pt2 = ptb2.template def set_mismatched_model(): with client.build_process_template(process_template_id=pt1.id) as builder: @@ -84,33 +123,42 @@ def set_mismatched_model(): set_mismatched_model() +def test_process_template_builder_set_model_persists_detached_edits(client): + with client.build_process_template("PTM-edit", "1.0") as builder: + builder.add_step("A").param_group("Inputs").add_attribute("Voltage", "int", "", 0).close_group() + template_id = builder.template.id + + with client.build_process_template(process_template_id=template_id) as builder: + model = builder.get_model(update=True) + model.name = "PTM-edited" + model.step_templates["A"].attribute_group_templates[0].attribute_templates[0].default_value = 42 + builder.set_model(model) + + with client.build_process_template(process_template_id=template_id) as builder: + refreshed = builder.get_model(update=True) + assert refreshed.name == "PTM-edited" + assert refreshed.step_templates["A"].attribute_group_templates[0].attribute_templates[0].default_value == 42 + + def test_process_run_builder_set_model_handles_mismatch(client): with client.build_process_template("PTM-R", "1.0") as ptb: ptb.add_step("S") - client.create_campaign("C-M1", "P-M1") with client.build_process_run( name="RUN-1", description="d1", template_name="PTM-R", version="1.0", - ) as _: - pass + ) as builder: + run1_model = builder.process_run with client.build_process_run( name="RUN-2", description="d2", template_name="PTM-R", version="1.0", - ) as _: - pass - - run1_model = ( - client.query_maker().process_runs().filter(name="RUN-1").include_steps().first() - ) - run2_model = ( - client.query_maker().process_runs().filter(name="RUN-2").include_steps().first() - ) + ) as builder: + run2_model = builder.process_run def set_mismatched_model(): with client.build_process_run(process_run_id=run1_model.id) as builder: @@ -122,7 +170,7 @@ def set_mismatched_model(): set_mismatched_model() -def test_resource_builder_add_child_persists_and_links(client): +def test_resource_builder_add_child_persists_and_links(client, recwarn): """ResourceBuilder.add_child() creates a child resource in the parent's unit of work and links it to the parent. The parent's __exit__ commits both. @@ -140,23 +188,203 @@ def test_resource_builder_add_child_persists_and_links(client): with client.build_resource("AC-Root", "AC-Parent") as rb: rb.add_child("AC-Leaf", "AC-Child") + root_id = rb.resource.id - parent = ( - client.query_maker() - .resources() - .filter(name="AC-Root") - .include(["children", "template"]) - .first() - ) - child = ( - client.query_maker() - .resources() - .filter(name="AC-Leaf") - .include(["template"]) - .first() - ) + with client.build_resource(resource_id=root_id) as builder: + parent = builder.get_model(update=True) + child = parent.children["AC-Leaf"] assert child is not None assert child.template.name == "AC-Child" assert "AC-Leaf" in parent.children assert parent.children["AC-Leaf"].id == child.id + assert not [warning for warning in recwarn if warning.category.__name__ == "SAWarning"] + + +def test_resource_builder_add_child_accepts_keyword_name(client): + with client.build_resource_template( + name="AC-Keyword-Parent", type_names=["container"] + ): + pass + with client.build_resource_template( + name="AC-Keyword-Child", type_names=["sample"] + ): + pass + + with client.build_resource("AC-Keyword-Root", "AC-Keyword-Parent") as builder: + child = builder.add_child( + name="AC-Keyword-Leaf", template_name="AC-Keyword-Child" + ) + + assert child.resource.name == "AC-Keyword-Leaf" + + +def test_resource_builder_add_child_accepts_keyword_source(client): + with client.build_resource_template( + name="AC-Source-Parent", type_names=["container"] + ): + pass + with client.build_resource_template( + name="AC-Source-Child", type_names=["sample"] + ): + pass + source = client.create_resource("AC-Source-Leaf", "AC-Source-Child") + with client.build_resource(resource_id=source.id) as source_builder: + source_model = source_builder.get_model(update=True) + with client.build_resource("AC-Source-Root", "AC-Source-Parent") as parent_builder: + copied = parent_builder.add_child(source=source_model) + + assert copied.resource.copied_from_id == source.id + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"name": "child", "source": uuid4()}, + {"source": uuid4(), "template_name": "Child"}, + {"name": "child"}, + ], +) +def test_resource_builder_add_child_rejects_invalid_keyword_combinations( + client, kwargs +): + with client.build_resource_template( + name="AC-Invalid-Parent", type_names=["container"] + ): + pass + + with client.build_resource("AC-Invalid-Root", "AC-Invalid-Parent") as builder: + with pytest.raises(TypeError): + builder.add_child(**kwargs) + + +def test_resource_builder_add_child_copies_uuid_source(client): + with client.build_resource_template(name="AC-UUID-Group", type_names=["group"]): + pass + with client.build_resource_template(name="AC-UUID-Leaf", type_names=["leaf"]): + pass + + group = client.create_resource("AC-UUID-Group-1", "AC-UUID-Group") + source = client.create_resource("AC-UUID-Source", "AC-UUID-Leaf") + + with client.build_resource(resource_id=group.id) as builder: + child_builder = builder.add_child(source.id) + copied_id = child_builder.resource.id + + assert child_builder.parent is builder + assert copied_id != source.id + assert child_builder.resource.copied_from_id == source.id + assert child_builder.resource.template.id == source.template.id + + with client.build_resource(resource_id=group.id) as builder: + refreshed = builder.get_model(update=True) + assert refreshed.children[source.name].id == child_builder.resource.id + assert refreshed.children[source.name].copied_from_id == source.id + + +def test_resource_builder_add_child_copies_resource_schema_source(client): + with client.build_resource_template(name="AC-Schema-Group", type_names=["group"]): + pass + with client.build_resource_template(name="AC-Schema-Leaf", type_names=["leaf"]): + pass + + group = client.create_resource("AC-Schema-Group-1", "AC-Schema-Group") + source = client.create_resource("AC-Schema-Source", "AC-Schema-Leaf") + + with client.build_resource(resource_id=group.id) as builder: + child_builder = builder.add_child(source) + + assert child_builder.parent is builder + assert child_builder.resource.id != source.id + assert child_builder.resource.copied_from_id == source.id + assert child_builder.resource.template.id == source.template.id + + with client.build_resource(resource_id=group.id) as builder: + refreshed = builder.get_model(update=True) + assert refreshed.children[source.name].id == child_builder.resource.id + assert refreshed.children[source.name].copied_from_id == source.id + + +def test_resource_copy_child_keeps_existing_and_provisional_children(client): + with client.build_resource_template(name="Merge-Group", type_names=["group"]): + pass + with client.build_resource_template(name="Merge-Leaf", type_names=["leaf"]): + pass + + group = client.create_resource("Merge-group", "Merge-Group") + with client.build_resource(resource_id=group.id) as builder: + existing_builder = builder.add_child("Merge-existing", "Merge-Leaf") + existing = existing_builder.resource + source = client.create_resource("Merge-source", "Merge-Leaf") + with client.build_resource(resource_id=source.id) as source_builder: + source_child_builder = source_builder.add_child( + "Merge-descendant", "Merge-Leaf" + ) + source_child = source_child_builder.resource + + with client.build_resource(resource_id=group.id) as builder: + copied = builder.add_child(source) + + assert builder.resource.children[existing.name].id == existing.id + assert builder.resource.children[copied.resource.name].id == copied.resource.id + assert copied.resource.children[source_child.name].id != source_child.id + + +def test_resource_child_lifecycle_is_flushed_with_parent(client): + with client.build_resource_template(name="Lifecycle-Group", type_names=["group"]): + pass + with client.build_resource_template(name="Lifecycle-Leaf", type_names=["leaf"]): + pass + + with client.build_resource("Lifecycle-group", "Lifecycle-Group") as builder: + child = builder.add_child("Lifecycle-leaf", "Lifecycle-Leaf") + child.finalize() + + with client.build_resource(resource_id=builder.resource.id) as loader: + refreshed = loader.get_model(update=True) + assert refreshed.children[child.resource.name].status.value == "ACTIVE" + + +def test_resource_copy_child_rejects_missing_source_schema(client, monkeypatch): + with client.build_resource_template(name="Invalid-Group", type_names=["group"]): + pass + group = client.create_resource("Invalid-group", "Invalid-Group") + + with client.build_resource(resource_id=group.id) as builder: + monkeypatch.setattr(builder, "_reload_resource", lambda resource_id: None) + with pytest.raises(ValueError, match="valid resource schema"): + builder.add_child(group.id) + + +def test_new_process_run_keeps_provisional_id_after_context_commit(client): + with client.build_process_template("ID PT", "1.0"): + pass + builder = client.build_process_run("ID run", "", "ID PT", "1.0") + provisional_id = builder.process_run.id + with builder: + pass + assert builder.process_run.id == provisional_id + + +def test_new_resource_keeps_provisional_id_after_context_commit(client): + with client.build_resource_template(name="ID RT", type_names=["sample"]): + pass + builder = client.build_resource("ID resource", "ID RT") + provisional_id = builder.resource.id + with builder: + pass + assert builder.resource.id == provisional_id + + +@pytest.mark.parametrize("kind", ["process", "resource"]) +def test_new_template_keeps_provisional_id_after_context_commit(client, kind): + builder = ( + client.build_process_template("ID PT", "1.0") + if kind == "process" + else client.build_resource_template(name="ID RT", type_names=["sample"]) + ) + provisional_id = builder.template.id + with builder: + pass + assert builder.template.id == provisional_id diff --git a/recap/tests/test_campaign_update.py b/recap/tests/test_campaign_update.py deleted file mode 100644 index b125adf..0000000 --- a/recap/tests/test_campaign_update.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Tests for campaign accessors and ``RecapClient.update_campaign``. - -See ``RECAP_QUERY_OPTIMIZATION.md``: ``create_campaign``/``set_campaign`` now -return the active :class:`CampaignSchema`, a public ``campaign`` read accessor -is exposed, and a new ``update_campaign`` write path persists edits (via field -kwargs or an explicit schema) while keeping the client cache consistent. -""" - -import pytest - -from recap.client.base_client import RecapClient -from recap.schemas.process import CampaignSchema - - -def test_create_campaign_returns_schema(apply_migrations, db_url): - with RecapClient(url=db_url) as client: - result = client.create_campaign("ret-create", "ret-prop") - assert isinstance(result, CampaignSchema) - assert result.name == "ret-create" - assert result.id == client._campaign.id - - -def test_create_campaign_persists_metadata(apply_migrations, db_url): - """Regression: ``create_campaign(metadata=...)`` must persist to the - ``meta_data`` column, not be silently dropped on the ORM instance.""" - with RecapClient(url=db_url) as client: - created = client.create_campaign( - "meta-camp", "meta-prop", metadata={"target": "mpro"} - ) - cid = created.id - - # In-process return value carries the metadata. - assert created.meta_data == {"target": "mpro"} - - # And it survives a real DB round-trip. - reloaded = client.set_campaign(cid, force=True) - assert reloaded.meta_data == {"target": "mpro"} - - -def test_set_campaign_returns_schema(apply_migrations, db_url): - with RecapClient(url=db_url) as client: - client.create_campaign("set-a", "set-prop-a") - id_a = client._campaign.id - client.create_campaign("set-b", "set-prop-b") - - result = client.set_campaign(id_a) - assert isinstance(result, CampaignSchema) - assert result.id == id_a - - -def test_campaign_property_returns_active(apply_migrations, db_url): - with RecapClient(url=db_url) as client: - assert client.campaign is None - client.create_campaign("prop-camp", "prop-prop") - assert client.campaign is not None - assert client.campaign.id == client._campaign.id - - -def test_update_campaign_via_kwargs_persists(apply_migrations, db_url): - with RecapClient(url=db_url) as client: - client.create_campaign("upd-kwargs", "upd-kwargs-prop") - cid = client._campaign.id - - client.update_campaign(name="upd-kwargs-new", saf="SAF-99") - - # Re-read from the DB to confirm persistence. - reloaded = client.set_campaign(cid, force=True) - assert reloaded.name == "upd-kwargs-new" - assert reloaded.saf == "SAF-99" - - -def test_update_campaign_via_schema_persists(apply_migrations, db_url): - with RecapClient(url=db_url) as client: - client.create_campaign("upd-schema", "upd-schema-prop") - cid = client._campaign.id - - edited = client.campaign.model_copy(update={"name": "upd-schema-new"}) - client.update_campaign(edited) - - reloaded = client.set_campaign(cid, force=True) - assert reloaded.name == "upd-schema-new" - - -def test_update_campaign_updates_cache(apply_migrations, db_url): - with RecapClient(url=db_url) as client: - client.create_campaign("upd-cache", "upd-cache-prop") - - client.update_campaign(name="upd-cache-new") - - assert client.campaign.name == "upd-cache-new" - - -def test_update_campaign_no_active_raises(apply_migrations, db_url): - with RecapClient(url=db_url) as client, pytest.raises(ValueError): - client.update_campaign(name="nope") - - -def test_update_campaign_unknown_kwarg_raises(apply_migrations, db_url): - with RecapClient(url=db_url) as client: - client.create_campaign("upd-bad", "upd-bad-prop") - with pytest.raises(TypeError): - client.update_campaign(bogus="x") diff --git a/recap/tests/test_canonical_models.py b/recap/tests/test_canonical_models.py new file mode 100644 index 0000000..6d8ab09 --- /dev/null +++ b/recap/tests/test_canonical_models.py @@ -0,0 +1,120 @@ +import warnings +from datetime import datetime +from uuid import uuid4 + +import pytest + +from recap.dsl.query import QueryDSL +from recap.exceptions import UnloadedFieldError +from recap.lifecycle import LifecycleStatus +from recap.schemas.attribute import ( + AttributeGroupRef, + AttributeGroupTemplate, + AttributeGroupTemplateRef, + AttributeGroupTemplateSchema, +) +from recap.schemas.namespace import NamespaceContext +from recap.schemas.process import ( + ProcessRunSchema, + ProcessTemplateSchema, +) +from recap.schemas.resource import ResourceRef, ResourceSchema, ResourceTemplateRef +from recap.schemas.step import ( + StepSchema, + StepTemplate, + StepTemplateRef, + StepTemplateSchema, +) + + +def _fields(**extra): + return { + "id": uuid4(), + "create_date": datetime(2026, 1, 1), + "modified_date": datetime(2026, 1, 1), + "namespace_id": uuid4(), + "status": LifecycleStatus.ACTIVE, + "revision": 1, + **extra, + } + + +def test_ref_names_are_canonical_models_with_unloaded_relation_defaults(): + assert ResourceRef is ResourceSchema + assert ResourceTemplateRef.__name__ == "ResourceTemplateSchema" + resource = ResourceRef.model_validate(_fields(name="sample")) + assert isinstance(resource, ResourceSchema) + assert resource.children == {} + assert resource.properties == {} + assert resource.is_loaded("children") is False + + +def test_public_template_ref_aliases_share_canonical_identity_families(): + assert AttributeGroupRef is AttributeGroupTemplateSchema + assert AttributeGroupTemplate is AttributeGroupTemplateSchema + assert AttributeGroupTemplateRef is AttributeGroupTemplateSchema + assert StepTemplate is StepTemplateSchema + assert StepTemplateRef is StepTemplateSchema + + +@pytest.mark.parametrize( + ("model", "relation"), + [ + (ResourceSchema, "template"), + (ResourceSchema, "parent"), + (ProcessRunSchema, "template"), + ], +) +def test_canonical_models_guard_unloaded_template_relations(model, relation): + value = model.model_construct() + value.set_loaded_relations({relation: False}, on_unloaded="raise") + + with pytest.raises(UnloadedFieldError): + getattr(value, relation) + + +def test_recursive_relations_use_canonical_classes(): + template = ResourceTemplateRef.model_validate( + _fields(name="sample", version="1.0", labels=[]) + ) + resource = ResourceSchema.model_validate(_fields(name="sample", template=template)) + assert isinstance(resource.template, ResourceTemplateRef) + assert ResourceTemplateRef is type(resource.template) + + +def _query(): + return QueryDSL(object(), context=NamespaceContext(id=uuid4(), path="beamline")) + + +def test_deprecated_ref_shape_and_expand_return_canonical_models(): + with warnings.catch_warnings(record=True) as seen: + warnings.simplefilter("always") + query = _query().resources(shape="ref") + assert any(item.category is DeprecationWarning for item in seen) + assert query.model is ResourceSchema + assert query._spec.load_mode == "none" + + +def test_deprecated_expand_warns_and_maps_to_eager(): + with warnings.catch_warnings(record=True) as seen: + warnings.simplefilter("always") + query = _query().resources(expand=True) + assert any(item.category is DeprecationWarning for item in seen) + assert query._spec.load_mode == "eager" + + +@pytest.mark.parametrize( + ("model", "relation"), + [ + (ResourceTemplateRef, "children"), + (ProcessTemplateSchema, "step_templates"), + (StepTemplateSchema, "attribute_group_templates"), + (StepSchema, "parameters"), + ], +) +def test_canonical_relation_models_guard_unloaded_relations(model, relation): + value = model.model_construct() + value.set_loaded_relations({relation: False}, on_unloaded="raise") + + with pytest.raises(UnloadedFieldError): + getattr(value, relation) diff --git a/recap/tests/test_client.py b/recap/tests/test_client.py index 2631ef5..38c41c4 100644 --- a/recap/tests/test_client.py +++ b/recap/tests/test_client.py @@ -1,19 +1,292 @@ from pathlib import Path from tempfile import gettempdir +from uuid import UUID, uuid4 import pytest +from recap.client.backend import ClientBackend from recap.client.base_client import RecapClient -from recap.schemas.process import CampaignSchema +from recap.commands.models import CreateNamespace, UpdateNamespace +from recap.dsl.query import QuerySpec +from recap.exceptions import RecapNotFoundError +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext +from recap.schemas.resource import ResourceRef, ResourceSchema -def test_build_process_run_requires_campaign(client): - with pytest.raises(ValueError): - client.build_process_run("run", "desc", "tmpl", "1.0") +class _Reader: + def __init__(self, result=None): + self.result = result or [] + def query(self, schema, spec, *, namespace_path): + return self.result -def test_build_resource_template_validates_type_names(apply_migrations, db_url): - with RecapClient(url=db_url) as client: + def count(self, schema, spec, *, namespace_path): + return len(self.result) + + +class _Writer: + def __init__(self, result=None): + self.commands = [] + self.result = result + + def execute(self, command, context, *, etag_override=None): + self.commands.append((command, context)) + return self.result + + +class _Namespaces: + def list_child_namespaces(self, parent_path): + return [parent_path] + + +class _NamespaceWriter: + def create_namespace(self, path, metadata, context): + return (path, metadata, context) + + def update_namespace( + self, namespace_id, expected_revision, metadata, status, context, *, etag=None + ): + return (namespace_id, expected_revision, metadata, status, context, etag) + + +class _NamespaceContextResolver: + def get_namespace_context(self, path: str): + return NamespaceContext(id=uuid4(), path=path) + + +class _ClosableFake: + def __init__(self): + self.close_count = 0 + + def query(self, schema, spec, *, namespace_path): + return [] + + def count(self, schema, spec, *, namespace_path): + return 0 + + def execute(self, command, context): + return None + + def list_child_namespaces(self, parent_path): + return [] + + def create_namespace(self, path, metadata, context): + return None + + def update_namespace( + self, namespace_id, expected_revision, metadata, status, context, *, etag=None + ): + return None + + def close(self): + self.close_count += 1 + + +def _backend_values(): + return { + "reader": _Reader(), + "writer": _Writer(), + "namespaces": _Namespaces(), + "namespace_writer": _NamespaceWriter(), + "context_resolver": _NamespaceContextResolver(), + } + + +def test_client_backend_accepts_distinct_capability_objects(): + values = _backend_values() + backend = ClientBackend(**values) + + assert backend.reader is values["reader"] + assert backend.writer is values["writer"] + assert backend.namespaces is values["namespaces"] + assert backend.namespace_writer is values["namespace_writer"] + + +def test_client_backend_delegates_query_count_and_execute(): + reader = _Reader() + reader.result = ["read"] + writer = _Writer("written") + backend = ClientBackend( + reader=reader, + writer=writer, + namespaces=_Namespaces(), + namespace_writer=_NamespaceWriter(), + context_resolver=_NamespaceContextResolver(), + ) + spec = QuerySpec() + + assert backend.query(ResourceSchema, spec, namespace_path="scope") == ["read"] + assert backend.count(ResourceSchema, spec, namespace_path="scope") == 1 + assert backend._execute("command", "context") == "written" + + +def test_client_backend_delegates_namespace_operations(): + namespaces = _Namespaces() + namespace_writer = _NamespaceWriter() + backend = ClientBackend( + reader=_Reader(), + writer=_Writer(), + namespaces=namespaces, + namespace_writer=namespace_writer, + context_resolver=_NamespaceContextResolver(), + ) + + assert backend.list_child_namespaces("scope") == ["scope"] + assert backend.create_namespace("scope", {}, "context") == ("scope", {}, "context") + assert backend.update_namespace("id", 1, {}, None, "context", etag="etag") == ( + "id", + 1, + {}, + None, + "context", + "etag", + ) + + +def test_client_backend_closes_shared_capability_once(): + shared = _ClosableFake() + backend = ClientBackend( + reader=shared, + writer=shared, + namespaces=shared, + namespace_writer=shared, + context_resolver=_NamespaceContextResolver(), + ) + + backend.close() + + assert shared.close_count == 1 + + +def test_client_close_delegates_to_backend(): + shared = _ClosableFake() + backend = ClientBackend( + reader=shared, + writer=shared, + namespaces=shared, + namespace_writer=shared, + context_resolver=_NamespaceContextResolver(), + ) + client = RecapClient._from_backends(backend, namespace="") + + client.close() + + assert shared.close_count == 1 + + +def test_client_backend_is_frozen(): + backend = ClientBackend(**_backend_values()) + + with pytest.raises(AttributeError): + backend.reader = _Reader() + + +@pytest.mark.parametrize( + "field", ["reader", "writer", "namespaces", "namespace_writer"] +) +def test_client_backend_rejects_missing_required_capability(field): + values = _backend_values() + values[field] = object() + + with pytest.raises(TypeError, match=field): + ClientBackend(**values) + + +def test_client_stores_typed_reader_and_writer(): + reader = _Reader() + writer = _Writer() + backend = ClientBackend( + reader=reader, + writer=writer, + namespaces=_Namespaces(), + namespace_writer=_NamespaceWriter(), + context_resolver=_NamespaceContextResolver(), + ) + client = RecapClient._from_backends(backend, namespace="") + + assert client.connection_state.backend is backend + assert client.connection_state.backend.reader is reader + assert client.connection_state.backend.writer is writer + client.close() + + +def test_copy_resource_routes_through_write_capability(): + expected = ResourceSchema.model_construct(id=UUID(int=1), name="copy") + reader = _Reader() + writer = _Writer(expected) + backend = ClientBackend( + reader=reader, + writer=writer, + namespaces=_Namespaces(), + namespace_writer=_NamespaceWriter(), + context_resolver=_NamespaceContextResolver(), + ) + client = RecapClient._from_backends(backend, namespace="scope") + + assert client.copy_resource(UUID(int=2)) is expected + assert writer.commands[0][0].source_resource_id == UUID(int=2) + client.close() + + +def test_namespace_operations_route_through_command_writer(): + context = NamespaceContext( + id=UUID(int=3), + path="scope", + metadata={}, + status=LifecycleStatus.ACTIVE, + revision=1, + ) + + class NamespaceWriter(_Writer): + def execute(self, command, context, *, etag_override=None): + self.commands.append((command, context)) + return self.context + + writer = NamespaceWriter() + writer.context = context + namespace_writer = _NamespaceWriter() + backend = ClientBackend( + reader=_Reader(), + writer=writer, + namespaces=_Namespaces(), + namespace_writer=namespace_writer, + context_resolver=_NamespaceContextResolver(), + ) + client = RecapClient._from_backends(backend, namespace="") + + assert client.create_namespace("scope", as_current=True) == context + assert client.update_namespace() == context + assert isinstance(writer.commands[0][0], CreateNamespace) + assert isinstance(writer.commands[1][0], UpdateNamespace) + client.close() + + +def test_build_process_run_resolves_root_namespace(tmp_path): + with RecapClient.from_sqlite(tmp_path / "recap.db") as client: + with client.build_process_template("tmpl", "1.0") as template: + template.get_model() + + with client.build_process_run("run", "desc", "tmpl", "1.0") as builder: + assert builder.namespace_context.path == "" + + +def test_from_sqlite_returns_root_recap_client(tmp_path): + client = RecapClient.from_sqlite(tmp_path / "recap.db") + + assert isinstance(client, RecapClient) + assert client.namespace_path == "" + client.close() + + +def test_factories_dont_accept_nonexistent_namespace_scope(tmp_path): + with pytest.raises(RecapNotFoundError): + RecapClient.from_sqlite(tmp_path / "recap.db", namespace="beamline/amx") + + +def test_build_resource_template_validates_type_names(apply_migrations, db_path): + with RecapClient.from_sqlite(db_path) as client: + client.create_namespace("validation") with pytest.raises(TypeError): client.build_resource_template(name="Bad", type_names="not-a-list") @@ -23,57 +296,241 @@ def test_build_resource_template_validates_type_names(apply_migrations, db_url): def test_from_sqlite_uses_temp_dir(): with RecapClient.from_sqlite() as client: - assert client.database_path is not None - assert client.database_path.exists() - assert client.database_path.parent == Path(gettempdir()) - client.create_campaign("name", "proposal") - assert isinstance(client._campaign, CampaignSchema) + assert client.connection_state.database_path is not None + assert client.connection_state.database_path.exists() + assert client.connection_state.database_path.parent == Path(gettempdir()) + client.create_namespace("name") + assert isinstance(client.namespace_context, NamespaceContext) - if client.database_path and client.database_path.exists(): - client.database_path.unlink() + if ( + client.connection_state.database_path + and client.connection_state.database_path.exists() + ): + client.connection_state.database_path.unlink() def test_from_sqlite_reuses_existing_file(tmp_path): db_file = tmp_path / "recap.db" with RecapClient.from_sqlite(db_file) as client: - client.create_campaign("name", "proposal") - existing_id = client._campaign.id + client.create_namespace("name") with RecapClient.from_sqlite(db_file) as client: - client.set_campaign(existing_id) - assert client._campaign.id == existing_id - assert client.database_path == db_file + scoped = client.namespace("name") + assert scoped.namespace_context.path == "name" + scoped.close() + assert client.connection_state.database_path == db_file + + +def test_blank_database_copies_are_isolated( + blank_database_path, copy_database, tmp_path +): + first_path = copy_database(blank_database_path, tmp_path / "first.db") + second_path = copy_database(blank_database_path, tmp_path / "second.db") + + with RecapClient.from_sqlite(first_path) as first: + first.create_namespace("only-first") + + with RecapClient.from_sqlite(second_path) as second: + assert ( + second.connection_state.backend.namespaces.list_child_namespace_paths("") + == [] + ) + +def test_client_fixture_starts_at_root_scope(client): + assert client.namespace_path == "" + assert ( + client.connection_state.backend.namespaces.list_child_namespace_paths("") == [] + ) -def test_query_maker_defaults_to_client_campaign(apply_migrations, db_url): - with RecapClient(url=db_url) as client: - client.create_campaign("name", "proposal") + +def test_query_maker_uses_client_namespace_scope(apply_migrations, db_path): + with RecapClient.from_sqlite(db_path) as client: + context = client.create_namespace("query-name") qm = client.query_maker() - assert qm.process_runs()._spec.campaign_id == client._campaign.id - assert qm.resources()._spec.campaign_id == client._campaign.id - assert qm.process_templates()._spec.campaign_id is None + assert qm.process_runs()._context == context + assert qm.resources()._context == context + assert qm.process_templates()._context == context assert qm.process_runs()._spec.on_unloaded == "warn" -def test_query_maker_can_override_campaign(apply_migrations, db_url): - with RecapClient(url=db_url) as client: - client.create_campaign("default", "p1") - default_id = client._campaign.id - client.create_campaign("other", "p2") - other_id = client._campaign.id +def test_query_maker_receives_client_backend_reader_facade(client): + query = client.query_maker() + + assert query.backend is client.connection_state.backend - client.set_campaign(default_id) - qm = client.query_maker(campaign=other_id) - assert qm.process_runs()._spec.campaign_id == other_id - assert qm.resources()._spec.campaign_id == other_id - assert qm.process_runs()._spec.campaign_id != default_id +def test_query_maker_uses_scoped_namespace_view(apply_migrations, db_path): + with RecapClient.from_sqlite(db_path) as client: + other = client.create_namespace("client-other") + qm = client.query_maker() + + assert qm.process_runs()._context == other + assert qm.resources()._context == other -def test_query_maker_can_set_on_unloaded_policy(apply_migrations, db_url): - with RecapClient(url=db_url) as client: - client.create_campaign("name-policy", "proposal-policy") +def test_query_maker_can_set_on_unloaded_policy(apply_migrations, db_path): + with RecapClient.from_sqlite(db_path) as client: + client.create_namespace("name-policy") qm = client.query_maker(on_unloaded="raise") assert qm.process_runs()._spec.on_unloaded == "raise" + + +def test_root_query_maker_uses_root_scope_remotely(monkeypatch): + from recap.adapter.rest import RESTAdapter + + monkeypatch.setattr( + RESTAdapter, + "get_namespace_context", + lambda _adapter, path: NamespaceContext(id=UUID(int=0), path=path, metadata={}), + ) + + client = RecapClient.from_url("http://recap.test", api_key="secret") + + query = client.query_maker().resources() + + assert query._context.path == "" + client.close() + + +def test_scoped_permissions_use_client_namespace(monkeypatch): + from recap.adapter.rest import RESTAdapter + + monkeypatch.setattr( + RESTAdapter, + "get_namespace_context", + lambda _adapter, path: NamespaceContext(id=UUID(int=0), path=path, metadata={}), + ) + client = RecapClient.from_url("http://recap.test", api_key="secret") + calls = [] + monkeypatch.setattr( + client.connection_state.backend.reader, + "permissions", + lambda path: calls.append(path) or object(), + ) + + client.namespace("beamline/amx").permissions() + + assert calls == ["beamline/amx"] + client.close() + + +def test_remote_get_resource_uses_read_backend(monkeypatch): + from recap.adapter.rest import RESTAdapter + + monkeypatch.setattr( + RESTAdapter, + "get_namespace_context", + lambda _adapter, path: NamespaceContext(id=UUID(int=0), path=path, metadata={}), + ) + + client = RecapClient.from_url("http://recap.test", api_key="secret") + expected = ResourceRef.model_construct(id=UUID(int=1), name="sample") + calls = [] + + def read(*args, **kwargs): + calls.append((args, kwargs)) + return [expected] + + monkeypatch.setattr(client.connection_state.backend.reader, "query", read) + monkeypatch.setattr( + client.connection_state.backend.writer, + "get_resource", + lambda *args, **kwargs: pytest.fail("remote read used REST backend"), + raising=False, + ) + + assert client.get_resource("sample", "Sample") is expected + assert calls[0][1]["namespace_path"] == "" + client.close() + + +def test_get_resource_uses_supported_relationship_filters(monkeypatch, tmp_path): + client = RecapClient.from_sqlite(tmp_path / "recap.db") + expected = ResourceRef.model_construct(id=UUID(int=4), name="sample") + calls = [] + + def read(*args, **kwargs): + calls.append((args, kwargs)) + return [expected] + + monkeypatch.setattr(client.connection_state.backend.reader, "query", read) + + assert client.get_resource("sample", "Sample", "2.0") is expected + assert calls[0][0][1].filters == { + "name": "sample", + "template__name": "Sample", + "template__version": "2.0", + } + client.close() + + +def test_get_resource_rejects_multiple_matches(monkeypatch, tmp_path): + client = RecapClient.from_sqlite(tmp_path / "recap.db") + matches = [ + ResourceRef.model_construct(id=UUID(int=5), name="sample"), + ResourceRef.model_construct(id=UUID(int=6), name="sample"), + ] + monkeypatch.setattr( + client.connection_state.backend.reader, "query", lambda *args, **kwargs: matches + ) + + with pytest.raises(ValueError, match="[Mm]ultiple resources"): + client.get_resource("sample", "Sample") + client.close() + + +def test_uuid_parent_resolution_uses_read_backend(monkeypatch): + from recap.adapter.rest import RESTAdapter + + monkeypatch.setattr( + RESTAdapter, + "get_namespace_context", + lambda _adapter, path: NamespaceContext(id=UUID(int=0), path=path, metadata={}), + ) + client = RecapClient.from_url("http://recap.test", api_key="secret") + expected = ResourceRef.model_construct(id=UUID(int=2), name="parent") + calls = [] + + def read(*args, **kwargs): + calls.append((args, kwargs)) + return [expected] + + monkeypatch.setattr(client.connection_state.backend.reader, "query", read) + + assert ( + client._resolve_parent( + expected.id, + NamespaceContext( + id=UUID(int=3), + path="beamline", + metadata={}, + status=LifecycleStatus.ACTIVE, + revision=1, + ), + ) + is expected + ) + assert calls[0][1]["namespace_path"] == "beamline" + client.close() + + +def test_scoped_remote_query_uses_view_namespace(monkeypatch): + from recap.adapter.rest import RESTAdapter + + monkeypatch.setattr( + RESTAdapter, + "get_namespace_context", + lambda _adapter, path: NamespaceContext(id=UUID(int=0), path=path, metadata={}), + ) + + client = RecapClient.from_url("http://recap.test", api_key="secret") + scoped = client.namespace("beamline/amx") + + query = scoped.query_maker().resources() + + assert query._context.path == "beamline/amx" + scoped.close() + client.close() diff --git a/recap/tests/test_client_identity.py b/recap/tests/test_client_identity.py new file mode 100644 index 0000000..a9c24ac --- /dev/null +++ b/recap/tests/test_client_identity.py @@ -0,0 +1,900 @@ +import gc +import weakref +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from threading import RLock +from unittest.mock import patch +from uuid import UUID, uuid4 + +import pytest + +from recap.client.backend import ClientBackend +from recap.client.base_client import RecapClient +from recap.client.connection_state import ConnectionState +from recap.client.identity import IdentityMap, IdentityMergeConflict +from recap.schemas.attribute import AttributeValueSchema +from recap.schemas.namespace import NamespaceContext, NamespaceRef, NamespaceSchema +from recap.schemas.resource import ( + ResourceRef, + ResourceSchema, + ResourceSlotSchema, + ResourceTemplateSchema, + ResourceTypeSchema, +) +from recap.schemas.step import ParameterSchema + + +def _resource(resource_id, *, name="sample", revision=1, modified_date=None): + return ResourceSchema.model_construct( + id=resource_id, + create_date=datetime(2026, 1, 1), + modified_date=modified_date or datetime(2026, 1, revision), + namespace_id=uuid4(), + status="ACTIVE", + revision=revision, + name=name, + ) + + +def test_same_id_ref_and_full_models_share_identity(): + identity_map = IdentityMap() + resource_id = uuid4() + resource = _resource(resource_id) + reference = ResourceRef.model_construct( + id=resource_id, + create_date=resource.create_date, + modified_date=resource.modified_date, + namespace_id=resource.namespace_id, + status=resource.status, + revision=resource.revision, + name=resource.name, + ) + + first = identity_map.intern(resource) + second = identity_map.intern(reference) + + assert first is second + + +def test_namespace_ref_and_schema_share_identity_but_context_does_not(): + identity_map = IdentityMap() + namespace_id = uuid4() + namespace = NamespaceSchema.model_construct( + id=namespace_id, + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 1), + path="sample", + parent_id=None, + status="ACTIVE", + revision=1, + metadata={}, + ) + reference = NamespaceRef(id=namespace_id, path="sample") + context = NamespaceContext(id=namespace_id, path="sample") + + canonical = identity_map.intern(namespace) + + assert identity_map.intern(reference) is canonical + assert identity_map.intern(context) is context + + +def test_namespace_schema_promotes_ref_without_changing_identity(): + identity_map = IdentityMap() + namespace_id = uuid4() + reference = NamespaceRef(id=namespace_id, path="sample") + schema = NamespaceSchema.model_construct( + id=namespace_id, + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 1), + path="sample", + parent_id=None, + status="ACTIVE", + revision=1, + metadata={}, + ) + + canonical = identity_map.intern(reference) + promoted = identity_map.intern(schema) + + assert promoted is canonical + assert isinstance(canonical, NamespaceSchema) + assert canonical.revision == 1 + assert ( + identity_map.intern(NamespaceRef(id=namespace_id, path="sample")) is canonical + ) + + +def test_concurrent_intern_returns_one_namespace_canonical(): + identity_map = IdentityMap() + namespace_id = uuid4() + + def intern_reference(): + return identity_map.intern(NamespaceRef(id=namespace_id, path="sample")) + + with ThreadPoolExecutor(max_workers=8) as executor: + results = list(executor.map(lambda _index: intern_reference(), range(32))) + + assert {id(result) for result in results} == {id(results[0])} + + +def test_identity_map_retains_model_until_clear(): + identity_map = IdentityMap() + resource_id = uuid4() + resource = _resource(resource_id) + reference = weakref.ref(resource) + identity_map.intern(resource) + + del resource + gc.collect() + assert reference() is not None + + identity_map.clear() + gc.collect() + assert reference() is None + + +def test_intern_handles_self_referential_persisted_graphs(): + identity_map = IdentityMap() + resource = _resource(uuid4()) + resource.children = [resource] + + canonical = identity_map.intern(resource) + + assert canonical.children[0] is canonical + + +def test_equal_revision_cyclic_template_relations_do_not_recurse(): + identity_map = IdentityMap() + namespace_id = uuid4() + parent_id = uuid4() + child_id = uuid4() + + def template_graph(): + parent = ResourceTemplateSchema.model_construct( + id=parent_id, + namespace_id=namespace_id, + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 1), + status="ACTIVE", + revision=1, + name="Parent", + slug="parent", + version="1.0", + labels=[], + types=[], + children={}, + attribute_group_templates=[], + ) + child = ResourceTemplateSchema.model_construct( + id=child_id, + namespace_id=namespace_id, + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 1), + status="ACTIVE", + revision=1, + name="Child", + slug="child", + version="1.0", + labels=[], + types=[], + children={}, + attribute_group_templates=[], + ) + parent.children = {"child": child} + parent.parent = None + child.parent = parent + parent.set_loaded_relations( + { + "parent": True, + "children": True, + "types": True, + "attribute_group_templates": True, + } + ) + child.set_loaded_relations( + { + "parent": True, + "children": True, + "types": True, + "attribute_group_templates": True, + } + ) + return parent + + first = identity_map.intern(template_graph()) + second = template_graph() + + assert identity_map.intern(second) is first + + +def test_merge_replaces_scalar_list_and_dict_fields(): + identity_map = IdentityMap() + current = AttributeValueSchema(value=["old"], metadata_json={"old": True}) + incoming = AttributeValueSchema(value=["new"], metadata_json={"new": True}) + + identity_map._merge_container(current, incoming) + + assert current.value == ["new"] + assert current.metadata_json == {"new": True} + + +def test_entity_family_and_uuid_separate_keys(): + identity_map = IdentityMap() + entity_id = uuid4() + + resource = identity_map.intern(_resource(entity_id)) + namespace = identity_map.intern( + NamespaceSchema.model_construct( + id=entity_id, + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 1), + path="sample", + parent_id=None, + status="ACTIVE", + revision=1, + metadata={}, + ) + ) + + assert resource is not namespace + + +def test_identity_map_keeps_namespace_context_outside_backend_entity_family(): + identity_map = IdentityMap() + namespace_id = uuid4() + context = NamespaceContext(id=namespace_id, path="beamline") + namespace = NamespaceSchema.model_construct( + id=namespace_id, + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 1), + path="beamline", + parent_id=None, + status="ACTIVE", + revision=1, + metadata={}, + ) + + canonical = identity_map.intern(namespace) + + assert canonical is not context + assert identity_map.intern(context) is context + + +def test_higher_revision_merges_into_first_canonical_object(): + identity_map = IdentityMap() + resource_id = uuid4() + first = identity_map.intern(_resource(resource_id, name="old", revision=1)) + + result = identity_map.intern(_resource(resource_id, name="new", revision=2)) + + assert result is first + assert first.name == "new" + assert first.revision == 2 + + +def test_lower_revision_does_not_overwrite_canonical_object(): + identity_map = IdentityMap() + resource_id = uuid4() + first = identity_map.intern(_resource(resource_id, name="new", revision=2)) + + result = identity_map.intern(_resource(resource_id, name="old", revision=1)) + + assert result is first + assert first.name == "new" + + +def test_lower_revision_does_not_merge_loaded_relations(): + identity_map = IdentityMap() + resource_id = uuid4() + first = _resource(resource_id, revision=2) + first.children = {"new": _resource(uuid4(), name="new")} + first.set_loaded_relations({"children": True}) + canonical = identity_map.intern(first) + new_child = canonical.children["new"] + + incoming = _resource(resource_id, revision=1) + incoming.children = {"old": _resource(uuid4(), name="old")} + incoming.set_loaded_relations({"children": True}) + identity_map.intern(incoming) + + assert canonical.children == {"new": new_child} + + +def test_equal_revision_conflicting_data_raises_merge_conflict(): + identity_map = IdentityMap() + resource_id = uuid4() + identity_map.intern(_resource(resource_id, name="first", revision=1)) + + with pytest.raises(IdentityMergeConflict): + identity_map.intern(_resource(resource_id, name="second", revision=1)) + + +def test_equal_revision_conflicting_status_raises_merge_conflict(): + identity_map = IdentityMap() + resource_id = uuid4() + first = _resource(resource_id, revision=1) + identity_map.intern(first) + + second = _resource(resource_id, revision=1) + second.namespace_id = first.namespace_id + second.status = "ARCHIVED" + with pytest.raises(IdentityMergeConflict, match="status"): + identity_map.intern(second) + + +def test_equal_revision_loaded_relation_conflict_raises_merge_conflict(): + identity_map = IdentityMap() + resource_id = uuid4() + first = _resource(resource_id) + second_namespace_id = first.namespace_id + first.children = [_resource(uuid4(), name="first")] + first.set_loaded_relations({"children": True}) + identity_map.intern(first) + + second = _resource(resource_id) + second.namespace_id = second_namespace_id + second.children = [_resource(uuid4(), name="second")] + second.set_loaded_relations({"children": True}) + with pytest.raises(IdentityMergeConflict): + identity_map.intern(second) + + +def test_equal_revision_omitted_relation_containers_do_not_conflict(): + identity_map = IdentityMap() + resource_id = uuid4() + first = _resource(resource_id) + second_namespace_id = first.namespace_id + first.children = [_resource(uuid4(), name="first")] + first.set_loaded_relations({"children": False}) + identity_map.intern(first) + + second = _resource(resource_id) + second.namespace_id = second_namespace_id + second.children = [_resource(uuid4(), name="second")] + second.set_loaded_relations({}) + + assert identity_map.intern(second) is first + + +def test_equal_revision_loaded_relation_conflict_is_deterministic(): + identity_map = IdentityMap() + resource_id = uuid4() + first = _resource(resource_id) + second_namespace_id = first.namespace_id + first.children = [_resource(uuid4(), name="first")] + first.set_loaded_relations({"children": True}) + identity_map.intern(first) + + second = _resource(resource_id) + second.namespace_id = second_namespace_id + second.children = [_resource(uuid4(), name="second")] + second.set_loaded_relations({"children": True}) + + with pytest.raises(IdentityMergeConflict, match="children"): + identity_map.intern(second) + + +def test_repeated_partial_and_full_hydration_does_not_conflict_on_omitted_relations(): + identity_map = IdentityMap() + resource_id = uuid4() + namespace_id = uuid4() + first = _resource(resource_id) + first.namespace_id = namespace_id + first.children = [_resource(uuid4(), name="child")] + first.set_loaded_relations({"children": True}) + canonical = identity_map.intern(first) + + partial = _resource(resource_id) + partial.namespace_id = namespace_id + partial.children = [] + partial.set_loaded_relations({}) + + assert identity_map.intern(partial) is canonical + assert len(canonical.children) == 1 + + +def test_equal_revision_resource_template_projection_upgrade_does_not_conflict(): + identity_map = IdentityMap() + resource_id = uuid4() + template_id = uuid4() + namespace_id = uuid4() + full_template = ResourceTemplateSchema.model_construct( + id=template_id, + namespace_id=namespace_id, + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 1), + status="ACTIVE", + revision=1, + name="Sample", + slug="sample", + version="1.0", + labels=[], + types=[], + children={}, + attribute_group_templates=[], + ) + full_template.set_loaded_relations( + { + "parent": False, + "children": True, + "types": True, + "attribute_group_templates": True, + } + ) + first = _resource(resource_id) + first.namespace_id = namespace_id + first.template = full_template + first.set_loaded_relations({"template": True}) + canonical = identity_map.intern(first) + + partial_template = ResourceTemplateSchema.model_construct( + id=template_id, + namespace_id=namespace_id, + create_date=full_template.create_date, + modified_date=full_template.modified_date, + status="ACTIVE", + revision=1, + name="Sample", + slug="sample", + version="1.0", + ) + partial_template.set_loaded_relations({"parent": False}) + second = _resource(resource_id) + second.namespace_id = namespace_id + second.template = partial_template + second.set_loaded_relations({"template": True}) + + assert identity_map.intern(second) is canonical + assert canonical.template is full_template + + +def test_equal_revision_nested_loaded_scalar_conflict_is_deterministic(): + identity_map = IdentityMap() + resource_id = uuid4() + child_id = uuid4() + namespace_id = uuid4() + first = _resource(resource_id) + first.namespace_id = namespace_id + first.children = [_resource(child_id, name="first")] + first.set_loaded_relations({"children": True}) + identity_map.intern(first) + + second = _resource(resource_id) + second.namespace_id = namespace_id + second.children = [_resource(child_id, name="second")] + second.set_loaded_relations({"children": True}) + + with pytest.raises(IdentityMergeConflict, match="children"): + identity_map.intern(second) + + +def test_higher_revision_replaces_loaded_relation_container(): + identity_map = IdentityMap() + resource_id = uuid4() + first = _resource(resource_id) + first.children = [] + first.set_loaded_relations({"children": True}) + canonical = identity_map.intern(first) + old_children = canonical.children + + second = _resource(resource_id, revision=2) + child = _resource(uuid4(), name="child") + second.children = [child] + second.set_loaded_relations({"children": True}) + identity_map.intern(second) + + assert canonical.children is not old_children + assert canonical.children[0].id == child.id + + +def test_higher_revision_replaces_loaded_relation_container_to_reflect_deletions(): + identity_map = IdentityMap() + resource_id = uuid4() + retained = _resource(uuid4(), name="retained") + deleted = _resource(uuid4(), name="deleted") + first = _resource(resource_id) + first.children = [retained, deleted] + first.set_loaded_relations({"children": True}) + canonical = identity_map.intern(first) + old_children = canonical.children + + incoming = _resource(resource_id, revision=2) + incoming.children = [retained] + incoming.set_loaded_relations({"children": True}) + identity_map.intern(incoming) + + assert canonical.children == [retained] + assert canonical.children is not old_children + + +def test_authoritative_command_merge_does_not_change_later_query_merges(): + identity_map = IdentityMap() + resource_id = uuid4() + initial = _resource(resource_id) + initial.children = [_resource(uuid4(), name="old")] + initial.set_loaded_relations({"children": True}) + canonical = identity_map.intern(initial) + + command_result = _resource(resource_id, revision=2, name="command") + command_result.children = [] + command_result.set_loaded_relations({"children": True}) + identity_map.intern(command_result, authoritative=True) + + query_result = _resource(resource_id, revision=3, name="query") + query_child = _resource(uuid4(), name="query-child") + query_result.children = [query_child] + query_result.set_loaded_relations({"children": True}) + identity_map.intern(query_result) + + assert canonical.name == "query" + assert canonical.children == [query_child] + + conflicting_query = _resource(resource_id, revision=3, name="conflict") + conflicting_query.children = [query_child] + conflicting_query.set_loaded_relations({"children": True}) + with pytest.raises(IdentityMergeConflict): + identity_map.intern(conflicting_query) + + +def test_equal_revision_compatible_relation_loads_extend_without_deleting(): + identity_map = IdentityMap() + resource_id = uuid4() + first = _resource(resource_id) + first.children = [_resource(uuid4(), name="first")] + first.set_loaded_relations({"children": True}) + canonical = identity_map.intern(first) + + incoming = _resource(resource_id) + incoming.namespace_id = first.namespace_id + incoming.children = [first.children[0], _resource(uuid4(), name="second")] + incoming.set_loaded_relations({"children": True}) + identity_map.intern(incoming) + + assert {child.name for child in canonical.children} == {"first", "second"} + + +def test_repeated_loaded_value_array_reload_does_not_duplicate_items(): + identity_map = IdentityMap() + resource_id = uuid4() + type_id = uuid4() + template_id = uuid4() + namespace_id = uuid4() + first = _resource(resource_id) + first.namespace_id = namespace_id + first.template = ResourceTemplateSchema.model_construct( + id=template_id, + namespace_id=namespace_id, + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 1), + status="ACTIVE", + revision=1, + name="Sample", + slug="sample", + version="1.0", + types=[ResourceTypeSchema.model_construct(id=type_id, name="sample")], + ) + first.template.set_loaded_relations({"types": True}) + first.set_loaded_relations({"template": True}) + canonical = identity_map.intern(first) + + second = _resource(resource_id) + second.namespace_id = namespace_id + second.template = ResourceTemplateSchema.model_construct( + id=template_id, + namespace_id=namespace_id, + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 1), + status="ACTIVE", + revision=1, + name="Sample", + slug="sample", + version="1.0", + types=[ResourceTypeSchema.model_construct(id=type_id, name="sample")], + ) + second.template.set_loaded_relations({"types": True}) + second.set_loaded_relations({"template": True}) + identity_map.intern(second) + + assert [item.id for item in canonical.template.types] == [type_id] + + +@pytest.mark.parametrize( + "model", [ParameterSchema, ResourceSlotSchema, ResourceTypeSchema] +) +def test_value_models_are_not_interned(model): + identity_map = IdentityMap() + model_id = uuid4() + first = model.model_construct(id=model_id) + second = model.model_construct(id=model_id) + + assert identity_map.intern(first) is first + assert identity_map.intern(second) is second + + +def test_models_without_revision_merge_only_when_modified_date_is_newer(): + identity_map = IdentityMap() + entity_id = uuid4() + old = NamespaceSchema.model_construct( + id=entity_id, + create_date=datetime(2026, 1, 1), + modified_date=datetime(2026, 1, 2), + path="old", + parent_id=None, + status="ACTIVE", + revision=None, + metadata={}, + ) + newer = old.model_copy( + update={"path": "new", "modified_date": datetime(2026, 1, 3)} + ) + + canonical = identity_map.intern(old) + identity_map.intern(newer) + + assert canonical.path == "new" + + +def test_clear_removes_strong_references(): + identity_map = IdentityMap() + resource_id = uuid4() + identity_map.intern(_resource(resource_id)) + + identity_map.clear() + + assert identity_map.get(("resource", resource_id)) is None + + +def test_connection_state_clears_identity_map_once_on_final_close(): + backend = ClientBackend.__new__(ClientBackend) + object.__setattr__(backend, "_close_lock", RLock()) + object.__setattr__(backend, "_closed", False) + object.__setattr__(backend, "identity_map", IdentityMap()) + for name in ("reader", "writer", "namespaces", "namespace_writer"): + object.__setattr__(backend, name, object()) + state = ConnectionState(backend=backend) + state.acquire() + state.acquire() + resource_id = uuid4() + backend.identity_map.intern(_resource(resource_id)) + + state.release() + assert backend.identity_map.get(("resource", resource_id)) is not None + state.release() + state.close() + + assert backend.identity_map.get(("resource", resource_id)) is None + + +def test_direct_backend_close_clears_identity_map_idempotently(): + backend = ClientBackend.__new__(ClientBackend) + object.__setattr__(backend, "_close_lock", RLock()) + object.__setattr__(backend, "_closed", False) + object.__setattr__(backend, "identity_map", IdentityMap()) + for name in ("reader", "writer", "namespaces", "namespace_writer"): + object.__setattr__(backend, name, object()) + resource_id = uuid4() + backend.identity_map.intern(_resource(resource_id)) + + backend.close() + backend.close() + + assert backend.identity_map.get(("resource", resource_id)) is None + + +def test_concurrent_direct_backend_close_closes_capabilities_and_identity_once(): + class Closable: + def __init__(self): + self.close_calls = 0 + + def close(self): + self.close_calls += 1 + + backend = ClientBackend.__new__(ClientBackend) + object.__setattr__(backend, "_close_lock", RLock()) + object.__setattr__(backend, "_closed", False) + identity_map = IdentityMap() + object.__setattr__(backend, "identity_map", identity_map) + capabilities = [Closable() for _ in range(4)] + for name, capability in zip( + ("reader", "writer", "namespaces", "namespace_writer"), + capabilities, + strict=True, + ): + object.__setattr__(backend, name, capability) + resource_id = uuid4() + identity_map.intern(_resource(resource_id)) + + with ThreadPoolExecutor(max_workers=8) as executor: + list(executor.map(lambda _index: backend.close(), range(32))) + + assert identity_map.get(("resource", resource_id)) is None + assert [capability.close_calls for capability in capabilities] == [1] * 4 + + +def test_direct_backend_close_closes_optional_capabilities_once_by_identity(): + class Closable: + def __init__(self): + self.close_calls = 0 + + def close(self): + self.close_calls += 1 + + backend = ClientBackend.__new__(ClientBackend) + object.__setattr__(backend, "_close_lock", RLock()) + object.__setattr__(backend, "_closed", False) + object.__setattr__(backend, "identity_map", IdentityMap()) + capabilities = [Closable() for _ in range(6)] + for name, capability in zip( + ( + "reader", + "writer", + "namespaces", + "namespace_writer", + "context_resolver", + "permissions", + ), + capabilities, + strict=True, + ): + object.__setattr__(backend, name, capability) + + backend.close() + backend.close() + + assert [capability.close_calls for capability in capabilities] == [1] * 6 + + +def test_backend_close_retries_failed_capability_without_repeating_successful_cleanup(): + class FlakyIdentityMap(IdentityMap): + def __init__(self): + super().__init__() + self.clear_calls = 0 + + def clear(self): + self.clear_calls += 1 + if self.clear_calls == 1: + raise RuntimeError("clear failed") + super().clear() + + class Closable: + def __init__(self, *, failures=0): + self.close_calls = 0 + self.failures = failures + + def close(self): + self.close_calls += 1 + if self.failures: + self.failures -= 1 + raise RuntimeError("close failed") + + backend = ClientBackend.__new__(ClientBackend) + object.__setattr__(backend, "_close_lock", RLock()) + object.__setattr__(backend, "_closed", False) + identity_map = FlakyIdentityMap() + object.__setattr__(backend, "identity_map", identity_map) + failing = Closable(failures=2) + successful = Closable() + for name, capability in zip( + ("reader", "writer", "namespaces", "namespace_writer"), + (failing, successful, successful, successful), + strict=True, + ): + object.__setattr__(backend, name, capability) + resource_id = uuid4() + identity_map.intern(_resource(resource_id)) + + with pytest.raises(RuntimeError, match="clear failed"): + backend.close() + + assert identity_map.get(("resource", resource_id)) is not None + assert failing.close_calls == 1 + assert successful.close_calls == 1 + + with pytest.raises(RuntimeError, match="close failed"): + backend.close() + + assert identity_map.get(("resource", resource_id)) is None + assert identity_map.clear_calls == 2 + assert failing.close_calls == 2 + assert successful.close_calls == 1 + + backend.close() + + assert identity_map.clear_calls == 2 + assert failing.close_calls == 3 + assert successful.close_calls == 1 + + backend.close() + assert failing.close_calls == 3 + + +def test_recap_client_close_retries_failed_shared_cleanup(monkeypatch): + class FlakyIdentityMap(IdentityMap): + def __init__(self): + super().__init__() + self.clear_calls = 0 + + def clear(self): + self.clear_calls += 1 + if self.clear_calls == 1: + raise RuntimeError("clear failed") + super().clear() + + class _ContextResolver: + def get_namespace_context(self, path=""): + pass + + backend = ClientBackend.__new__(ClientBackend) + object.__setattr__(backend, "_close_lock", RLock()) + object.__setattr__(backend, "_closed", False) + object.__setattr__(backend, "identity_map", FlakyIdentityMap()) + object.__setattr__(backend, "context_resolver", _ContextResolver()) + monkeypatch.setattr( + backend.context_resolver, + "get_namespace_context", + lambda _adapter, path="": NamespaceContext( + id=UUID(int=0), path=path, metadata={} + ), + ) + for name in ("reader", "writer", "namespaces", "namespace_writer"): + object.__setattr__(backend, name, object()) + + client = RecapClient._from_backends(backend, namespace="") + state = client.connection_state + + with pytest.raises(RuntimeError, match="clear failed"): + client.close() + + assert client._closed is False + assert client.connection_state is state + assert state.closed is False + + client.close() + + assert client._closed is True + assert client.connection_state is state + assert state.closed is True + assert backend.identity_map.clear_calls == 2 + + +def test_root_and_scoped_clients_share_identity_map(tmp_path): + root = RecapClient.from_sqlite(tmp_path / "identity.db") + root.create_namespace("beamline") + scoped = root.namespace("beamline") + + assert ( + scoped.connection_state.backend.identity_map + is root.connection_state.backend.identity_map + ) + + scoped.close() + root.close() + + +def test_final_root_close_clears_shared_identity_map_once(tmp_path): + root = RecapClient.from_sqlite(tmp_path / "identity.db") + root.create_namespace("beamline") + scoped = root.namespace("beamline") + + with patch.object( + root.connection_state.backend.identity_map, + "clear", + wraps=root.connection_state.backend.identity_map.clear, + ) as clear: + scoped.close() + root.close() + + clear.assert_called_once_with() + + +def test_separate_backend_connections_have_separate_identity_maps(): + first = RecapClient.from_sqlite() + second = RecapClient.from_sqlite() + + assert ( + first.connection_state.backend.identity_map + is not second.connection_state.backend.identity_map + ) + + first.close() + second.close() diff --git a/recap/tests/test_client_scope_lifecycle.py b/recap/tests/test_client_scope_lifecycle.py new file mode 100644 index 0000000..5dd1e05 --- /dev/null +++ b/recap/tests/test_client_scope_lifecycle.py @@ -0,0 +1,343 @@ +import inspect +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import patch +from uuid import uuid4 + +import pytest + +from recap.client.base_client import RecapClient +from recap.client.connection_state import ConnectionState +from recap.schemas.namespace import NamespaceContext + + +class FakeClosable: + def __init__(self): + self.closed = False + self.close_calls = 0 + + def close(self): + self.closed = True + self.close_calls += 1 + + +class FakeEngine: + def __init__(self): + self.dispose_calls = 0 + + def dispose(self): + self.dispose_calls += 1 + + +class FlakyBackend: + def __init__(self): + self.close_calls = 0 + + def close(self): + self.close_calls += 1 + if self.close_calls == 1: + raise RuntimeError("backend close failed") + + +def test_shared_state_closes_backend_only_after_last_view_releases(): + backend = FakeClosable() + state = ConnectionState(backend=backend) + + state.acquire() + state.acquire() + state.release() + + assert backend.closed is False + + state.release() + + assert backend.closed is True + + +def test_shared_state_release_is_idempotent_after_close(): + backend = FakeClosable() + state = ConnectionState(backend=backend) + state.acquire() + state.release() + state.release() + + assert state.closed is True + assert backend.close_calls == 1 + + +def test_shared_state_rejects_acquisition_after_close(): + state = ConnectionState(backend=FakeClosable()) + state.acquire() + state.release() + + with pytest.raises(RuntimeError): + state.acquire() + + +def test_shared_state_disposes_optional_engine_when_last_view_releases(): + engine = FakeEngine() + state = ConnectionState(backend=FakeClosable(), engine=engine) + state.acquire() + state.release() + state.release() + + assert engine.dispose_calls == 1 + + +def test_shared_state_closes_backend(): + backend = FakeClosable() + state = ConnectionState(backend=backend) + state.acquire() + state.release() + + assert backend.close_calls == 1 + + +def test_shared_state_retries_final_backend_cleanup_after_failure(): + backend = FlakyBackend() + state = ConnectionState(backend=backend) + state.acquire() + + with pytest.raises(RuntimeError, match="backend close failed"): + state.release() + + assert state.closed is False + assert state._active_views == 0 + + state.close() + + assert state.closed is True + assert backend.close_calls == 2 + + +def test_connection_state_stores_single_backend(): + backend = object() + + state = ConnectionState(backend=backend) + + assert state.backend is backend + assert not hasattr(state, "read_backend") + assert not hasattr(state, "write_backend") + + +def test_namespace_returns_same_type_and_shares_connection_state(tmp_path): + root = RecapClient.from_sqlite(tmp_path / "recap.db") + root.create_namespace("beamline") + root.create_namespace("beamline/amx") + scoped = root.namespace("beamline/amx") + + assert isinstance(scoped, RecapClient) + assert scoped is not root + assert scoped.namespace_path == "beamline/amx" + assert scoped.connection_state.backend is root.connection_state.backend + + scoped.close() + assert root.connection_state.backend is not None + root.close() + + +def test_scoped_close_does_not_close_shared_backend_until_last_view(tmp_path): + root = RecapClient.from_sqlite(tmp_path / "recap.db") + root.create_namespace("beamline") + root.create_namespace("beamline/amx") + scoped = root.namespace("beamline/amx") + state = root.connection_state + + scoped.close() + + assert state.closed is False + assert state._active_views == 1 + + root.close() + + assert state.closed is True + + +def test_remote_transport_closes_once_after_last_view_releases(): + with patch( + "recap.adapter.rest.RESTAdapter.get_namespace_context", + lambda _adapter, path: NamespaceContext(id=uuid4(), path=path), + ): + root = RecapClient.from_url("http://recap.test", api_key="secret") + scoped = root.namespace("beamline/amx") + transport = root.connection_state.backend.reader._transport + + with patch.object(transport._client, "close") as close: + scoped.close() + close.assert_not_called() + + root.close() + + close.assert_called_once_with() + + +def test_namespace_normalizes_root_and_slashes(tmp_path): + root = RecapClient.from_sqlite(tmp_path / "recap.db") + root.create_namespace("beamline") + root.create_namespace("beamline/amx") + + assert root.namespace("/").namespace_path == "" + assert root.namespace("/beamline/amx/").namespace_path == "beamline/amx" + + root.close() + + +def test_namespace_key_access_is_additive(tmp_path): + root = RecapClient.from_sqlite(tmp_path / "recap.db") + root.create_namespace("beamline") + root.create_namespace("beamline/amx") + root.create_namespace("beamline/amx/proposal") + + root = RecapClient.from_sqlite(tmp_path / "recap.db", namespace="beamline") + scoped = root["amx"]["/proposal"] + + assert isinstance(scoped, RecapClient) + assert scoped.namespace_path == "beamline/amx/proposal" + assert scoped.connection_state.backend is root.connection_state.backend + + root.close() + + +def test_namespace_root_key_preserves_current_scope(tmp_path): + root = RecapClient.from_sqlite(tmp_path / "recap.db") + root.create_namespace("beamline") + root = RecapClient.from_sqlite(tmp_path / "recap.db", namespace="beamline") + + scoped = root["/"] + + assert scoped.namespace_path == "beamline" + scoped.close() + root.close() + + +def test_namespace_key_requires_string(tmp_path): + root = RecapClient.from_sqlite(tmp_path / "recap.db") + + with pytest.raises(TypeError, match="namespace path must be a string"): + root[123] + + root.close() + + +def test_scoped_view_context_manager_returns_scoped_view(tmp_path): + root = RecapClient.from_sqlite(tmp_path / "recap.db") + root.create_namespace("beamline") + root.create_namespace("beamline/amx") + scoped = root.namespace("beamline/amx") + + with scoped as entered: + assert entered is scoped + assert entered.namespace_path == "beamline/amx" + + assert root.connection_state.backend is not None + root.close() + + +def test_scoped_view_close_is_idempotent(tmp_path): + root = RecapClient.from_sqlite(tmp_path / "recap.db") + root.create_namespace("beamline") + root.create_namespace("beamline/amx") + scoped = root.namespace("beamline/amx") + + scoped.close() + scoped.close() + + assert root.connection_state is not None + assert root.connection_state._active_views == 1 + root.close() + + +def test_concurrent_repeated_close_of_one_view_releases_one_reference(tmp_path): + root = RecapClient.from_sqlite(tmp_path / "recap.db") + root.create_namespace("beamline") + root.create_namespace("beamline/amx") + scoped = root.namespace("beamline/amx") + state = root.connection_state + + with ThreadPoolExecutor(max_workers=8) as executor: + list(executor.map(lambda _index: scoped.close(), range(32))) + + assert state._active_views == 1 + assert state.closed is False + root.close() + + +def test_scoped_local_builder_resolves_namespace_path_without_active_context(tmp_path): + root = RecapClient.from_sqlite(tmp_path / "recap.db") + root.create_namespace("beamline") + root.create_namespace("beamline/amx") + scoped = root.namespace("beamline/amx") + + builder = scoped.build_resource_template(name="Sample", type_names=["sample"]) + + assert builder.namespace_context.id + + scoped.close() + root.close() + + +def test_scoped_local_get_resource_resolves_namespace_without_active_context(tmp_path): + root = RecapClient.from_sqlite(tmp_path / "recap.db") + root.create_namespace("beamline") + root.create_namespace("beamline/amx") + with root.build_resource_template(name="Sample", type_names=["sample"]): + pass + root.create_resource("S-001", "Sample") + # root._namespace_context = None + scoped = root.namespace("beamline/amx") + + resource = scoped.get_resource("S-001", "Sample") + + assert resource.name == "S-001" + + scoped.close() + root.close() + + +def test_scoped_query_does_not_require_namespace_argument(monkeypatch): + from uuid import UUID + + from recap.adapter.rest import RESTAdapter + from recap.schemas.namespace import NamespaceContext + + monkeypatch.setattr( + RESTAdapter, + "get_namespace_context", + lambda _adapter, path: NamespaceContext(id=UUID(int=0), path=path, metadata={}), + ) + + client = RecapClient.from_url( + "http://recap.test", api_key="secret", namespace="beamline/amx" + ) + + query = client.query_maker() + + assert query.namespace_path == "beamline/amx" + client.close() + + +def test_scoped_client_rejects_redundant_namespace_override(monkeypatch): + from uuid import UUID + + from recap.adapter.rest import RESTAdapter + from recap.schemas.namespace import NamespaceContext + + monkeypatch.setattr( + RESTAdapter, + "get_namespace_context", + lambda _adapter, path: NamespaceContext(id=UUID(int=0), path=path, metadata={}), + ) + + client = RecapClient.from_url( + "http://recap.test", api_key="secret", namespace="beamline/amx" + ) + + with pytest.raises(TypeError): + client.query_maker(namespace="other") + + client.close() + + +def test_scoped_builder_signature_has_no_namespace_path_argument(): + parameters = inspect.signature(RecapClient.build_resource).parameters + + assert "namespace_path" not in parameters diff --git a/recap/tests/test_command_errors.py b/recap/tests/test_command_errors.py new file mode 100644 index 0000000..8f6575b --- /dev/null +++ b/recap/tests/test_command_errors.py @@ -0,0 +1,68 @@ +from dataclasses import FrozenInstanceError, fields + +import pytest + +from recap.commands.errors import ( + CommandConflictError, + CommandError, + CommandNotFoundError, + CommandValidationError, +) +from recap.commands.models import CommandContext +from recap.exceptions import ( + RecapConflictError, + RecapNotFoundError, + RecapValidationError, +) + + +@pytest.mark.parametrize( + ("error_type", "code", "public_type"), + [ + (CommandNotFoundError, "not_found", RecapNotFoundError), + (CommandConflictError, "conflict", RecapConflictError), + (CommandValidationError, "validation_error", RecapValidationError), + ], +) +def test_command_errors_have_stable_semantics(error_type, code, public_type): + error = error_type("safe message") + + assert isinstance(error, CommandError) + assert isinstance(error, public_type) + assert error.code == code + assert str(error) == "safe message" + + +def test_validation_error_supports_optional_public_message(): + default = CommandValidationError("internal message") + explicit = CommandValidationError( + "internal message", public_message="safe public message" + ) + + assert default.public_message is None + assert explicit.public_message == "safe public message" + assert str(explicit) == "internal message" + + +def test_command_context_has_exact_request_scoped_fields(): + assert [field.name for field in fields(CommandContext)] == [ + "actor", + "request_id", + "policy", + "audit_sink", + "authorization_generation", + "idempotency_key", + ] + + +def test_command_context_is_frozen(): + context = CommandContext( + actor=object(), + request_id="request-1", + policy=object(), + audit_sink=object(), + authorization_generation=None, + ) + + with pytest.raises(FrozenInstanceError): + context.request_id = "request-2" diff --git a/recap/tests/test_command_models.py b/recap/tests/test_command_models.py new file mode 100644 index 0000000..60e42dc --- /dev/null +++ b/recap/tests/test_command_models.py @@ -0,0 +1,40 @@ +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from recap.commands.models import CreateResource + + +def test_create_resource_forbids_server_fields(): + with pytest.raises(ValidationError, match="namespace_id"): + CreateResource( + namespace_path="beamline/endstation", + name="robot", + template_id=uuid4(), + namespace_id=uuid4(), + ) + + +@pytest.mark.parametrize("field", ["id", "status", "revision"]) +def test_create_resource_forbids_other_server_fields(field): + values = { + "namespace_path": "beamline/endstation", + "name": "robot", + "template_id": uuid4(), + field: "server-controlled", + } + + with pytest.raises(ValidationError, match=field): + CreateResource(**values) + + +def test_create_resource_is_immutable(): + command = CreateResource( + namespace_path="beamline/endstation", + name="robot", + template_id=uuid4(), + ) + + with pytest.raises(ValidationError, match="frozen"): + command.name = "replacement" diff --git a/recap/tests/test_command_registry.py b/recap/tests/test_command_registry.py new file mode 100644 index 0000000..17adb4c --- /dev/null +++ b/recap/tests/test_command_registry.py @@ -0,0 +1,530 @@ +from unittest.mock import patch +from uuid import UUID, uuid4 + +import pytest + +from recap.commands import registry +from recap.commands.errors import CommandValidationError +from recap.commands.models import ( + CommandModel, + CopyProcessRun, + CopyResource, + CreateNamespace, + CreateProcessRun, + CreateProcessTemplate, + CreateResource, + CreateResourceTemplate, + SetLifecycleStatus, + UpdateNamespace, + UpdateProcessRun, + UpdateProcessTemplate, + UpdateResource, + UpdateResourceTemplate, +) +from recap.commands.registry import COMMAND_REGISTRY +from recap.commands.service import CommandService +from recap.dsl.drafts import ( + ProcessRunDraft, + ProcessTemplateDraft, + ResourceTemplateDraft, +) +from recap.schemas.process import ProcessRunSchema, ProcessTemplateSchema +from recap.schemas.resource import ( + ResourceCopyOptions, + ResourceSchema, + ResourceTemplateSchema, +) +from recap.server.rest_models import ( + CopyProcessRunRequest, + CopyResourceRequest, + CreateNamespaceRequest, + CreateResourceRequest, + UpdateNamespaceRequest, + UpdateProcessRunRequest, + UpdateResourceRequest, +) + + +class UnregisteredCommand(CommandModel): + marker: str + + +def test_update_resource_has_registration(): + registration = COMMAND_REGISTRY.by_type(UpdateResource) + + assert registration.name == "update_resource" + assert registration.service_handler == "update_resource" + assert registration.method == "PATCH" + assert registration.route_template == "/api/v1/resources/{resource_id}" + assert registration.request_model is UpdateResourceRequest + + +def test_pilot_command_registry_is_complete(): + expected = { + CreateResource, + UpdateResource, + CopyResource, + CopyProcessRun, + CreateResourceTemplate, + CreateProcessTemplate, + CreateProcessRun, + UpdateResourceTemplate, + UpdateProcessTemplate, + UpdateProcessRun, + CreateNamespace, + UpdateNamespace, + SetLifecycleStatus, + } + + COMMAND_REGISTRY.validate_complete(expected) + + +def test_registry_covers_all_command_types(): + expected = { + CreateResource, + UpdateResource, + CopyResource, + CopyProcessRun, + CreateResourceTemplate, + UpdateResourceTemplate, + CreateProcessTemplate, + UpdateProcessTemplate, + CreateProcessRun, + UpdateProcessRun, + SetLifecycleStatus, + CreateNamespace, + UpdateNamespace, + } + + assert {item.command_type for item in COMMAND_REGISTRY.all()} == expected + COMMAND_REGISTRY.validate_complete(expected) + + +def test_lifecycle_registration_uses_polymorphic_response_decoder(): + registration = COMMAND_REGISTRY.by_type(SetLifecycleStatus) + + assert registration.name == "set_lifecycle_status" + assert registration.service_handler == "set_lifecycle_status" + assert registration.method == "POST" + assert registration.route_template == "/api/v1/lifecycle/{object_type}/{object_id}" + assert registration.decode_response is registry._decode_lifecycle_response + + +@pytest.mark.parametrize( + "object_type, schema_name", + [ + ("resource", "ResourceSchema"), + ("resource_template", "ResourceTemplateSchema"), + ("process_template", "ProcessTemplateSchema"), + ("process_run", "ProcessRunSchema"), + ], +) +def test_lifecycle_response_decoder_selects_schema(object_type, schema_name): + command = SetLifecycleStatus( + object_type=object_type, object_id=uuid4(), expected_revision=1, status="ACTIVE" + ) + + schema_module = { + "ResourceSchema": "recap.commands.registry.ResourceSchema", + "ResourceTemplateSchema": "recap.commands.registry.ResourceTemplateSchema", + "ProcessTemplateSchema": "recap.commands.registry.ProcessTemplateSchema", + "ProcessRunSchema": "recap.commands.registry.ProcessRunSchema", + }[schema_name] + with patch(f"{schema_module}.model_validate", return_value=object()) as validate: + decoded = registry._decode_lifecycle_response({}, '"2"', command=command) + + assert decoded is not None + validate.assert_called_once_with({}) + + +def test_lifecycle_response_decoder_rejects_unknown_object_type(): + command = SetLifecycleStatus( + object_type="unknown", object_id=uuid4(), expected_revision=1, status="ACTIVE" + ) + + with pytest.raises(TypeError, match="Unsupported lifecycle object"): + registry._decode_lifecycle_response({}, None, command=command) + + +def test_pilot_command_registry_rejects_unexpected_registered_types(): + with pytest.raises(CommandValidationError, match="Unexpected command registrations"): + COMMAND_REGISTRY.validate_complete({UpdateResource}) + + +def test_unregistered_command_fails_loudly(): + with pytest.raises(CommandValidationError, match="No command registration"): + COMMAND_REGISTRY.by_command(UnregisteredCommand(marker="missing")) + + +def test_service_dispatches_update_resource_via_registered_handler(monkeypatch): + command = UpdateResource(resource_id=uuid4(), expected_revision=1, name="renamed") + result = object() + calls = [] + + def handler(self, context, **kwargs): + calls.append(kwargs) + return result + + monkeypatch.setattr(CommandService, "update_resource", handler) + + returned = CommandService(None).execute(command, context=object()) + + assert returned is result + assert calls == [ + { + "resource_id": command.resource_id, + "expected_revision": 1, + "name": "renamed", + "properties": None, + } + ] + + +def test_namespace_commands_have_registrations_and_complete_expected_set(): + create = COMMAND_REGISTRY.by_type(CreateNamespace) + update = COMMAND_REGISTRY.by_type(UpdateNamespace) + + assert (create.method, create.route_template, create.request_model) == ( + "PUT", + "/api/v1/namespaces/{namespace_path:path}", + CreateNamespaceRequest, + ) + assert (update.method, update.route_template, update.request_model) == ( + "PATCH", + "/api/v1/namespaces/{namespace_id}", + UpdateNamespaceRequest, + ) + COMMAND_REGISTRY.validate_complete( + { + CreateResource, + UpdateResource, + CopyResource, + CopyProcessRun, + CreateResourceTemplate, + CreateProcessTemplate, + CreateProcessRun, + UpdateResourceTemplate, + UpdateProcessTemplate, + UpdateProcessRun, + CreateNamespace, + UpdateNamespace, + SetLifecycleStatus, + } + ) + + +def test_update_namespace_encoding_uses_if_match_and_excludes_revision(): + command = UpdateNamespace( + namespace_id=uuid4(), expected_revision=4, metadata={"owner": "amx"} + ) + + encoded = COMMAND_REGISTRY.by_command(command).encode_request(command) + + assert encoded.method == "PATCH" + assert encoded.path == f"/api/v1/namespaces/{command.namespace_id}" + assert encoded.body == {"metadata": {"owner": "amx"}} + assert encoded.etag == '"4"' + + +@pytest.mark.parametrize( + ("headers", "expected"), + [ + ({"If-Match": '"4"'}, 4), + ({"If-Match": 'W/"7"'}, 7), + ], +) +def test_registry_revision_header_helper(headers, expected): + assert registry._expected_revision(headers) == expected + + +@pytest.mark.parametrize( + "headers", + [{}, {"If-Match": ""}, {"If-Match": '"0"'}, {"If-Match": "invalid"}], +) +def test_registry_revision_header_helper_rejects_invalid_headers(headers): + with pytest.raises(CommandValidationError): + registry._expected_revision(headers) + + +def test_namespace_update_request_rejects_empty_changes(): + with pytest.raises(ValueError, match="Namespace update is empty"): + UpdateNamespaceRequest() + + +def test_namespace_response_decoder_preserves_etag(): + registration = COMMAND_REGISTRY.by_type(UpdateNamespace) + + decoded = registration.decode_response( + { + "id": str(uuid4()), + "path": "beamline/amx", + "revision": 7, + "status": "ACTIVE", + "metadata": {}, + "create_date": "2026-01-01T00:00:00Z", + "modified_date": "2026-01-01T00:00:00Z", + }, + 'W/"server-7"', + ) + + assert decoded.etag == 'W/"server-7"' + + +def test_resource_registry_covers_create_copy_and_templates(): + expected = { + CreateResource: ("POST", "/api/v1/resources/{namespace_path:path}", CreateResourceRequest, ResourceSchema), + CopyResource: ("POST", "/api/v1/resources/{source_resource_id}/copies", CopyResourceRequest, ResourceSchema), + CreateResourceTemplate: ("POST", "/api/v1/resource-templates/{namespace_path:path}", ResourceTemplateDraft, ResourceTemplateSchema), + UpdateResourceTemplate: ("PATCH", "/api/v1/resource-templates/{template_id}", ResourceTemplateDraft, ResourceTemplateSchema), + } + for command_type, values in expected.items(): + registration = COMMAND_REGISTRY.by_type(command_type) + assert (registration.method, registration.route_template, registration.request_model) == values[:3] + assert registration.decode_response is not None + + +def test_copy_resource_codec_preserves_default_options_on_wire(): + resource_id = uuid4() + copy = CopyResource( + source_resource_id=resource_id, + destination_namespace_path="beamline/amx", + ) + + encoded = COMMAND_REGISTRY.by_command(copy).encode_request(copy) + + assert encoded.body == { + "destination_namespace": "beamline/amx", + "name": None, + "parent_id": None, + "changes": {"properties": {}}, + } + + +def test_copy_resource_codec_preserves_explicit_options_on_wire(): + resource_id = uuid4() + copy = CopyResource( + source_resource_id=resource_id, + destination_namespace_path="beamline/amx", + options=ResourceCopyOptions( + name="copy", changes={"properties": {"group": {"value": 2}}} + ), + ) + encoded = COMMAND_REGISTRY.by_command(copy).encode_request(copy) + assert encoded.body == { + "destination_namespace": "beamline/amx", + "name": "copy", + "parent_id": None, + "changes": {"properties": {"group": {"value": 2}}}, + } + assert encoded.path == f"/api/v1/resources/{resource_id}/copies" + + +def test_copy_resource_codec_preserves_parent_id_on_wire(): + source_id = UUID("00000000-0000-0000-0000-000000000001") + parent_id = UUID("00000000-0000-0000-0000-000000000002") + command = CopyResource( + source_resource_id=source_id, + destination_namespace_path="beamline/amx", + options=ResourceCopyOptions(parent_id=parent_id), + ) + + encoded = COMMAND_REGISTRY.by_command(command).encode_request(command) + + assert encoded.body["parent_id"] == str(parent_id) + decoded = COMMAND_REGISTRY.by_command(command).decode_command( + {"source_resource_id": str(source_id)}, + {}, + CopyResourceRequest.model_validate(encoded.body), + ) + assert decoded.options.parent_id == parent_id + + +def test_resource_codecs_preserve_wire_shapes_and_revision_headers(): + resource_id = uuid4() + template_id = uuid4() + create = CreateResource( + namespace_path="beamline/amx", + name="sample", + template_id=template_id, + parent_id=resource_id, + properties={"group": {"value": 2}}, + ) + encoded = COMMAND_REGISTRY.by_command(create).encode_request(create) + assert encoded.body == { + "name": "sample", + "template_id": str(template_id), + "parent_id": str(resource_id), + "properties": {"group": {"value": 2}}, + } + assert encoded.path == "/api/v1/resources/beamline/amx" + + +def test_resource_template_codecs_unwrap_draft_and_use_if_match(): + draft = ResourceTemplateDraft(name="sample", version="1", type_names=[]) + create = CreateResourceTemplate(namespace_path="beamline", draft=draft) + encoded = COMMAND_REGISTRY.by_command(create).encode_request(create) + assert encoded.body == {"name": "sample", "version": "1", "labels": [], "type_names": [], "property_groups": [], "children": []} + assert encoded.path == "/api/v1/resource-templates/beamline" + + template_id = uuid4() + update = UpdateResourceTemplate(template_id=template_id, expected_revision=3, draft=draft) + encoded = COMMAND_REGISTRY.by_command(update).encode_request(update) + assert encoded.body == draft.model_dump(mode="json") + assert encoded.etag == '"3"' + assert encoded.path == f"/api/v1/resource-templates/{template_id}" + + +def test_process_registry_has_exact_routes_request_models_and_response_schemas(): + expected = { + CreateProcessTemplate: ( + "POST", + "/api/v1/process-templates/{namespace_path:path}", + ProcessTemplateDraft, + ProcessTemplateSchema, + ), + UpdateProcessTemplate: ( + "PATCH", + "/api/v1/process-templates/{template_id}", + ProcessTemplateDraft, + ProcessTemplateSchema, + ), + CreateProcessRun: ( + "POST", + "/api/v1/process-runs/{namespace_path:path}", + ProcessRunDraft, + ProcessRunSchema, + ), + UpdateProcessRun: ( + "PATCH", + "/api/v1/process-runs/{process_run_id}", + UpdateProcessRunRequest, + ProcessRunSchema, + ), + CopyProcessRun: ( + "POST", + "/api/v1/process-runs/{source_process_run_id}/copies", + CopyProcessRunRequest, + ProcessRunSchema, + ), + } + for command_type, values in expected.items(): + registration = COMMAND_REGISTRY.by_type(command_type) + assert ( + registration.method, + registration.route_template, + registration.request_model, + ) == values[:3] + assert registration.decode_response is not None + + +def test_process_template_codecs_unwrap_draft_and_use_if_match(): + draft = ProcessTemplateDraft(name="screen", version="1") + create = CreateProcessTemplate(namespace_path="beamline/amx", draft=draft) + encoded = COMMAND_REGISTRY.by_command(create).encode_request(create) + assert encoded.body == {"name": "screen", "version": "1", "labels": [], "resource_slots": [], "steps": []} + assert encoded.path == "/api/v1/process-templates/beamline/amx" + + update = UpdateProcessTemplate(template_id=uuid4(), expected_revision=3, draft=draft) + encoded = COMMAND_REGISTRY.by_command(update).encode_request(update) + assert encoded.body == draft.model_dump(mode="json") + assert encoded.etag == '"3"' + assert encoded.path == f"/api/v1/process-templates/{update.template_id}" + + +def test_process_run_codecs_keep_revision_in_if_match_only(): + template_id = uuid4() + draft = ProcessRunDraft( + name="run-1", description="first", template_id=template_id + ) + create = CreateProcessRun(namespace_path="beamline/amx", draft=draft) + encoded = COMMAND_REGISTRY.by_command(create).encode_request(create) + assert encoded.body == { + "name": "run-1", + "description": "first", + "template_id": str(template_id), + "assignments": {}, + "steps": {}, + } + assert encoded.path == "/api/v1/process-runs/beamline/amx" + + update = UpdateProcessRun( + process_run_id=uuid4(), expected_revision=4, description="finished" + ) + encoded = COMMAND_REGISTRY.by_command(update).encode_request(update) + assert encoded.body == { + "description": "finished", + "status": None, + "assignments": None, + "steps": None, + } + assert encoded.etag == '"4"' + assert "expected_revision" not in encoded.body + + +def test_create_codecs_preserve_supplied_ids(): + process_template_id = uuid4() + resource_template_id = uuid4() + process_run_id = uuid4() + process_template = CreateProcessTemplate( + namespace_path="beamline", + draft=ProcessTemplateDraft(id=process_template_id, name="pt", version="1"), + ) + resource_template = CreateResourceTemplate( + namespace_path="beamline", + draft=ResourceTemplateDraft( + id=resource_template_id, name="rt", version="1", type_names=[] + ), + ) + process_run = CreateProcessRun( + namespace_path="beamline", + draft=ProcessRunDraft( + id=process_run_id, name="run", description="", template_id=uuid4() + ), + ) + + assert COMMAND_REGISTRY.by_command(process_template).encode_request( + process_template + ).body["id"] == str(process_template_id) + assert COMMAND_REGISTRY.by_command(resource_template).encode_request( + resource_template + ).body["id"] == str(resource_template_id) + assert COMMAND_REGISTRY.by_command(process_run).encode_request(process_run).body[ + "id" + ] == str(process_run_id) + + +def test_process_run_route_decoder_reads_if_match_and_forbids_unknown_body_fields(): + registration = COMMAND_REGISTRY.by_type(UpdateProcessRun) + process_run_id = uuid4() + command = registration.decode_command( + {"process_run_id": process_run_id}, + {"If-Match": 'W/"7"'}, + UpdateProcessRunRequest(description="finished"), + ) + assert command.process_run_id == process_run_id + assert command.expected_revision == 7 + assert command.description == "finished" + + with pytest.raises(ValueError): + UpdateProcessRunRequest.model_validate({"description": "x", "unknown": 1}) + + +def test_service_dispatches_process_run_via_registered_handler(monkeypatch): + command = UpdateProcessRun(process_run_id=uuid4(), expected_revision=1, description="x") + result = object() + calls = [] + + def handler(self, context, **kwargs): + calls.append(kwargs) + return result + + monkeypatch.setattr(CommandService, "update_process_run", handler) + assert CommandService(None).execute(command, context=object()) is result + assert calls == [{ + "process_run_id": command.process_run_id, + "expected_revision": 1, + "description": "x", + "status": None, + "assignments": None, + "steps": None, + }] diff --git a/recap/tests/test_container.py b/recap/tests/test_container.py deleted file mode 100644 index d61c64f..0000000 --- a/recap/tests/test_container.py +++ /dev/null @@ -1,109 +0,0 @@ -from recap.db.attribute import AttributeTemplate -from recap.utils.database import get_or_create - - -def test_attribute(db_session): - from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate - from recap.db.resource import ResourceTemplate, ResourceType - - # container_type = ResourceType(name="container") - container_type, _ = get_or_create( - db_session, ResourceType, where={"name": "container"} - ) - prop_type = AttributeGroupTemplate( - name="TestProp" - ) # , value_type="int", unit="kg") - prop_value_template = AttributeTemplate( - name="test_value", value_type="int", unit="kg", default_value=3 - ) - prop_type.attribute_templates.append(prop_value_template) - db_session.add(prop_type) - container_template = ResourceTemplate( - name="TestContainer", - attribute_group_templates=[prop_type], - types=[container_type], - ) - db_session.add(container_type) - db_session.add(container_template) - - db_session.commit() - - result = db_session.query(ResourceTemplate).filter_by(name="TestContainer").first() - - assert result.attribute_group_templates[0].attribute_templates[0].unit == "kg" - assert result.types[0].name == "container" - - -def test_container_type(db_session): - from recap.db.resource import ResourceTemplate, ResourceType - - container_type, _ = get_or_create( - db_session, ResourceType, where={"name": "container"} - ) - container = ResourceTemplate(name="test", types=[container_type]) - db_session.add(container_type) - db_session.add(container) - db_session.commit() - - result = db_session.query(ResourceTemplate).filter_by(name="test").first() - assert result.name == "test" - - -def test_container(db_session): - from recap.db.attribute import AttributeGroupTemplate - from recap.db.resource import Resource, ResourceTemplate, ResourceType - - container_type, _ = get_or_create( - db_session, ResourceType, where={"name": "container"} - ) - prop_type = AttributeGroupTemplate(name="TestPropType") # , - prop_value_template = AttributeTemplate( - name="test_prop_val", value_type="int", unit="kg", default_value="10" - ) - prop_type.attribute_templates.append(prop_value_template) - db_session.add(prop_type) - container_template = ResourceTemplate( - name="TestContainerType", - attribute_group_templates=[prop_type], - types=[container_type], - ) - db_session.add(container_template) - db_session.commit() - - container = Resource(name="TestContainer", template=container_template) - db_session.add(container) - - db_session.commit() - - result = db_session.query(Resource).filter_by(name="TestContainer").first() - - assert result.properties["TestPropType"].values["test_prop_val"] == 10 - - child_prop_type = AttributeGroupTemplate(name="ChildPropTest") - child_value_template = AttributeTemplate( - name="child_prop_test", value_type="float", unit="mm", default_value="2.2" - ) - child_prop_type.attribute_templates.append(child_value_template) - db_session.add(child_prop_type) - - child_container_type = ResourceTemplate( - name="ChildTestContainerType", - attribute_group_templates=[child_prop_type], - types=[container_type], - ) - db_session.add(child_container_type) - db_session.commit() - - child_container_a1 = Resource(name="A1", template=child_container_type) - child_container_a2 = Resource(name="A2", template=child_container_type) - - container.children[child_container_a1.name] = child_container_a1 - container.children[child_container_a2.name] = child_container_a2 - db_session.commit() - - result = db_session.query(Resource).filter_by(name="TestContainer").first() - - assert len(result.children) == 2 - children = sorted(result.children.values(), key=lambda c: c.name) - assert children[0].name == "A1" - assert children[1].properties["ChildPropTest"].values["child_prop_test"] == 2.2 diff --git a/recap/tests/test_database_config.py b/recap/tests/test_database_config.py new file mode 100644 index 0000000..a8d764f --- /dev/null +++ b/recap/tests/test_database_config.py @@ -0,0 +1,97 @@ +import os + +import pytest +from pydantic import ValidationError +from sqlalchemy import text +from sqlalchemy.pool import QueuePool + +from recap.db.engine import create_engine_and_session_factory +from recap.server.config import ServerConfig + + +def test_db_path_selects_sqlite_and_sets_busy_timeout(tmp_path): + config = ServerConfig(db_path=tmp_path / "recap.db", api_key="secret") + engine, factory = create_engine_and_session_factory(config) + + assert config.database_url == f"sqlite:///{tmp_path / 'recap.db'}" + assert factory.kw["bind"] is engine + with engine.connect() as connection: + assert connection.scalar(text("PRAGMA busy_timeout")) == 5000 + engine.dispose() + + +def test_database_uri_selects_postgresql_and_uses_pooling(): + config = ServerConfig( + database_uri="postgresql+psycopg://user:password@db.example/recap", + api_key="secret", + ) + engine, factory = create_engine_and_session_factory(config) + + assert config.database_url == ( + "postgresql+psycopg://user:password@db.example/recap" + ) + assert isinstance(engine.pool, QueuePool) + assert engine.pool._pre_ping is True + assert factory.kw["bind"] is engine + engine.dispose() + + +@pytest.mark.parametrize( + "values", + [ + {"api_key": "secret"}, + { + "db_path": "/tmp/recap.db", + "database_uri": "postgresql+psycopg://user:password@db/recap", + "api_key": "secret", + }, + ], +) +def test_exactly_one_database_location_is_required(values): + with pytest.raises(ValidationError, match="exactly one"): + ServerConfig(**values) + + +def test_database_uri_repr_redacts_credentials(): + config = ServerConfig( + database_uri="postgresql+psycopg://user:never-print-this@db/recap", + api_key="secret", + ) + + assert "never-print-this" not in repr(config) + assert "never-print-this" not in str(config) + + +def test_server_migrates_selected_database_url(monkeypatch): + from recap.server import app as app_module + + selected = "postgresql+psycopg://user:password@db/recap" + migrated = [] + monkeypatch.setattr(app_module, "apply_migrations", migrated.append) + monkeypatch.setattr( + app_module, + "create_engine_and_session_factory", + lambda config: (object(), lambda: None), + ) + + app_module.create_app(database_uri=selected) + + assert migrated == [selected] + + +@pytest.mark.skipif( + "RECAP_TEST_POSTGRES_URI" not in os.environ, + reason="RECAP_TEST_POSTGRES_URI is not configured", +) +def test_postgresql_transaction_can_roll_back(): + config = ServerConfig( + database_uri=os.environ["RECAP_TEST_POSTGRES_URI"], api_key="secret" + ) + engine, factory = create_engine_and_session_factory(config) + session = factory() + transaction = session.begin() + session.execute(text("SELECT 1")) + transaction.rollback() + assert not session.in_transaction() + session.close() + engine.dispose() diff --git a/recap/tests/test_db_models.py b/recap/tests/test_db_models.py index 2678a60..bd9858f 100644 --- a/recap/tests/test_db_models.py +++ b/recap/tests/test_db_models.py @@ -2,7 +2,7 @@ from sqlalchemy.exc import IntegrityError from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate -from recap.db.campaign import Campaign +from recap.db.namespace import Namespace from recap.db.process import ( Direction, ProcessRun, @@ -15,10 +15,20 @@ from recap.utils.general import make_slug -def test_attribute_group_template_slug_and_constraint(db_session): - process_template = ProcessTemplate(name="Pipeline", version="v1") +@pytest.fixture +def namespace(db_session): + namespace = Namespace(path="db-models", metadata_json={}) + db_session.add(namespace) + db_session.flush() + return namespace + + +def test_attribute_group_template_slug_and_constraint(db_session, namespace): + process_template = ProcessTemplate( + namespace=namespace, name="Pipeline", version="v1" + ) step_template = StepTemplate(name="Prep", process_template=process_template) - resource_template = ResourceTemplate(name="Instrument") + resource_template = ResourceTemplate(namespace=namespace, name="Instrument") valid_group = AttributeGroupTemplate( name="Group One", resource_template=resource_template ) @@ -42,8 +52,8 @@ def test_attribute_group_template_slug_and_constraint(db_session): db_session.rollback() -def test_resource_initialization_from_template(db_session): - resource_template = ResourceTemplate(name="Parent") +def test_resource_initialization_from_template(db_session, namespace): + resource_template = ResourceTemplate(namespace=namespace, name="Parent") attr_group = AttributeGroupTemplate( name="Settings", resource_template=resource_template ) @@ -57,7 +67,9 @@ def test_resource_initialization_from_template(db_session): db_session.add_all([resource_template, attr_group, child_template]) db_session.flush() - resource = Resource(name="Main Resource", template=resource_template) + resource = Resource( + namespace=namespace, name="Main Resource", template=resource_template + ) db_session.add(resource) db_session.flush() @@ -70,31 +82,44 @@ def test_resource_initialization_from_template(db_session): assert child.name == child_template.name -def test_process_run_validates_resource_assignments(db_session): +def test_process_run_validates_resource_assignments(db_session, namespace): resource_type = ResourceType(name="Microscope") - process_template = ProcessTemplate(name="Acquisition", version="v1") + process_template = ProcessTemplate( + namespace=namespace, name="Acquisition", version="v1" + ) slot = ResourceSlot( name="instrument", process_template=process_template, resource_type=resource_type, direction=Direction.input, ) - campaign = Campaign(name="Campaign", proposal="P1", saf=None, meta_data=None) + slot_two = ResourceSlot( + name="instrument-2", + process_template=process_template, + resource_type=resource_type, + direction=Direction.input, + ) matching_template = ResourceTemplate( - name="MicroscopeTemplate", types=[resource_type] + namespace=namespace, name="MicroscopeTemplate", types=[resource_type] + ) + matching_resource = Resource( + namespace=namespace, name="Scope A", template=matching_template ) - matching_resource = Resource(name="Scope A", template=matching_template) wrong_type = ResourceType(name="Other") - wrong_template = ResourceTemplate(name="OtherTemplate", types=[wrong_type]) - wrong_resource = Resource(name="Other A", template=wrong_template) + wrong_template = ResourceTemplate( + namespace=namespace, name="OtherTemplate", types=[wrong_type] + ) + wrong_resource = Resource( + namespace=namespace, name="Other A", template=wrong_template + ) run = ProcessRun( + namespace=namespace, name="run-1", description="desc", template=process_template, - campaign=campaign, ) db_session.add_all( @@ -102,7 +127,7 @@ def test_process_run_validates_resource_assignments(db_session): resource_type, process_template, slot, - campaign, + slot_two, matching_template, matching_resource, wrong_type, @@ -117,17 +142,12 @@ def test_process_run_validates_resource_assignments(db_session): db_session.flush() assert run.assignments[slot].resource is matching_resource - slot_two = ResourceSlot( - name="instrument-2", - process_template=process_template, - resource_type=resource_type, - direction=Direction.input, - ) - db_session.add(slot_two) with pytest.raises(ValueError): run.resources[slot_two] = wrong_resource - another_resource = Resource(name="Scope B", template=matching_template) + another_resource = Resource( + namespace=namespace, name="Scope B", template=matching_template + ) db_session.add(another_resource) with pytest.raises(ValueError): run.assignments[slot] = ResourceAssignment( @@ -135,8 +155,8 @@ def test_process_run_validates_resource_assignments(db_session): ) -def test_step_initializes_parameters_from_template(db_session): - process_template = ProcessTemplate(name="Prep", version="1.0") +def test_step_initializes_parameters_from_template(db_session, namespace): + process_template = ProcessTemplate(namespace=namespace, name="Prep", version="1.0") step_template = StepTemplate(name="Incubate", process_template=process_template) attr_group = AttributeGroupTemplate(name="Conditions", step_template=step_template) AttributeTemplate( @@ -145,15 +165,14 @@ def test_step_initializes_parameters_from_template(db_session): default_value=30, attribute_group_template=attr_group, ) - campaign = Campaign(name="C1", proposal="P1", saf=None, meta_data=None) run = ProcessRun( + namespace=namespace, name="run-001", description="desc", template=process_template, - campaign=campaign, ) - db_session.add_all([process_template, step_template, attr_group, campaign, run]) + db_session.add_all([process_template, step_template, attr_group, run]) db_session.flush() step = run.steps["Incubate"] @@ -163,44 +182,42 @@ def test_step_initializes_parameters_from_template(db_session): assert param.values["Duration"] == 30 -def test_resource_slug_and_active_toggle(db_session): - tmpl1 = ResourceTemplate(name="Machine") - tmpl2 = ResourceTemplate(name="Machine2") +def test_resource_slug_and_duplicate_top_level_names(db_session, namespace): + tmpl1 = ResourceTemplate(namespace=namespace, name="Machine") + tmpl2 = ResourceTemplate(namespace=namespace, name="Machine2") db_session.add_all([tmpl1, tmpl2]) db_session.flush() - first = Resource(name="Shared Name", template=tmpl1) + first = Resource(namespace=namespace, name="Shared Name", template=tmpl1) db_session.add(first) db_session.flush() assert first.slug == make_slug("Shared Name") - assert first.active is True - second = Resource(name="Shared Name", template=tmpl2) + second = Resource(namespace=namespace, name="Shared Name", template=tmpl2) db_session.add(second) db_session.flush() - # new one is active, old one was deactivated by the before_insert hook - assert second.active is True - db_session.refresh(first) - assert first.active is False + assert second.slug == make_slug("Shared Name") + assert first.id != second.id + assert not hasattr(first, "active") -def test_resource_children_respect_max_depth(db_session): - tmpl = ResourceTemplate(name="Parent") +def test_resource_children_respect_max_depth(db_session, namespace): + tmpl = ResourceTemplate(namespace=namespace, name="Parent") ResourceTemplate(name="Child", parent=tmpl) db_session.add(tmpl) db_session.flush() - resource = Resource(name="Root", template=tmpl, _max_depth=0) + resource = Resource(namespace=namespace, name="Root", template=tmpl, _max_depth=0) db_session.add(resource) db_session.flush() assert resource.children == {} -def test_property_values_reject_unknown_keys(db_session): - tmpl = ResourceTemplate(name="Machine") +def test_property_values_reject_unknown_keys(db_session, namespace): + tmpl = ResourceTemplate(namespace=namespace, name="Machine") group = AttributeGroupTemplate(name="Props", resource_template=tmpl) AttributeTemplate( name="Voltage", @@ -211,7 +228,7 @@ def test_property_values_reject_unknown_keys(db_session): db_session.add_all([tmpl, group]) db_session.flush() - res = Resource(name="Machine A", template=tmpl) + res = Resource(namespace=namespace, name="Machine A", template=tmpl) db_session.add(res) db_session.flush() @@ -219,8 +236,8 @@ def test_property_values_reject_unknown_keys(db_session): res.properties["Props"].values["Unknown"] = 1 -def test_resource_slot_name_unique_per_process_template(db_session): - pt = ProcessTemplate(name="PT", version="1") +def test_resource_slot_name_unique_per_process_template(db_session, namespace): + pt = ProcessTemplate(namespace=namespace, name="PT", version="1") slot1 = ResourceSlot( name="instrument", process_template=pt, @@ -239,12 +256,16 @@ def test_resource_slot_name_unique_per_process_template(db_session): db_session.rollback() -def test_step_uses_template_name_when_missing(db_session): - pt = ProcessTemplate(name="PT2", version="1") +def test_step_uses_template_name_when_missing(db_session, namespace): + pt = ProcessTemplate(namespace=namespace, name="PT2", version="1") st = StepTemplate(name="T1", process_template=pt) - campaign = Campaign(name="C2", proposal="p", saf=None, meta_data=None) - run = ProcessRun(name="run-step", description="", template=pt, campaign=campaign) - db_session.add_all([pt, st, campaign, run]) + run = ProcessRun( + namespace=namespace, + name="run-step", + description="", + template=pt, + ) + db_session.add_all([pt, st, run]) db_session.flush() # ProcessRun __init__ auto-creates steps; ensure the default naming uses the template @@ -252,8 +273,8 @@ def test_step_uses_template_name_when_missing(db_session): assert auto_step.name == st.name -def test_step_template_binding_role_unique(db_session): - pt = ProcessTemplate(name="PT3", version="1") +def test_step_template_binding_role_unique(db_session, namespace): + pt = ProcessTemplate(namespace=namespace, name="PT3", version="1") st = StepTemplate(name="T2", process_template=pt) rt = ResourceType(name="slot-type") slot = ResourceSlot( diff --git a/recap/tests/test_draft_builders.py b/recap/tests/test_draft_builders.py new file mode 100644 index 0000000..4799539 --- /dev/null +++ b/recap/tests/test_draft_builders.py @@ -0,0 +1,604 @@ +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from recap.client.backend import ClientBackend +from recap.commands.models import ( + CreateProcessRun, + CreateProcessTemplate, + CreateResource, +) +from recap.dsl.builder_state import BuilderChanges, BuilderTransactionState +from recap.dsl.process_builder import ProcessRunBuilder, ProcessTemplateBuilder +from recap.dsl.resource_builder import ResourceBuilder, ResourceTemplateBuilder +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext +from recap.schemas.resource import ResourceSchema, ResourceTemplateSchema +from recap.tests.transport_factories import minimal_resource, resource_template +from recap.utils.general import Direction + + +class RecordingBackend: + def __init__(self, existing=None): + self.commands = [] + self.existing = existing + self.resource_template = None + + def get_process_template(self, *args, **kwargs): + return self.existing + + def find_resources_by_identity(self, *args, **kwargs): + return [] + + def query(self, schema, *args, **kwargs): + if ( + schema is ResourceTemplateSchema + and args + and args[0].filters.get("name") is not None + ): + self.resource_template = resource_template().model_copy( + update={ + "name": args[0].filters["name"], + "version": args[0].filters["version"], + } + ) + return [self.resource_template] + if schema is ResourceTemplateSchema and self.resource_template is not None: + return [self.resource_template] + if ( + schema.__name__ == "ProcessTemplateSchema" + and args + and args[0].filters.get("id") is not None + ): + return [SimpleNamespace(id=uuid4(), step_templates={}, resource_slots=[])] + return [] + + def execute(self, command, context): + self.commands.append((command, context)) + return self.existing + + def count(self, *args, **kwargs): + return 0 + + def list_child_namespaces(self, parent_path): + return [] + + def create_namespace(self, path, metadata, context): + return None + + def update_namespace( + self, namespace_id, expected_revision, metadata, status, context, *, etag=None + ): + return None + + +class RecordingReader: + def __init__(self, existing=None): + self.existing = existing + self.queries = [] + + def query(self, schema, spec, *, namespace_path): + self.queries.append((schema, spec, namespace_path)) + if schema is ResourceTemplateSchema: + template = self.existing or resource_template() + if "name" in spec.filters: + template = template.model_copy( + update={ + "name": spec.filters["name"], + "version": spec.filters["version"], + } + ) + return [template] + return [] + + def count(self, schema, spec, *, namespace_path): + return 0 + + +class RecordingWriter: + def __init__(self, existing=None): + self.existing = existing + self.commands = [] + + def execute(self, command, context): + self.commands.append((command, context)) + return self.existing + + +class RecordingNamespaces: + def list_child_namespaces(self, parent_path): + return [] + + +class RecordingNamespaceWriter: + def create_namespace(self, path, metadata, context): + return None + + def update_namespace( + self, namespace_id, expected_revision, metadata, status, context, *, etag=None + ): + return None + + +class RecordingNamspaceContextResolver: + def get_namespace_context(self, path): + return NamespaceContext(id=uuid4(), path=path) + + +def resource_backend(): + reader = RecordingReader() + writer = RecordingWriter() + context_resolver = RecordingNamspaceContextResolver() + return ( + ClientBackend( + reader=reader, + writer=writer, + namespaces=RecordingNamespaces(), + namespace_writer=RecordingNamespaceWriter(), + context_resolver=context_resolver, + ), + reader, + writer, + ) + + +def test_transaction_state_flushes_only_on_clean_outermost_exit(): + state = BuilderTransactionState() + state.enter() + state.enter() + assert not state.exit(None) + assert state.exit(None) + + +def test_transaction_state_preserves_pending_lifecycle_after_exception(): + state = BuilderTransactionState() + state.enter() + state.request_lifecycle(LifecycleStatus.ACTIVE) + assert not state.exit(RuntimeError) + assert state.pending_lifecycle is LifecycleStatus.ACTIVE + + +def test_transaction_state_remembers_swallowed_nested_exception(): + state = BuilderTransactionState() + state.enter() + state.enter() + assert not state.exit(RuntimeError) + assert not state.exit(None) + + state.enter() + assert state.exit(None) + + +def test_transaction_state_rejects_conflicting_lifecycle_requests(): + state = BuilderTransactionState() + state.request_lifecycle(LifecycleStatus.ACTIVE) + with pytest.raises(ValueError, match="Conflicting lifecycle requests"): + state.request_lifecycle(LifecycleStatus.ARCHIVED) + + +def test_transaction_state_tracks_lifecycle_by_builder_owner(): + state = BuilderTransactionState() + parent = object() + child = object() + state.request_lifecycle(LifecycleStatus.ACTIVE, owner=parent) + state.request_lifecycle(LifecycleStatus.ACTIVE, owner=child) + + assert state.pending_lifecycle_for(parent) is LifecycleStatus.ACTIVE + assert state.pending_lifecycle_for(child) is LifecycleStatus.ACTIVE + state.clear_lifecycle(owner=child) + assert state.pending_lifecycle_for(parent) is LifecycleStatus.ACTIVE + assert state.pending_lifecycle_for(child) is None + + +def test_builder_changes_defaults_to_empty_fields_and_no_lifecycle(): + changes = BuilderChanges() + assert changes.fields == {} + assert changes.lifecycle is None + + +def test_all_builders_require_context_for_save_and_expose_serializable_empty_changes(client): + client.create_namespace("builder-contracts") + scoped = client.namespace("builder-contracts") + with scoped.build_resource_template( + name="contract-resource-template", type_names=["sample"] + ): + pass + with scoped.build_process_template("contract-process-template", "1.0"): + pass + + builders = [ + scoped.build_resource_template( + name="contract-resource-template-2", type_names=["sample"] + ), + scoped.build_process_template("contract-process-template-2", "1.0"), + scoped.build_resource("contract-resource", "contract-resource-template"), + scoped.build_process_run( + "contract-process-run", + "", + "contract-process-template", + "1.0", + ), + ] + + for builder in builders: + assert not hasattr(builder, "activate") + with pytest.raises(RuntimeError, match="require a context manager"): + builder.save() + with builder: + pass + assert builder.changes().model_dump(mode="json") == { + "fields": {}, + "lifecycle": None, + } + + +def process_backend(adapter=None): + adapter = adapter or RecordingBackend() + context_resolver = RecordingNamspaceContextResolver() + return ( + ClientBackend( + reader=adapter, + writer=adapter, + namespaces=adapter, + namespace_writer=adapter, + context_resolver=context_resolver, + ), + adapter, + ) + + +def test_process_template_body_has_no_backend_mutation(): + client_backend, backend = process_backend() + context = object() + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + builder = ProcessTemplateBuilder( + backend=client_backend, + namespace_context=namespace_context, + name="draft-template", + version="1.0", + command_context=context, + ) + + builder.add_resource_slot("input", "container", Direction.input) + step = builder.add_step("measure") + + assert backend.commands == [] + assert step.parent.backend is client_backend + + +def test_process_run_exception_retains_draft_without_submitting(): + client_backend, backend = process_backend() + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + builder = ProcessRunBuilder( + name="run", + description="desc", + template_name="template", + version="1.0", + namespace_context=namespace_context, + backend=client_backend, + template_id=uuid4(), + command_context=object(), + ) + + try: + with builder: + builder.assign_resource("input", type("Resource", (), {"id": uuid4()})()) + raise RuntimeError("discard") + except RuntimeError: + pass + + assert backend.commands == [] + assert builder.changes().fields["assignments"] + + +def test_process_builders_submit_one_command_on_clean_exit(): + client_backend, backend = process_backend() + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + context = object() + with ProcessTemplateBuilder( + backend=client_backend, + name="template", + version="1.0", + namespace_context=namespace_context, + command_context=context, + ) as builder: + builder.add_step("measure") + + assert len(backend.commands) == 1 + assert isinstance(backend.commands[0][0], CreateProcessTemplate) + + +def test_process_run_builder_submits_one_aggregate_command(): + client_backend, backend = process_backend() + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + builder = ProcessRunBuilder( + name="run", + description="desc", + template_name="template", + version="1.0", + backend=client_backend, + namespace_context=namespace_context, + template_id=uuid4(), + command_context=object(), + ) + + builder.assign_resource("input", type("Resource", (), {"id": uuid4()})()) + with builder: + pass + + assert len(backend.commands) == 1 + assert isinstance(backend.commands[0][0], CreateProcessRun) + + +def test_resource_builder_has_no_construction_side_effect_and_submits_once(): + client_backend, reader, writer = resource_backend() + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + builder = ResourceBuilder( + name="resource", + template_name="template", + backend=client_backend, + namespace_context=namespace_context, + command_context=object(), + ) + + assert writer.commands == [] + with builder: + pass + + assert reader.queries + assert len(writer.commands) == 1 + assert isinstance(writer.commands[0][0], CreateResource) + + +def test_resource_builder_serializes_property_values_into_create_command(): + client_backend, _, writer = resource_backend() + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + builder = ResourceBuilder( + name="resource-with-values", + template_name="template", + backend=client_backend, + namespace_context=namespace_context, + command_context=object(), + ) + builder._resource = SimpleNamespace( + properties={ + "measurements": SimpleNamespace( + values=SimpleNamespace( + dose=SimpleNamespace( + value=12, + unit="mg", + metadata_json={"source": "builder"}, + ) + ) + ) + } + ) + with builder: + pass + + assert writer.commands[0][0].properties == { + "measurements": { + "dose": { + "value": 12, + "unit": "mg", + "metadata_json": {"source": "builder"}, + } + } + } + + +def test_resource_child_builder_reuses_client_backend(): + client_backend, _, _ = resource_backend() + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + builder = ResourceBuilder( + name="parent", + template_name="template", + backend=client_backend, + command_context=object(), + namespace_context=namespace_context, + ) + + child = builder.add_child("child", "template") + + assert child.backend is client_backend + + +def test_client_routes_local_builders_through_commands_without_begin(client): + client.create_namespace("command") + scoped = client.namespace("command") + backend = scoped.connection_state.backend + + with scoped.build_process_template("command-pt", "1.0"): + pass + with scoped.build_resource_template(name="command-rt", type_names=["container"]): + pass + + assert not hasattr(backend, "begin") + + process_template = scoped.build_process_template("command-pt-2", "1.0") + process_run = scoped.build_process_run( + "command-run", "description", "command-pt", "1.0" + ) + resource_template = scoped.build_resource_template( + name="command-rt-2", type_names=["container"] + ) + resource = scoped.build_resource( + "command-resource", "command-rt", on_existing="create" + ) + + for builder in (process_template, process_run, resource_template, resource): + assert builder._command_context is not None + assert resource_template.backend is backend + assert resource.backend is backend + + +def test_resource_template_command_draft_accepts_attribute_group_builder(): + client_backend, _, writer = resource_backend() + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + builder = ResourceTemplateBuilder( + name="template", + type_names=["container"], + backend=client_backend, + namespace_context=namespace_context, + command_context=object(), + ) + + builder.prop_group("properties").add_attribute( + "serial", "str", "", "" + ).close_group() + with builder: + pass + + assert writer.commands[0][0].draft.property_groups[0].attributes[0].name == "serial" + + +def test_process_run_command_builder_rejects_missing_template_and_run_ids(): + import pytest + + client_backend, backend = process_backend() + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + + with pytest.raises(ValueError, match="template_id or process_run_id"): + ProcessRunBuilder( + "run", + "description", + "template", + backend=client_backend, + namespace_context=namespace_context, + command_context=object(), + ) + + +def test_process_run_command_save_tolerates_none_or_partial_result(): + class ResultBackend(RecordingBackend): + def __init__(self, result): + super().__init__() + self.result = result + + def execute(self, command, context): + self.commands.append((command, context)) + return self.result + + for result in (None, object()): + client_backend, backend = process_backend(ResultBackend(result)) + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + builder = ProcessRunBuilder( + "run", + "description", + "template", + backend=client_backend, + namespace_context=namespace_context, + template_id=uuid4(), + command_context=object(), + ) + builder.assign_resource("input", type("Resource", (), {"id": uuid4()})()) + with builder: + pass + + assert len(backend.commands) == 1 + assert builder._dirty + assert not builder._submitted + + +def test_resource_reload_preloads_guarded_relations(): + class ExistingResourceReader(RecordingReader): + def query(self, schema, spec, *, namespace_path): + self.queries.append((schema, spec, namespace_path)) + if schema is ResourceSchema: + return [minimal_resource()] + return super().query(schema, spec, namespace_path=namespace_path) + + reader = ExistingResourceReader() + writer = RecordingWriter() + backend = ClientBackend( + reader=reader, + writer=writer, + namespaces=RecordingNamespaces(), + namespace_writer=RecordingNamespaceWriter(), + context_resolver=RecordingNamspaceContextResolver(), + ) + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + ResourceBuilder( + name=None, + template_name=None, + backend=backend, + resource_id=uuid4(), + namespace_context=namespace_context, + command_context=object(), + ) + + resource_query = next( + spec for schema, spec, _ in reader.queries if schema is ResourceSchema + ) + assert resource_query.preloads == ["template", "parent", "children", "properties"] + + +def test_process_run_builder_loads_template_without_client_lookup(): + template_id = uuid4() + + class QueryBackend(RecordingBackend): + def query(self, schema, spec, *, namespace_path): + if spec.filters == {"name": "template", "version": "1.0"}: + return [ + SimpleNamespace( + id=template_id, + step_templates={}, + resource_slots=[], + ) + ] + return [] + + client_backend, backend = process_backend(QueryBackend()) + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + builder = ProcessRunBuilder( + "run", + "description", + "template", + backend=client_backend, + namespace_context=namespace_context, + version="1.0", + command_context=object(), + ) + + assert builder._template_id == template_id + + +def test_process_run_command_save_handles_missing_template_steps(): + client_backend, backend = process_backend() + namespace_context = NamespaceContext(id=uuid4(), path="beamline/amx") + builder = ProcessRunBuilder( + "run", + "description", + "template", + backend=client_backend, + namespace_context=namespace_context, + template_id=uuid4(), + command_context=object(), + ) + builder._process_template = SimpleNamespace(step_templates=None) + + with builder: + pass + + assert len(backend.commands) == 1 + + +def test_resource_reuse_with_changed_properties_submits_update(client): + with client.build_resource_template( + name="ReuseUpdateRT", type_names=["container"] + ) as template: + template.prop_group("properties").add_attribute( + "serial", "str", "", "" + ).close_group() + first = client.create_resource("ReuseUpdate", "ReuseUpdateRT", on_existing="create") + + with client.build_resource( + "ReuseUpdate", "ReuseUpdateRT", on_existing="silent" + ) as builder: + builder.resource.properties["properties"].values["serial"] = "changed" + + with client.build_resource(resource_id=first.id) as verifier: + assert ( + verifier.resource.properties["properties"].values.serial.value == "changed" + ) diff --git a/recap/tests/test_end_to_end.py b/recap/tests/test_end_to_end.py new file mode 100644 index 0000000..2637f9c --- /dev/null +++ b/recap/tests/test_end_to_end.py @@ -0,0 +1,81 @@ +"""End-to-end public API workflow for local and REST deployments.""" + +import pytest + +from recap.client import RecapClient +from recap.lifecycle import LifecycleStatus +from recap.utils.general import Direction + + +@pytest.fixture(params=["local", "remote"], ids=["local", "rest"]) +def end_to_end_client(request, tmp_path, rest_loopback_client): + if request.param == "local": + with RecapClient.from_sqlite(tmp_path / "end-to-end.db") as client: + yield client + return + + yield rest_loopback_client + + +@pytest.mark.integration +def test_complete_recap_workflow(end_to_end_client): + """Create, execute, and query a complete provenance workflow.""" + client = end_to_end_client + client.create_namespace("facility") + client.create_namespace("facility/beamline") + scoped = client.namespace("facility/beamline") + + namespace = scoped.update_namespace( + metadata={"beamline": "AMX", "proposal": "E2E-001"} + ) + assert namespace.metadata == {"beamline": "AMX", "proposal": "E2E-001"} + + with scoped.build_resource_template( + name="E2E plate", type_names=["container", "plate"] + ) as resource_template: + resource_template.add_properties( + {"metrics": [{"name": "rating", "type": "int", "default": 1}]} + ) + resource_template.finalize() + + plate = scoped.create_resource("plate-001", "E2E plate") + with scoped.build_resource(resource_id=plate.id) as resource_builder: + resource = resource_builder.get_model() + resource.properties.metrics.rating = 12 + resource_builder.set_model(resource) + resource_builder.finalize() + + with scoped.build_process_template("E2E workflow", "1.0") as process_template: + process_template.add_resource_slot("plate", "container", Direction.input) + ( + process_template.add_step("Collect") + .add_parameters( + {"exposure": [{"name": "dwell", "type": "int", "default": 1}]} + ) + .bind_slot("source", "plate") + .close_step() + ) + process_template.finalize() + + with scoped.build_process_run( + "run-001", "End-to-end collection", "E2E workflow", "1.0" + ) as process_run: + process_run.assign_resource("plate", plate) + parameters = process_run.get_params("Collect") + parameters.exposure.dwell = 15 + process_run.set_params(parameters) + process_run.finalize() + + query = scoped.query_maker() + loaded_plate = query.resources(load="eager").filter(name="plate-001").first() + loaded_run = query.process_runs(load="eager").filter(name="run-001").first() + + assert loaded_plate is not None + assert loaded_plate.status is LifecycleStatus.ACTIVE + assert loaded_plate.properties.metrics.rating.value == 12 + assert loaded_run is not None + assert loaded_run.status is LifecycleStatus.ACTIVE + assert loaded_run.steps["Collect"].parameters.exposure.dwell.value == 15 + assert loaded_run.assigned_resources["plate"].resource is loaded_plate + assert query.resources().filter(name="plate-001").count() == 1 + assert query.process_runs().filter(name="run-001").count() == 1 diff --git a/recap/tests/test_exceptions.py b/recap/tests/test_exceptions.py index d17ee1e..c238856 100644 --- a/recap/tests/test_exceptions.py +++ b/recap/tests/test_exceptions.py @@ -1,9 +1,13 @@ def test_recap_connection_error_importable(): from recap.exceptions import RecapConnectionError - err = RecapConnectionError("http://localhost:8000", 404) - assert "http://localhost:8000" in str(err) - assert "404" in str(err) + err = RecapConnectionError( + "Connection failed", url="http://localhost:8000", status_code=404 + ) + assert err.message == "Connection failed" + assert err.url == "http://localhost:8000" + assert err.status_code == 404 + assert str(err) == "Connection failed; HTTP 404" def test_recap_connection_error_is_exception(): diff --git a/recap/tests/test_experiment.py b/recap/tests/test_experiment.py deleted file mode 100644 index 4d300fc..0000000 --- a/recap/tests/test_experiment.py +++ /dev/null @@ -1,5 +0,0 @@ -def test_experiment(db_session): - from recap.db.process import ProcessTemplate - - experiment_type = ProcessTemplate(name="Test Experiment") - db_session.add(experiment_type) diff --git a/recap/tests/test_exporter.py b/recap/tests/test_exporter.py new file mode 100644 index 0000000..9b3d427 --- /dev/null +++ b/recap/tests/test_exporter.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import importlib +import inspect +from dataclasses import dataclass +from io import StringIO +from pathlib import Path +from uuid import uuid4 + +import pytest +from pydantic import BaseModel + +from recap.dsl.query import BaseQuery +from recap.exporters.protocol import ExportContext, Exporter +from recap.exporters.registry import ( + ExporterRegistry, + default_exporter_registry, +) +from recap.schemas.namespace import NamespaceContext + + +class Record(BaseModel): + id: int + name: str + + +class FakeBackend: + def __init__(self, item: Record): + self.item = item + self.query_calls = 0 + + def query(self, model, spec, *, namespace_path): + self.query_calls += 1 + return [self.item] + + def count(self, model, spec, *, namespace_path): + raise AssertionError("export must not use count query") + + +class RecordQuery(BaseQuery[Record]): + model = Record + + +@dataclass +class RecordsExporter: + calls: list[tuple[ExportContext, Path | StringIO | None]] + + def export(self, context: ExportContext, destination): + self.calls.append((context, destination)) + return {"items": [item.model_dump() for item in context.items]} + + +@pytest.fixture +def query_and_backend(): + item = Record(id=1, name="canonical") + backend = FakeBackend(item) + query = RecordQuery( + backend, + context=NamespaceContext(id=uuid4(), path="test/export"), + ) + return query, backend, item + + +def test_export_executes_once_and_passes_canonical_items_and_destination( + query_and_backend, tmp_path +): + query, backend, item = query_and_backend + exporter = RecordsExporter([]) + name = f"records-{uuid4().hex}" + default_exporter_registry.register(name, exporter) + destination = tmp_path / "records.out" + + result = query.export(name, destination) + + assert result == {"items": [{"id": 1, "name": "canonical"}]} + assert backend.query_calls == 1 + context, received_destination = exporter.calls[0] + assert context.query is query + assert context.items[0] is item + assert received_destination is destination + + +def test_export_forwards_file_object_and_is_deterministic(query_and_backend): + query, _, _ = query_and_backend + exporter = RecordsExporter([]) + name = f"records-{uuid4().hex}" + default_exporter_registry.register(name, exporter) + destination = StringIO() + + first = query.export(name, destination) + second = query.export(name, destination) + + assert first == second + assert [call[1] for call in exporter.calls] == [destination, destination] + + +def test_export_rejects_unknown_format(query_and_backend): + query, _, _ = query_and_backend + + with pytest.raises(KeyError, match="missing"): + query.export("missing") + + +def test_registry_rejects_duplicate_registration(): + registry = ExporterRegistry() + exporter = RecordsExporter([]) + registry.register("records", exporter) + + with pytest.raises(ValueError, match="records"): + registry.register("records", exporter) + + +def test_export_propagates_exporter_exception(query_and_backend): + query, backend, _ = query_and_backend + + class FailingExporter: + def export(self, context, destination): + raise RuntimeError("export failed") + + name = f"failing-{uuid4().hex}" + default_exporter_registry.register(name, FailingExporter()) + + with pytest.raises(RuntimeError, match="export failed"): + query.export(name) + assert backend.query_calls == 1 + + +def test_export_rejects_scalar_count_results(query_and_backend): + query, _, _ = query_and_backend + query._backend.item = 1 + exporter = RecordsExporter([]) + name = f"records-{uuid4().hex}" + default_exporter_registry.register(name, exporter) + + with pytest.raises(TypeError, match="BaseModel"): + query.export(name) + assert exporter.calls == [] + + +def test_export_protocol_is_format_neutral(): + source = inspect.getsource(importlib.import_module(Exporter.__module__)).lower() + assert all(term not in source for term in ("json-ld", "rdf", "semantic", "ro-crate")) diff --git a/recap/tests/test_field_predicates.py b/recap/tests/test_field_predicates.py index b5acb30..272c9a0 100644 --- a/recap/tests/test_field_predicates.py +++ b/recap/tests/test_field_predicates.py @@ -6,7 +6,11 @@ from recap.db.process import ProcessRun from recap.db.resource import Resource, ResourceTemplate from recap.dsl.query import Field, FieldOrdering, FieldPredicate, QuerySpec -from recap.tests.test_query_dsl import make_query, seed_process_run +from recap.tests.test_query_dsl import ( # noqa: F401 + active_namespace, + make_query, + seed_process_run, +) def test_field_builds_structured_predicates_and_orderings(): @@ -208,21 +212,22 @@ def test_structured_predicate_coerces_transport_uuid(db_session): ) result = make_query(db_session).backend.query( - make_query(db_session).process_runs().model, spec + make_query(db_session).process_runs().model, + spec, + namespace_path=make_query(db_session).context.path, ) assert [item.id for item in result] == [run.id] def test_structured_predicate_resolves_relationship_and_reuses_join(db_session): - campaign, run = seed_process_run(db_session, name="shared-join") + namespace, run = seed_process_run(db_session, name="shared-join") result = ( make_query(db_session) .process_runs() - .where(Field("campaign.proposal") == campaign.proposal) - .where(Field("campaign.name") == campaign.name) - .order_by(Field("campaign.proposal"), Field("name")) + .where(Field("namespace.path") == namespace.path) + .order_by(Field("namespace.path"), Field("name")) .all() ) @@ -267,7 +272,7 @@ def test_structured_and_legacy_path_filters_share_joins(db_session): [ ("missing", "has no attribute 'missing'"), ("missing.name", "has no relationship 'missing'"), - ("campaign", "is a relationship, not a column"), + ("namespace", "is a relationship, not a column"), ], ) def test_structured_predicate_rejects_invalid_paths(db_session, field, message): diff --git a/recap/tests/test_fragment_screening.py b/recap/tests/test_fragment_screening.py deleted file mode 100644 index 6c9c409..0000000 --- a/recap/tests/test_fragment_screening.py +++ /dev/null @@ -1,365 +0,0 @@ -from sqlalchemy import select - -from recap.db.campaign import Campaign -from recap.db.step import StepTemplate -from recap.utils.database import get_or_create -from recap.utils.general import generate_uppercase_alphabets - - -def test_fragment_screening(db_session): - from recap.db.attribute import ( - AttributeGroupTemplate, - AttributeTemplate, - ) - - # noqa - from recap.db.resource import ( - Resource, - ResourceTemplate, - ResourceType, - ) - - """ - Testing fragment screening - - - Create container Resource type - - Library plate creation - - Create a library plate template - - Create library well templates and associate with plate template - - Create a library plate resource - - Xtal plate creation - - Create an xtal plate template - - Create xtal well type template and associate with xtal template - - Create Xtal plate resource - - Process Template creation - - Create Process template - - Create resource slots - - Create Step templates and connect them to resource slots - - Process Creation - - Create Process from template - - Add resources to the process - """ - # Create container Resource type - # container_type = ResourceType(name="container") - container_type, _ = get_or_create( - db_session, ResourceType, where={"name": "container"} - ) - - # Create a library plate template - lib_plate_1536_template = ResourceTemplate( - name="Library Plate 1536", types=[container_type] - ) - plate_dimensions_attr = AttributeGroupTemplate( - name="LB1536_dimensions", - ) - - num_rows_attr_val = AttributeTemplate( - name="rows", - value_type="int", - default_value="32", - ) - num_cols_attr_val = AttributeTemplate( - name="columns", - value_type="int", - default_value="48", - ) - plate_dimensions_attr.attribute_templates.append(num_rows_attr_val) - plate_dimensions_attr.attribute_templates.append(num_cols_attr_val) - - lib_plate_1536_template.attribute_group_templates.append(plate_dimensions_attr) - db_session.add(lib_plate_1536_template) - db_session.commit() - statement = select(ResourceTemplate).where( - ResourceTemplate.name == "Library Plate 1536" - ) - lib_plate_1536_template = db_session.scalars(statement).one() - assert ( - lib_plate_1536_template.attribute_group_templates[0] - .attribute_templates[0] - .default_value - == "32" - ) - - # Create library well templates and associate with plate template - a_to_af = generate_uppercase_alphabets(32) - lib_well_type_names_1536 = [ - {"name": f"{i}{str(j).zfill(2)}"} for i in a_to_af for j in range(1, 49) - ] - - # Well attributes - used = AttributeGroupTemplate( - name="well_status", - ) - used_value = AttributeTemplate(name="used", value_type="bool", default_value="true") - used.attribute_templates.append(used_value) - content_attr = AttributeGroupTemplate( - name="content", - ) - catalog_id = AttributeTemplate( - name="catalog_id", value_type="str", default_value="" - ) - smiles = AttributeTemplate(name="SMILES", value_type="str", default_value="") - sequence = AttributeTemplate(name="sequence", value_type="int", default_value="0") - - content_attr.attribute_templates.append(catalog_id) - content_attr.attribute_templates.append(smiles) - content_attr.attribute_templates.append(sequence) - - for well_data in lib_well_type_names_1536: - well = ResourceTemplate(name=well_data["name"], types=[container_type]) - well.attribute_group_templates.append(used) - well.attribute_group_templates.append(content_attr) - db_session.add(well) - lib_plate_1536_template.children[well.name] = well - - db_session.commit() - statement = select(ResourceTemplate).where( - ResourceTemplate.name == "Library Plate 1536" - ) - lib_plate_1536_template = db_session.scalars(statement).one() - assert lib_plate_1536_template.children["A01"].name == "A01" - - # Create a library plate resource - lib_plate = Resource( - name="Test LP1536", - template=lib_plate_1536_template, - ) - db_session.add(lib_plate) - db_session.commit() - - statement = select(Resource).where(Resource.name == "Test LP1536") - lib_plate = db_session.scalars(statement).one() - assert lib_plate.children["A01"].template.name == "A01" - assert lib_plate.properties["LB1536_dimensions"].values["rows"] == 32 - - from recap.db.attribute import ( - AttributeGroupTemplate, - AttributeTemplate, - ) - - # noqa - from recap.db.resource import Resource, ResourceTemplate # noqa - - # - Create an xtal plate template - xtal_plate_type = ResourceTemplate(name="SwissCI-MRC-2d", types=[container_type]) - a_to_h = generate_uppercase_alphabets(8) - a_to_p = generate_uppercase_alphabets(16) - - echo = [f"{i}{j}" for i in a_to_p for j in range(1, 13)] - shifter = [f"{i}{k}{j}" for i in a_to_h for j in ["a", "b"] for k in range(1, 13)] - plate_maps = [ - {"echo": i, "shifter": j} for i, j in zip(echo, shifter, strict=False) - ] - - well_position = AttributeGroupTemplate(name="well_position") - well_pos_x = AttributeTemplate(name="x", value_type="int", default_value="0") - well_pos_y_offset_0 = AttributeTemplate( - name="y_0", - value_type="int", - default_value="0", - ) - well_pos_y_offset_1350 = AttributeTemplate( - name="y_1350", - value_type="int", - default_value="1350", - ) - well_position.attribute_templates.append(well_pos_x) - well_position.attribute_templates.append(well_pos_y_offset_0) - well_position.attribute_templates.append(well_pos_y_offset_1350) - - # - Create xtal well type template and associate with xtal template - for plate_map in plate_maps: - echo_pos_attr = AttributeGroupTemplate(name="echo_pos") - x_offset = well_pos_x - if plate_map["shifter"][-1] == "b": - y_offset = well_pos_y_offset_0 - else: - y_offset = well_pos_y_offset_1350 - - xtal_well_type = ResourceTemplate( - name=plate_map["shifter"], - types=[container_type], - ) - echo_pos = AttributeTemplate( - name=f"echo_pos_{plate_map['echo']}", - value_type="str", - default_value=plate_map["echo"], - ) - xtal_well_type.attribute_group_templates.append(echo_pos_attr) - echo_pos_attr.attribute_templates.append(x_offset) - echo_pos_attr.attribute_templates.append(y_offset) - echo_pos_attr.attribute_templates.append(echo_pos) - xtal_plate_type.children[xtal_well_type.name] = xtal_well_type - - # - Create Xtal plate resource - xtal_plate = Resource(name="TestXtalPlate", template=xtal_plate_type) - db_session.add(xtal_plate) - db_session.commit() - - statement = select(Resource).where(Resource.name == "TestXtalPlate") - xtal_plate = db_session.scalars(statement).one() - assert xtal_plate.children["A1a"].template.name == "A1a" - - # - Create Process template - from recap.db.process import ( - ProcessRun, - ProcessTemplate, - ResourceSlot, - ) - - process_template = ProcessTemplate( - name="Fragment Screening Sample Prep", version="1.0" - ) - # - Create resource slots - lib_plate_resource_slot = ResourceSlot( - process_template=process_template, - resource_type=container_type, - name="library_plate", - direction="input", - ) - xtal_plate_resource_slot = ResourceSlot( - process_template=process_template, - resource_type=container_type, - name="xtal_plate", - direction="input", - ) - puck_tray_resource_slot = ResourceSlot( - process_template=process_template, - resource_type=container_type, - name="puck_tray", - direction="output", - ) - process_template.resource_slots.append(lib_plate_resource_slot) - process_template.resource_slots.append(xtal_plate_resource_slot) - process_template.resource_slots.append(puck_tray_resource_slot) - db_session.add(process_template) - db_session.commit() - - statement = select(ProcessTemplate).where( - ProcessTemplate.name == "Fragment Screening Sample Prep" - ) - process_template: ProcessTemplate = db_session.scalars(statement).one() - assert any(slot.name == "library_plate" for slot in process_template.resource_slots) - - puck_collection_template = ResourceTemplate( - name="Puck collection template", - types=[container_type], - ) - puck_collection = Resource( - name="PuckCollection", - template=puck_collection_template, - ) - db_session.add(puck_collection) - db_session.commit() - - # - Create Step templates and connect them to resource slots - # Steps for fragment screening - # 1. Image plate - drop_volume_val = AttributeTemplate( - name="volume", value_type="float", unit="nL", default_value=0 - ) - drop_volume_attr = AttributeGroupTemplate( - name="drop_volume", attribute_templates=[drop_volume_val] - ) - image_plate_template = StepTemplate( - name="Image plate", - process_template=process_template, - attribute_group_templates=[drop_volume_attr], - ) - image_plate_template.resource_slots["xtal_container"] = xtal_plate_resource_slot - - # 2. Echo transfer - volume_transferred_val = AttributeTemplate( - name="volume", value_type="float", unit="nL", default_value=0 - ) - volume_transferred_attr = AttributeGroupTemplate( - name="volume_transferred", attribute_templates=[volume_transferred_val] - ) - batch_number_value = AttributeTemplate( - name="batch", value_type="int", default_value=0 - ) - batch_number_attr = AttributeGroupTemplate( - name="batch_number", attribute_templates=[batch_number_value] - ) - echo_tx_template = StepTemplate( - name="Echo Transfer", - process_template=process_template, - attribute_group_templates=[volume_transferred_attr, batch_number_attr], - ) - echo_tx_template.resource_slots["source_container"] = lib_plate_resource_slot - echo_tx_template.resource_slots["dest_container"] = xtal_plate_resource_slot - - # 3. Harvesting - time_departure_val = AttributeTemplate(name="departure_time", value_type="datetime") - time_arrival_val = AttributeTemplate(name="arrival_time", value_type="datetime") - lsdc_sample_val = AttributeTemplate(name="sample_name", value_type="str") - comment_val = AttributeTemplate(name="comment", value_type="str") - harvesting_status_val = AttributeTemplate(name="status", value_type="bool") - harvesting_attr = AttributeGroupTemplate( - name="harvesting", - attribute_templates=[ - time_departure_val, - time_arrival_val, - comment_val, - harvesting_status_val, - ], - ) - lsdc_attr = AttributeGroupTemplate( - name="lsdc", attribute_templates=[lsdc_sample_val] - ) - harvesting_step_template = StepTemplate( - name="Harvesting", - process_template=process_template, - attribute_group_templates=[harvesting_attr, lsdc_attr], - ) - harvesting_step_template.resource_slots["source_container"] = ( - xtal_plate_resource_slot - ) - harvesting_step_template.resource_slots["dest_container"] = puck_tray_resource_slot - - db_session.add(image_plate_template) - db_session.add(echo_tx_template) - db_session.add(harvesting_step_template) - db_session.commit() - - process_template.step_templates[image_plate_template.name] = image_plate_template - process_template.step_templates[echo_tx_template.name] = echo_tx_template - process_template.step_templates[harvesting_step_template.name] = ( - harvesting_step_template - ) - db_session.add(process_template) - db_session.commit() - - campaign = Campaign(name="Test campaign", proposal="111") - - # - Create Process from template - process_run = ProcessRun( - name="FS1", description="Test", template=process_template, campaign=campaign - ) - db_session.add(process_run) - # - Add resources to the process - for resource in [ - (lib_plate, lib_plate_resource_slot), - (xtal_plate, xtal_plate_resource_slot), - (puck_collection, puck_tray_resource_slot), - ]: - process_run.resources[resource[1]] = resource[0] - - db_session.commit() - - statement = select(ProcessRun).where(ProcessRun.name == "FS1") - process_run: ProcessRun = db_session.scalars(statement).one() - assert process_run.steps["Image plate"].template.name == "Image plate" - assert ( - process_run.steps["Image plate"].parameters["drop_volume"].values["volume"] == 0 - ) - assert ( - process_run.steps["Echo Transfer"] - .parameters["volume_transferred"] - .values["volume"] - == 0 - ) - assert ( - process_run.steps["Echo Transfer"].parameters["batch_number"].values["batch"] - == 0 - ) diff --git a/recap/tests/test_fragment_screening_api.py b/recap/tests/test_fragment_screening_api.py deleted file mode 100644 index abf241b..0000000 --- a/recap/tests/test_fragment_screening_api.py +++ /dev/null @@ -1,217 +0,0 @@ -from recap.utils.general import Direction, generate_uppercase_alphabets - - -def test_fragment_screening_api(client): - # Testing - # - Create templates - # - Library plate - # - Xtal plate - # - Puck collection - # - Puck template - # - Pin template - # - Process template - # - Create instance - # - Campaign - # - ProcessRun - # - Library plate - # - xtal plate - # - - - with client.build_resource_template( - name="Library Plate 1536", - type_names=["container", "library_plate", "plate"], - ) as lp: - lp.add_properties( - { - "LB1536_dimensions": [ - {"name": "rows", "type": "int", "default": 32}, - {"name": "columns", "type": "int", "default": 48}, - ] - } - ) - - a_to_af = generate_uppercase_alphabets(32) - lib_well_type_names_1536 = [ - {"name": f"{i}{str(j).zfill(2)}"} for i in a_to_af for j in range(1, 49) - ] - for well_data in lib_well_type_names_1536: - lp.add_child(well_data["name"], ["container", "well"]).add_properties( - { - "well_status": [ - {"name": "used", "type": "bool", "default": False}, - ], - "content": [ - {"name": "catalog_id", "type": "str", "default": ""}, - {"name": "SMILES", "type": "str", "default": ""}, - {"name": "sequence", "type": "int", "default": 0}, - ], - } - ).close_child() - - rt = ( - client.query_maker() - .resource_templates() - .filter_by_types(["library_plate"]) - .first() - ) - assert rt.name == "Library Plate 1536" - - with client.build_resource_template( - name="SwissCI-MRC-2d", type_names=["container", "xtal_plate", "plate"] - ) as plate: - a_to_h = generate_uppercase_alphabets(8) - a_to_p = generate_uppercase_alphabets(16) - echo = [f"{i}{j}" for i in a_to_p for j in range(1, 13)] - shifter = [ - f"{i}{k}{j}" for i in a_to_h for j in ["a", "b"] for k in range(1, 13) - ] - plate_maps = [ - {"echo": i, "shifter": j} for i, j in zip(echo, shifter, strict=False) - ] - # plate.prop_group("metadata").add_attribute("drop_volume", "int", "nL", 0).close_group() - plate.add_properties( - { - "metadata": [ - {"name": "drop_volume", "type": "float", "unit": "nL", "default": 0} - ] - } - ) - for plate_map in plate_maps: - plate_shift_b = plate_map["shifter"][-1] == "b" - plate.add_child(plate_map["shifter"], ["container", "well"]).add_properties( - { - "echo_offset": [ - {"name": "x", "type": "int", "default": 0}, - { - "name": "y_0" if plate_shift_b else "y_1350", - "type": "int", - "default": 0 if plate_shift_b else 1350, - }, - { - "name": f"echo_pos_{plate_map['echo']}", - "type": "str", - "default": plate_map["echo"], - }, - ] - } - ).close_child() - - rt = ( - client.query_maker() - .resource_templates() - .filter_by_types(["xtal_plate"]) - .first() - ) - assert rt.name == "SwissCI-MRC-2d" - - with client.build_resource_template( - name="puck_collection", type_names=["container"] - ) as puck_collection: - puck_collection.prop_group("contents").add_attribute("count", "int", "", 0) - - with client.build_resource_template( - name="puck", type_names=["container", "puck"] - ) as puck_template: - puck_template.prop_group("pin_count").add_attribute( - "total", "int", "", 16 - ).add_attribute("occupied", "int", "", 0).close_group() - - with client.build_resource_template( - name="pin", type_names=["container", "pin"] - ) as pin_template: - pin_template.prop_group("content").add_attribute( - "position", "int", "", 0 - ).close_group() - - with client.build_process_template("Fragment Screening Sample Prep", "1.0") as pt: - pt.add_resource_slot( - "library_plate", "plate", Direction.input - ).add_resource_slot("xtal_plate", "plate", Direction.input).add_resource_slot( - "puck_tray", "container", Direction.output - ) - pt.add_step(name="Image plate").param_group("drop").add_attribute( - "volume", "float", "nL", 0 - ).close_group().bind_slot("xtal_container", "xtal_plate").close_step() - pt.add_step(name="Echo transfer").param_group("volume").add_attribute( - "transferred", "float", "uL", 0 - ).close_group().param_group("batch").add_attribute( - "number", "int", "", 0 - ).close_group().bind_slot("source_container", "library_plate").bind_slot( - "dest_container", "xtal_plate" - ).close_step() - - pt.add_step(name="Harvesting").param_group("harvesting").add_attribute( - "departure_time", "datetime", "", None - ).add_attribute("arrival_time", "datetime", "", None).add_attribute( - "comment", "str", "", "" - ).add_attribute("status", "str", "", "").close_group().param_group( - "lsdc" - ).add_attribute("sample_name", "str", "", "").close_group().bind_slot( - "source_container", "xtal_plate" - ).bind_slot("dest_container", "puck_tray").close_step() - - client.create_campaign("Test campaign", "123", "0") - - xtal_plate_template = ( - client.query_maker() - .resource_templates() - .filter_by_types(["xtal_plate"]) - .first() - ) - if xtal_plate_template: - client.create_resource("Test Xtal plate", xtal_plate_template.name) - - library_plate_template = ( - client.query_maker() - .resource_templates() - .filter_by_types(["library_plate"]) - .filter(name="Library Plate 1536") - .first() - ) - if library_plate_template: - with client.build_resource( - "DSI-poised", library_plate_template.name - ) as lib_plate_builder: - for well in lib_plate_builder.resource.children.values(): - well.properties["content"].values["SMILES"].value = "" - well.properties["content"].values["catalog_id"].value = "" - - with client.build_process_run( - "Test run", "Test run for something", "Fragment Screening Sample Prep", "1.0" - ): - pass - - ## Testing queries - - qm = client.query_maker() - - campaigns = qm.campaigns() - runs = qm.process_runs() - - all_campaigns = campaigns.filter(proposal="123").all() - assert all_campaigns[0].name == "Test campaign" - - assert runs.filter(campaign__proposal="123").count() == 1 - - # for run in runs.include_steps(include_parameters=True).all(): - # # print(f"Run: {run.name}") - # for step_num, step in enumerate(run.steps): - # # print(f"\tStep {step_num}: {step.name}") - # for pg_num, (param_group_name, param_group) in enumerate( - # step.parameters.items() - # ): - # vals = param_group.values - # # print(f"\t\tGroup {pg_num}: {param_group_name}") - # for param_name, param_value in vals.model_dump(by_alias=True).items(): - # pass - # print(f"\t\t\t{param_name} : {param_value}") - - # for c in campaigns.include_process_runs().all(): - # print(c.name) - # for run in c.process_runs: - # print(run.name) - - # plates = resources.filter(template__types__name_in=["library_plate"]).include_template().all() - - # for plate in plates: - # print(f"{plate.name} : {plate.template.name}") diff --git a/recap/tests/test_from_url.py b/recap/tests/test_from_url.py index 69b3d5b..8d3fed8 100644 --- a/recap/tests/test_from_url.py +++ b/recap/tests/test_from_url.py @@ -1,93 +1,156 @@ """Tests for RecapClient.from_url().""" -from unittest.mock import MagicMock, patch +import inspect +from unittest.mock import patch +from uuid import uuid4 import pytest +from recap.schemas.namespace import NamespaceContext + + +def _namespace_context(path: str = "") -> NamespaceContext: + return NamespaceContext(id=uuid4(), path=path) -def test_from_url_returns_recap_client(tmp_path): - from recap.client import RecapClient - mock_resp = MagicMock() - mock_resp.json.return_value = {"db_path": str(tmp_path / "recap.db")} - mock_resp.raise_for_status = MagicMock() +def test_from_url_returns_root_recap_client(): + from recap.adapter.rest import RESTAdapter + from recap.client import RecapClient - with patch("httpx2.get", return_value=mock_resp): - client = RecapClient.from_url("http://localhost:8000") + with patch.object( + RESTAdapter, + "get_namespace_context", + return_value=_namespace_context(), + ): + client = RecapClient.from_url("http://localhost:8000", api_key="secret") assert isinstance(client, RecapClient) - assert client.backend is not None + assert client.namespace_path == "" + client.close() -def test_from_url_uses_graphql_adapter_for_reads(tmp_path): - from recap.adapter.graphql import GraphQLAdapter +def test_from_url_accepts_initial_namespace_scope(): + from recap.adapter.rest import RESTAdapter from recap.client import RecapClient - mock_resp = MagicMock() - mock_resp.json.return_value = {"db_path": str(tmp_path / "recap.db")} - mock_resp.raise_for_status = MagicMock() - - with patch("httpx2.get", return_value=mock_resp): - client = RecapClient.from_url("http://localhost:8000") + with patch.object( + RESTAdapter, + "get_namespace_context", + return_value=_namespace_context("beamline/amx"), + ): + remote = RecapClient.from_url( + "http://localhost:8000", api_key="secret", namespace="beamline/amx" + ) - assert isinstance(client._read_backend, GraphQLAdapter) + assert remote.namespace_path == "beamline/amx" + remote.close() -def test_from_url_uses_local_backend_for_writes(tmp_path): - from recap.adapter.local import LocalBackend +def test_constructor_has_no_database_url_argument(): from recap.client import RecapClient - mock_resp = MagicMock() - mock_resp.json.return_value = {"db_path": str(tmp_path / "recap.db")} - mock_resp.raise_for_status = MagicMock() + assert "url" not in inspect.signature(RecapClient).parameters + - with patch("httpx2.get", return_value=mock_resp): - client = RecapClient.from_url("http://localhost:8000") +def test_from_url_wires_one_rest_adapter_without_db_discovery(): + from recap.adapter.rest import RESTAdapter + from recap.client import RecapClient + from recap.client.backend import ClientBackend + + with patch.object( + RESTAdapter, + "get_namespace_context", + return_value=_namespace_context(), + ) as get_namespace_context: + client = RecapClient.from_url( + "http://localhost:8000", api_key="secret", timeout=12.5 + ) + + backend = client.connection_state.backend + assert isinstance(backend, ClientBackend) + assert isinstance(backend.reader, RESTAdapter) + assert isinstance(backend.writer, RESTAdapter) + assert backend.namespaces is backend.writer + assert backend.namespace_writer is backend.writer + assert backend.permissions is backend.reader + assert backend.context_resolver is backend.reader + reader = backend.reader + writer = backend.writer + assert reader._transport is writer._transport + assert reader._transport._client.timeout.connect == 12.5 + assert "secret" not in repr(reader) + assert "secret" not in repr(writer) + get_namespace_context.assert_called_once_with("") + client.close() + + +def test_from_url_query_maker_uses_client_backend_facade(): + from recap.adapter.rest import RESTAdapter + from recap.client import RecapClient - assert isinstance(client.backend, LocalBackend) + with patch.object( + RESTAdapter, + "get_namespace_context", + return_value=_namespace_context("test"), + ): + client = RecapClient.from_url("http://localhost:8000", api_key="secret") + qm = client.namespace("test").query_maker() + assert qm.backend is client.connection_state.backend + assert isinstance(qm.backend.reader, RESTAdapter) + client.close() -def test_from_url_connection_error(): - import httpx2 +def test_local_composition_uses_one_adapter_for_required_capabilities(tmp_path): + from recap.adapter.local import LocalBackend from recap.client import RecapClient - from recap.exceptions import RecapConnectionError - with ( - patch("httpx2.get", side_effect=httpx2.ConnectError("refused")), - pytest.raises(RecapConnectionError), - ): - RecapClient.from_url("http://localhost:9999") + with RecapClient.from_sqlite(tmp_path / "recap.db") as client: + backend = client.connection_state.backend + assert isinstance(backend.reader, LocalBackend) + assert backend.reader is backend.writer + assert backend.reader is backend.namespaces + assert backend.reader is backend.namespace_writer + +def test_local_permissions_are_unsupported(tmp_path): + from recap.client import RecapClient -def test_from_url_bad_status(): - import httpx2 + with RecapClient.from_sqlite(tmp_path / "recap.db") as client: + assert client.connection_state.backend.permissions is None + with pytest.raises(RuntimeError, match="Permissions"): + client.permissions() + +def test_from_url_rejects_unscoped_remote_client_before_http(): from recap.client import RecapClient - from recap.exceptions import RecapConnectionError - mock_resp = MagicMock() - mock_resp.raise_for_status.side_effect = httpx2.HTTPStatusError( - "404", request=MagicMock(), response=MagicMock(status_code=404) - ) with ( - patch("httpx2.get", return_value=mock_resp), - pytest.raises(RecapConnectionError), + patch("httpx2.get") as get, + pytest.raises(ValueError, match="unscoped"), ): - RecapClient.from_url("http://localhost:8000") + RecapClient.from_url("http://localhost:8000", api_key="secret", unscoped=True) + get.assert_not_called() -def test_from_url_query_maker_uses_read_backend(tmp_path): - from recap.adapter.graphql import GraphQLAdapter - from recap.client import RecapClient - mock_resp = MagicMock() - mock_resp.json.return_value = {"db_path": str(tmp_path / "recap.db")} - mock_resp.raise_for_status = MagicMock() +def test_from_url_does_not_import_or_construct_sqlite_components(): + from recap.adapter.rest import RESTAdapter + from recap.client import RecapClient - with patch("httpx2.get", return_value=mock_resp): - client = RecapClient.from_url("http://localhost:8000") + with ( + patch.object( + RESTAdapter, + "get_namespace_context", + return_value=_namespace_context(), + ), + patch("recap.client.base_client.apply_migrations") as migrations, + patch("recap.client.base_client.create_engine") as create_engine, + patch("recap.client.base_client.sessionmaker") as sessionmaker, + ): + client = RecapClient.from_url("http://localhost:8000", api_key="secret") - qm = client.query_maker(unscoped=True) - # QueryDSL stores backend as _backend - assert isinstance(qm.backend, GraphQLAdapter) + migrations.assert_not_called() + create_engine.assert_not_called() + sessionmaker.assert_not_called() + client.close() diff --git a/recap/tests/test_get_resource_perf.py b/recap/tests/test_get_resource_perf.py index cebe019..df0cb2e 100644 --- a/recap/tests/test_get_resource_perf.py +++ b/recap/tests/test_get_resource_perf.py @@ -10,16 +10,14 @@ that grows with tree depth. """ -from recap.dsl.resource_builder import ResourceTemplateBuilder +import pytest from .conftest import count_statements def _make_template(client, name): """A minimal single-type template with one property group.""" - with ResourceTemplateBuilder( - name=name, type_names=["container"], backend=client.backend - ) as rtb: + with client.build_resource_template(name=name, type_names=["container"]) as rtb: rtb.prop_group("details").add_attribute( "serial", "str", "", "abc" ).close_group() @@ -39,6 +37,8 @@ def _make_chain(client, depth, *, template, prefix): parent=parent, on_existing="create", ) + with client.build_resource(resource_id=root.id) as builder: + builder.finalize() return root @@ -52,6 +52,7 @@ def _walk_depth(resource): return n +@pytest.mark.performance def test_get_resource_expand_is_depth_independent(client): """``get_resource(expand=True)`` must issue a bounded, depth-independent number of SQL statements regardless of tree depth.""" @@ -78,6 +79,7 @@ def test_get_resource_expand_is_depth_independent(client): ) +@pytest.mark.performance def test_get_resource_expand_matches_query_eager(client): """``get_resource(expand=True)`` must return the same hydrated tree as a ``load="eager"`` query for the same root (behaviour-preserving refactor).""" @@ -86,7 +88,7 @@ def test_get_resource_expand_matches_query_eager(client): got = client.get_resource("eq-0", "GetResEqT", expand=True) - qm = client.query_maker(unscoped=True) + qm = client.query_maker() expected = qm.resources(load="eager").filter(name="eq-0").first() assert got.id == expected.id diff --git a/recap/tests/test_graphql_adapter.py b/recap/tests/test_graphql_adapter.py deleted file mode 100644 index 0353823..0000000 --- a/recap/tests/test_graphql_adapter.py +++ /dev/null @@ -1,222 +0,0 @@ -from unittest.mock import MagicMock, patch -from uuid import uuid4 - -import pytest - -from recap.adapter.transport import serialize_model -from recap.db.process import ProcessRun -from recap.dsl.query import Field, ParameterFilter, PropertyFilter, QuerySpec -from recap.schemas.resource import ResourceSchema -from recap.tests.transport_factories import full_resource - -EXECUTE_QUERY = ( - "query ExecuteQuery($schema_name: String!, $spec: JSON!) " - "{ execute_query(schema_name: $schema_name, spec: $spec) }" -) -EXECUTE_COUNT = ( - "query ExecuteCount($schema_name: String!, $spec: JSON!) " - "{ execute_count(schema_name: $schema_name, spec: $spec) }" -) - - -def response_with(body): - response = MagicMock() - response.json.return_value = body - return response - - -def test_graphql_adapter_query_posts_complete_spec_and_hydrates_nested_state(): - from recap.adapter.graphql import GraphQLAdapter - - campaign_id = uuid4() - parent_id = uuid4() - resource = full_resource() - response = response_with( - { - "data": { - "execute_query": { - "schema_name": "ResourceSchema", - "items": [serialize_model(resource)], - } - } - } - ) - spec = QuerySpec( - filters={"name": "sample"}, - preloads=("children", "properties"), - limit=25, - offset=5, - property_filters=[PropertyFilter(name="temperature", value=20)], - parent_resource_id=parent_id, - parameter_filters=[ParameterFilter(name="exposure", step="Acquire", value=2.5)], - campaign_id=campaign_id, - load_mode="none", - on_unloaded="raise", - ) - - with ( - patch("httpx2.Client.post", return_value=response) as post, - GraphQLAdapter("http://localhost:9999/graphql") as adapter, - ): - [hydrated] = adapter.query(ResourceSchema, spec) - - post.assert_called_once_with( - "http://localhost:9999/graphql", - json={ - "query": EXECUTE_QUERY, - "variables": { - "schema_name": "ResourceSchema", - "spec": { - "filters": {"name": "sample"}, - "predicates": [], - "orderings": [], - "preloads": ["children", "properties"], - "limit": 25, - "offset": 5, - "property_filters": [ - { - "name": "temperature", - "group": None, - "op": "eq", - "value": 20, - "upper": None, - "value_type": None, - } - ], - "parent_resource_id": str(parent_id), - "parameter_filters": [ - { - "name": "exposure", - "group": None, - "step": "Acquire", - "op": "eq", - "value": 2.5, - "upper": None, - "value_type": None, - } - ], - "campaign_id": str(campaign_id), - "load_mode": "none", - "on_unloaded": "raise", - }, - }, - }, - ) - assert hydrated.children["child"].name == "child" - assert hydrated._loaded_relations == {"children": True, "properties": True} - assert hydrated._on_unloaded == "raise" - assert hydrated.children["child"]._loaded_relations == { - "children": False, - "properties": False, - } - assert hydrated.children["child"]._on_unloaded == "silent" - - -def test_graphql_adapter_count_posts_filters_in_complete_spec(): - from recap.adapter.graphql import GraphQLAdapter - - response = response_with({"data": {"execute_count": 42}}) - spec = QuerySpec( - filters={"name": "sample"}, - property_filters=[PropertyFilter(name="temperature", op="gte", value=20)], - ) - - with ( - patch("httpx2.Client.post", return_value=response) as post, - GraphQLAdapter("http://localhost:9999/graphql") as adapter, - ): - count = adapter.count(ResourceSchema, spec) - - assert count == 42 - payload = post.call_args.kwargs["json"] - assert payload["query"] == EXECUTE_COUNT - assert payload["variables"]["schema_name"] == "ResourceSchema" - assert payload["variables"]["spec"]["filters"] == {"name": "sample"} - assert payload["variables"]["spec"]["property_filters"] == [ - { - "name": "temperature", - "group": None, - "op": "gte", - "value": 20, - "upper": None, - "value_type": None, - } - ] - - -@pytest.mark.parametrize("method", ("query", "count")) -def test_graphql_adapter_rejects_http_200_graphql_errors(method): - from recap.adapter.graphql import GraphQLAdapter - - response = response_with( - { - "data": None, - "errors": [ - { - "message": "Invalid query specification", - "locations": [{"line": 1, "column": 1}], - } - ], - } - ) - - with ( - patch("httpx2.Client.post", return_value=response), - GraphQLAdapter("http://localhost:9999/graphql") as adapter, - pytest.raises(RuntimeError) as exc_info, - ): - getattr(adapter, method)(ResourceSchema, QuerySpec()) - - assert str(exc_info.value) == "GraphQL request failed: Invalid query specification" - assert "locations" not in str(exc_info.value) - - -@pytest.mark.parametrize( - "errors", - ( - {"message": "Invalid query specification"}, - "Invalid query specification", - ["Invalid query specification"], - ), -) -def test_graphql_adapter_rejects_malformed_graphql_errors(errors): - from recap.adapter.graphql import GraphQLAdapter - - response = response_with({"data": None, "errors": errors}) - - with ( - patch("httpx2.Client.post", return_value=response), - GraphQLAdapter("http://localhost:9999/graphql") as adapter, - pytest.raises(RuntimeError) as exc_info, - ): - adapter.query(ResourceSchema, QuerySpec()) - - assert str(exc_info.value) == "GraphQL request failed: malformed error response" - - -@pytest.mark.parametrize( - ("field", "value"), - [ - ("predicates", (ProcessRun.name == "sample",)), - ( - "predicates", - (Field("name") == "sample", ProcessRun.name == "sample"), - ), - ("orderings", (ProcessRun.name,)), - ("orderings", (Field("name").asc(), ProcessRun.name.desc())), - ], -) -@pytest.mark.parametrize("method", ("query", "count")) -def test_graphql_adapter_rejects_legacy_query_features_before_http( - field, value, method -): - from recap.adapter.graphql import GraphQLAdapter - - with ( - patch("httpx2.Client.post") as post, - GraphQLAdapter("http://localhost:9999/graphql") as adapter, - pytest.raises(TypeError, match="Field"), - ): - getattr(adapter, method)(ResourceSchema, QuerySpec(**{field: value})) - - post.assert_not_called() diff --git a/recap/tests/test_graphql_client_parity.py b/recap/tests/test_graphql_client_parity.py deleted file mode 100644 index ed93734..0000000 --- a/recap/tests/test_graphql_client_parity.py +++ /dev/null @@ -1,349 +0,0 @@ -from contextlib import ExitStack, nullcontext -from urllib.parse import urlparse - -import httpx2 -import pytest -from fastapi.testclient import TestClient - -from recap.client import RecapClient -from recap.dsl.query import Field -from recap.exceptions import UnloadedFieldError, UnloadedFieldWarning -from recap.schemas.process import ProcessRunRef, ProcessTemplateRef -from recap.schemas.resource import ResourceRef, ResourceTemplateRef -from recap.server.app import create_app -from recap.utils.general import Direction - - -def _public_dump(value): - if isinstance(value, list | tuple): - return [_public_dump(item) for item in value] - if isinstance(value, dict): - return {key: _public_dump(item) for key, item in value.items()} - if not hasattr(value, "model_dump"): - return value - return value.model_dump(mode="json", by_alias=True) - - -def _assert_query_parity(clients, query): - local, remote = clients - local_result = query(local.query_maker(unscoped=True)) - remote_result = query(remote.query_maker(unscoped=True)) - assert _public_dump(remote_result) == _public_dump(local_result) - return local_result, remote_result - - -@pytest.fixture -def parity_clients(tmp_path, monkeypatch): - db_path = tmp_path / "parity.db" - with ExitStack() as stack: - local = stack.enter_context(RecapClient.from_sqlite(db_path)) - - local.create_campaign( - "MX parity campaign", - "PROPOSAL-42", - saf="SAF-7", - metadata={"beamline": "AMX"}, - ) - with local.build_resource_template( - name="Parity plate", type_names=["container", "plate"] - ) as template: - template.add_properties( - {"metrics": [{"name": "rating", "type": "int", "default": 1}]} - ) - ( - template.add_child("sample", ["sample"]) - .add_properties( - {"contents": [{"name": "mass", "type": "float", "default": 2.5}]} - ) - .close_child() - ) - - first_plate = local.create_resource("plate-1", "Parity plate") - second_plate = local.create_resource("plate-2", "Parity plate") - for plate, rating in ((first_plate, 12), (second_plate, 3)): - with local.build_resource(resource_id=plate.id) as builder: - model = builder.get_model() - model.properties.metrics.rating = rating - builder.set_model(model) - - with local.build_process_template("Parity workflow", "1.0") as template: - template.add_resource_slot("plate", "container", Direction.input) - ( - template.add_step("Collect") - .add_parameters( - {"exposure": [{"name": "dwell", "type": "int", "default": 1}]} - ) - .bind_slot("source", "plate") - .close_step() - ) - - for name, plate, dwell in ( - ("run-high", first_plate, 15), - ("run-low", second_plate, 5), - ): - with local.build_process_run( - name, "GraphQL parity run", "Parity workflow", "1.0" - ) as run: - run.assign_resource("plate", plate) - parameters = run.get_params("Collect") - parameters.exposure.dwell = dwell - run.set_params(parameters) - - app_client = stack.enter_context(TestClient(create_app(db_path))) - - def get(url, *args, **kwargs): - assert urlparse(url).path == "/db_path" - return app_client.get("/db_path", *args, **kwargs) - - def post(_client, url, *, json, **kwargs): - assert urlparse(url).path == "/graphql" - return app_client.post("/graphql", json=json, **kwargs) - - monkeypatch.setattr(httpx2, "get", get) - monkeypatch.setattr(httpx2.Client, "post", post) - remote = stack.enter_context(RecapClient.from_url("http://recap.test")) - yield local, remote - - -@pytest.mark.parametrize( - "query", - [ - pytest.param(lambda q: q.resources().all(), id="default"), - pytest.param(lambda q: q.resources().include_template().all(), id="template"), - pytest.param( - lambda q: q.resources().include("properties").all(), id="properties" - ), - pytest.param(lambda q: q.resources().include("children").all(), id="children"), - pytest.param(lambda q: q.resources(load="eager").all(), id="eager"), - ], -) -def test_resource_loading_parity(parity_clients, query): - local, remote = _assert_query_parity(parity_clients, query) - assert [item.id for item in remote] == [item.id for item in local] - - -@pytest.mark.parametrize( - "query", - [ - pytest.param(lambda q: q.process_runs().all(), id="default"), - pytest.param(lambda q: q.process_runs().include("steps").all(), id="steps"), - pytest.param( - lambda q: q.process_runs().include("steps.parameters").all(), - id="step-parameters", - ), - pytest.param( - lambda q: q.process_runs().include("resources").all(), id="resources" - ), - pytest.param(lambda q: q.process_runs(load="eager").all(), id="eager"), - ], -) -def test_process_run_loading_parity(parity_clients, query): - local, remote = _assert_query_parity(parity_clients, query) - assert [item.id for item in remote] == [item.id for item in local] - - -def test_campaign_and_template_loading_parity(parity_clients): - queries = [ - lambda q: q.campaigns().include_process_runs().all(), - lambda q: q.resource_templates().all(), - lambda q: ( - q.resource_templates() - .include(["children", "attribute_group_templates", "types"]) - .all() - ), - lambda q: q.resource_templates(load="eager").all(), - lambda q: q.process_templates().all(), - lambda q: ( - q.process_templates().include(["step_templates", "resource_slots"]).all() - ), - lambda q: q.process_templates(load="eager").all(), - ] - for query in queries: - _assert_query_parity(parity_clients, query) - - -@pytest.mark.parametrize( - ("query", "expected_type"), - [ - (lambda q: q.resources(shape="ref").all(), ResourceRef), - (lambda q: q.resource_templates(shape="ref").all(), ResourceTemplateRef), - (lambda q: q.process_runs(shape="ref").all(), ProcessRunRef), - (lambda q: q.process_templates(shape="ref").all(), ProcessTemplateRef), - ], -) -def test_reference_shape_parity(parity_clients, query, expected_type): - local, remote = _assert_query_parity(parity_clients, query) - assert all(isinstance(item, expected_type) for item in local) - assert all(isinstance(item, expected_type) for item in remote) - - -def test_filters_scopes_pagination_and_count_parity(parity_clients): - local, remote = parity_clients - parent = ( - local.query_maker(unscoped=True) - .resources() - .filter(name="plate-1") - .include("children") - .first() - ) - queries = [ - lambda q: q.resources().filter(name="plate-1").all(), - lambda q: q.resources().filter_property("rating", gt=10, group="metrics").all(), - lambda q: q.resources().under_parent(parent).all(), - lambda q: q.resources().descendants(parent).all(), - lambda q: q.resources().limit(2).all(), - lambda q: q.resources().offset(1).limit(2).all(), - lambda q: q.process_runs().filter(name="run-high").all(), - lambda q: ( - q.process_runs() - .filter_parameter("dwell", gt=10, group="exposure", step="Collect") - .all() - ), - lambda q: q.process_runs().limit(1).all(), - lambda q: q.process_runs().offset(1).limit(1).all(), - ] - for query in queries: - _assert_query_parity(parity_clients, query) - - local_q = local.query_maker(unscoped=True) - remote_q = remote.query_maker(unscoped=True) - count_queries = [ - lambda q: q.resources().count(), - lambda q: q.resources().filter_property("rating", gt=10).count(), - lambda q: q.resources().under_parent(parent).count(), - lambda q: q.process_runs().count(), - lambda q: q.process_runs().filter_parameter("dwell", gt=10).count(), - ] - for query in count_queries: - assert query(remote_q) == query(local_q) - - -@pytest.mark.parametrize( - "query", - [ - pytest.param( - lambda q: q.process_runs().where(Field("name").starts_with("run-")).all(), - id="starts-with", - ), - pytest.param( - lambda q: q.process_runs() - .where(Field("campaign.proposal") == "PROPOSAL-42") - .all(), - id="relationship-equality", - ), - pytest.param( - lambda q: q.process_runs().where(Field("name") == "run-high").all(), - id="equal", - ), - pytest.param( - lambda q: q.process_runs().where(Field("name") != "run-high").all(), - id="not-equal", - ), - pytest.param( - lambda q: q.process_runs().where(Field("name") > "run-high").all(), - id="greater-than", - ), - pytest.param( - lambda q: q.process_runs().where(Field("name") >= "run-high").all(), - id="greater-than-or-equal", - ), - pytest.param( - lambda q: q.process_runs().where(Field("name") < "run-low").all(), - id="less-than", - ), - pytest.param( - lambda q: q.process_runs().where(Field("name") <= "run-high").all(), - id="less-than-or-equal", - ), - pytest.param( - lambda q: q.process_runs() - .where(Field("name").in_(["run-high", "run-low"])) - .all(), - id="in", - ), - pytest.param( - lambda q: q.process_runs().where(Field("name").not_in(["run-high"])).all(), - id="not-in", - ), - pytest.param( - lambda q: q.process_runs().where(Field("name").ends_with("high")).all(), - id="ends-with", - ), - pytest.param( - lambda q: q.process_runs() - .where(Field("name").starts_with("run-")) - .where(Field("name").contains("low")) - .all(), - id="chained", - ), - ], -) -def test_field_predicate_parity(parity_clients, query): - _assert_query_parity(parity_clients, query) - - -@pytest.mark.parametrize(("value", "expected"), [("plate", 0), ("run", 2)]) -def test_field_predicate_count_parity(parity_clients, value, expected): - local, remote = _assert_query_parity( - parity_clients, - lambda q: q.process_runs().where(Field("name").contains(value)).count(), - ) - assert remote == local == expected - - -@pytest.mark.parametrize("ordering", [Field("name").asc(), Field("name").desc()]) -def test_field_ordering_parity(parity_clients, ordering): - local, remote = _assert_query_parity( - parity_clients, - lambda q: q.process_runs().order_by(ordering).all(), - ) - assert [item.id for item in remote] == [item.id for item in local] - - -@pytest.mark.parametrize("field", ["id", "create_date"]) -def test_transport_scalar_predicate_coercion_parity(parity_clients, field): - local, _ = parity_clients - target = local.query_maker(unscoped=True).process_runs().first() - - local_result, remote_result = _assert_query_parity( - parity_clients, - lambda q: q.process_runs().where(Field(field) == getattr(target, field)).all(), - ) - - assert [item.id for item in remote_result] == [item.id for item in local_result] - - -def _access_outcome(model, field, policy): - expected = { - "silent": nullcontext(), - "warn": pytest.warns(UnloadedFieldWarning), - "raise": pytest.raises(UnloadedFieldError), - }[policy] - with expected as caught: - value = getattr(model, field) - if policy == "raise": - return type(caught.value), str(caught.value) - if policy == "warn": - return caught[0].category, str(caught[0].message), _public_dump(value) - return _public_dump(value) - - -@pytest.mark.parametrize("policy", ["silent", "warn", "raise"]) -@pytest.mark.parametrize( - ("query", "field"), - [ - (lambda q, policy: q.resources(on_unloaded=policy), "properties"), - (lambda q, policy: q.resources(on_unloaded=policy), "children"), - (lambda q, policy: q.process_runs(on_unloaded=policy), "steps"), - ( - lambda q, policy: q.process_runs(on_unloaded=policy), - "assigned_resources", - ), - ], -) -def test_on_unloaded_access_behavior_parity(parity_clients, policy, query, field): - outcomes = [] - for client in parity_clients: - model = query(client.query_maker(unscoped=True), policy).first() - outcomes.append(_access_outcome(model, field, policy)) - assert outcomes[1] == outcomes[0] diff --git a/recap/tests/test_graphql_integration.py b/recap/tests/test_graphql_integration.py deleted file mode 100644 index 7d53c20..0000000 --- a/recap/tests/test_graphql_integration.py +++ /dev/null @@ -1,70 +0,0 @@ -"""End-to-end integration: write via LocalBackend, read via GraphQL server.""" - -from fastapi.testclient import TestClient - - -def test_write_local_read_graphql(tmp_path): - """Write a campaign via LocalBackend directly; read it back via GraphQL.""" - from recap.client import RecapClient - from recap.server.app import create_app - - db_path = tmp_path / "recap.db" - - # Write directly via local client - local_client = RecapClient.from_sqlite(str(db_path)) - local_client.create_campaign(name="Test Campaign", proposal="P-001") - local_client.close() - - # Read via GraphQL server - app = create_app(db_path) - test_client = TestClient(app) - - resp = test_client.post( - "/graphql", json={"query": "{ campaigns { id name proposal } }"} - ) - assert resp.status_code == 200 - data = resp.json()["data"]["campaigns"] - assert len(data) == 1 - assert data[0]["name"] == "Test Campaign" - assert data[0]["proposal"] == "P-001" - - -def test_graphql_resources_after_write(tmp_path): - """Write resources via LocalBackend; verify they appear in GraphQL.""" - from recap.client import RecapClient - from recap.server.app import create_app - - db_path = tmp_path / "recap.db" - local_client = RecapClient.from_sqlite(str(db_path)) - - with local_client.build_resource_template(name="Sample", type_names=["sample"]): - pass - - with local_client.build_resource(name="S-001", template_name="Sample"): - pass - - local_client.close() - - app = create_app(db_path) - test_client = TestClient(app) - resp = test_client.post( - "/graphql", json={"query": "{ resources { id name } resources_count }"} - ) - assert resp.status_code == 200 - body = resp.json()["data"] - assert body["resources_count"] == 1 - assert body["resources"][0]["name"] == "S-001" - - -def test_graphql_limit_enforced(tmp_path): - """Server enforces max limit of 10000.""" - from recap.server.app import create_app - - app = create_app(tmp_path / "recap.db") - test_client = TestClient(app) - resp = test_client.post( - "/graphql", json={"query": "{ resources(limit: 99999) { id } }"} - ) - assert resp.status_code == 200 - body = resp.json() - assert "errors" in body diff --git a/recap/tests/test_graphql_server.py b/recap/tests/test_graphql_server.py deleted file mode 100644 index 8614935..0000000 --- a/recap/tests/test_graphql_server.py +++ /dev/null @@ -1,241 +0,0 @@ -from types import SimpleNamespace -from unittest.mock import Mock - -import pytest -from fastapi.testclient import TestClient - -from recap.client import RecapClient - - -def make_test_app(tmp_path): - from recap.server.app import create_app - - return create_app(tmp_path / "test.db") - - -EXECUTE_QUERY = """ -query ExecuteQuery($schema_name: String!, $spec: JSON!) { - execute_query(schema_name: $schema_name, spec: $spec) -} -""" - -EXECUTE_COUNT = """ -query ExecuteCount($schema_name: String!, $spec: JSON!) { - execute_count(schema_name: $schema_name, spec: $spec) -} -""" - - -def seed_resource_tree(db_path): - client = RecapClient.from_sqlite(db_path) - with client.build_resource_template( - name="Parent", type_names=["container"] - ) as builder: - builder.close_child() - with client.build_resource_template(name="Child", type_names=["sample"]) as builder: - builder.close_child() - with client.build_resource("root", "Parent") as builder: - builder.add_child("nested", "Child") - client.close() - - -def test_db_path_endpoint(tmp_path): - app = make_test_app(tmp_path) - client = TestClient(app) - resp = client.get("/db_path") - assert resp.status_code == 200 - data = resp.json() - assert "db_path" in data - assert "test.db" in data["db_path"] - - -def test_graphql_endpoint_responds(tmp_path): - app = make_test_app(tmp_path) - client = TestClient(app) - resp = client.post("/graphql", json={"query": "{ campaigns { id name } }"}) - assert resp.status_code == 200 - body = resp.json() - assert "data" in body - assert "campaigns" in body["data"] - - -def test_graphql_resources_empty(tmp_path): - app = make_test_app(tmp_path) - client = TestClient(app) - resp = client.post("/graphql", json={"query": "{ resources { id name } }"}) - assert resp.status_code == 200 - assert resp.json()["data"]["resources"] == [] - - -def test_graphql_count_fields(tmp_path): - app = make_test_app(tmp_path) - client = TestClient(app) - resp = client.post( - "/graphql", - json={ - "query": "{ resources_count campaigns_count resource_templates_count process_templates_count }" - }, - ) - assert resp.status_code == 200 - data = resp.json()["data"] - assert data["resources_count"] == 0 - assert data["campaigns_count"] == 0 - assert data["resource_templates_count"] == 0 - assert data["process_templates_count"] == 0 - - -def test_execute_query_posts_full_spec_and_returns_nested_transport_payload(tmp_path): - db_path = tmp_path / "test.db" - seed_resource_tree(db_path) - client = TestClient(make_test_app(tmp_path)) - - response = client.post( - "/graphql", - json={ - "query": EXECUTE_QUERY, - "variables": { - "schema_name": "ResourceSchema", - "spec": { - "filters": {"name": "root"}, - "preloads": ["children", "template"], - "limit": 1, - "offset": 0, - "load_mode": "none", - "on_unloaded": "raise", - }, - }, - }, - ) - - assert response.status_code == 200 - result = response.json()["data"]["execute_query"] - assert result["schema_name"] == "ResourceSchema" - assert result["items"][0]["children"]["nested"]["name"] == "nested" - assert result["items"][0]["__recap__"] == { - "loaded_relations": { - "properties": False, - "children": True, - }, - "on_unloaded": "raise", - } - - -def test_execute_query_supports_ref_schema(tmp_path): - db_path = tmp_path / "test.db" - seed_resource_tree(db_path) - client = TestClient(make_test_app(tmp_path)) - - response = client.post( - "/graphql", - json={ - "query": EXECUTE_QUERY, - "variables": { - "schema_name": "ResourceRef", - "spec": {"filters": {"name": "nested"}}, - }, - }, - ) - - assert response.status_code == 200 - result = response.json()["data"]["execute_query"] - assert result["schema_name"] == "ResourceRef" - assert result["items"][0]["name"] == "nested" - assert "__recap__" not in result["items"][0] - - -def test_execute_count_supports_filters(tmp_path): - db_path = tmp_path / "test.db" - seed_resource_tree(db_path) - client = TestClient(make_test_app(tmp_path)) - - response = client.post( - "/graphql", - json={ - "query": EXECUTE_COUNT, - "variables": { - "schema_name": "ResourceSchema", - "spec": {"filters": {"name": "root"}}, - }, - }, - ) - - assert response.status_code == 200 - assert response.json()["data"]["execute_count"] == 1 - - -def test_execute_query_rejects_unknown_schema(tmp_path): - client = TestClient(make_test_app(tmp_path)) - - response = client.post( - "/graphql", - json={ - "query": EXECUTE_QUERY, - "variables": {"schema_name": "attacker-controlled", "spec": {}}, - }, - ) - - assert response.status_code == 200 - body = response.json() - assert body["data"] is None - assert body["errors"][0]["message"] == "Unknown query schema" - assert "attacker-controlled" not in str(body) - - -@pytest.mark.parametrize( - "query", - (EXECUTE_QUERY, EXECUTE_COUNT), - ids=("query", "count"), -) -def test_execute_rejects_malformed_query_spec(tmp_path, query): - client = TestClient(make_test_app(tmp_path)) - - response = client.post( - "/graphql", - json={ - "query": query, - "variables": { - "schema_name": "ResourceSchema", - "spec": {"load_mode": "attacker-controlled"}, - }, - }, - ) - - assert response.status_code == 200 - body = response.json() - assert body["data"] is None - assert body["errors"][0]["message"] == "Invalid query specification" - assert "attacker-controlled" not in str(body) - - -def test_execute_query_preserves_unlimited_spec(): - from recap.server.resolvers import resolve_execute_query - - backend = Mock() - backend.query.return_value = [] - - resolve_execute_query( - SimpleNamespace(context={"backend": backend}), - schema_name="ResourceSchema", - spec={}, - ) - - query_spec = backend.query.call_args.args[1] - assert query_spec.limit is None - - -def test_execute_query_normalizes_legacy_full_load_mode(): - from recap.server.resolvers import resolve_execute_query - - backend = Mock() - backend.query.return_value = [] - - with pytest.warns(DeprecationWarning, match="load='eager'"): - result = resolve_execute_query( - SimpleNamespace(context={"backend": backend}), - schema_name="ResourceSchema", - spec={"load_mode": "full"}, - ) - - query_spec = backend.query.call_args.args[1] - assert query_spec.load_mode == "eager" - assert result == {"schema_name": "ResourceSchema", "items": []} diff --git a/recap/tests/test_http_transport.py b/recap/tests/test_http_transport.py new file mode 100644 index 0000000..95c3005 --- /dev/null +++ b/recap/tests/test_http_transport.py @@ -0,0 +1,267 @@ +import json +from unittest.mock import patch + +import httpx2 +import pytest +from pydantic import SecretStr + +from recap.adapter.http_transport import HTTPTransport +from recap.exceptions import ( + RecapAuthenticationError, + RecapConflictError, + RecapConnectionError, + RecapInternalError, + RecapNotFoundError, + RecapPermissionDeniedError, + RecapProtocolError, + RecapRequestError, + RecapServiceUnavailableError, + RecapValidationError, +) + +EXAMPLE_URL = "https://" + "recap.example" + + +def make_response(status, body=None, *, headers=None, content=None): + if content is None: + content = b"" if body is None else json.dumps(body).encode() + return httpx2.Response( + status, + headers=headers, + content=content, + request=httpx2.Request("GET", EXAMPLE_URL), + ) + + +def test_transport_sends_auth_and_returns_metadata(): + transport = HTTPTransport("secret", timeout=12.5) + response = make_response( + 200, + {"value": 3}, + headers={"ETag": '"7"', "X-Request-ID": "request-7"}, + ) + + with patch.object(transport._client, "request", return_value=response) as request: + result = transport.request( + "PATCH", + EXAMPLE_URL + "/api/v1/resources/1", + json={"name": "sample"}, + headers={"If-Match": '"6"'}, + ) + + assert result.body == {"value": 3} + assert result.etag == '"7"' + assert result.request_id == "request-7" + assert request.call_args.kwargs["headers"] == { + "Authorization": "Apikey secret", + "If-Match": '"6"', + } + + +def test_transport_uses_custom_timeout(): + transport = HTTPTransport("secret", timeout=12.5) + + assert transport._client.timeout.connect == 12.5 + + +@pytest.mark.parametrize( + "error", + [ + httpx2.RequestError("secret"), + httpx2.ConnectError("secret"), + httpx2.TimeoutException("secret"), + ], +) +def test_request_error_becomes_connection_error_without_secret(error): + transport = HTTPTransport("secret") + with patch.object(transport._client, "request", side_effect=error), pytest.raises( + RecapConnectionError + ) as caught: + transport.request("GET", EXAMPLE_URL) + + assert "secret" not in str(caught.value) + assert caught.value.url == EXAMPLE_URL + + +@pytest.mark.parametrize( + "authorization", ["Authorization", "authorization", "AUTHORIZATION", "aUtHoRiZaTiOn"] +) +def test_caller_authorization_cannot_override_transport_auth(authorization): + transport = HTTPTransport("owned-secret") + response = make_response(200, {"ok": True}) + + with patch.object(transport._client, "request", return_value=response) as request: + transport.request( + "GET", + EXAMPLE_URL, + headers={authorization: "Apikey caller-secret", "X-Test": "value"}, + ) + + assert request.call_args.kwargs["headers"] == { + "Authorization": "Apikey owned-secret", + "X-Test": "value", + } + + +def test_secret_is_absent_from_repr_and_protocol_errors(): + transport = HTTPTransport(SecretStr("secret")) + response = make_response(200, content=b"secret malformed") + + with patch.object(transport._client, "request", return_value=response), pytest.raises( + RecapProtocolError + ) as caught: + transport.request("GET", EXAMPLE_URL) + + assert "secret" not in repr(transport) + assert "secret" not in str(caught.value) + + +def test_close_is_idempotent(): + transport = HTTPTransport("secret") + + with patch.object(transport._client, "close") as close: + transport.close() + transport.close() + + close.assert_called_once_with() + + +@pytest.mark.parametrize( + ("code", "error_type"), + [ + ("authentication_required", RecapAuthenticationError), + ("permission_denied", RecapPermissionDeniedError), + ("not_found", RecapNotFoundError), + ("validation_error", RecapValidationError), + ("conflict", RecapConflictError), + ("service_unavailable", RecapServiceUnavailableError), + ("internal_error", RecapInternalError), + ("request_error", RecapRequestError), + ], +) +def test_status_error_uses_public_error_category(code, error_type): + response = make_response( + 409, + {"error": {"code": code, "message": "Conflict", "request_id": "request-9"}}, + ) + transport = HTTPTransport("secret") + + with patch.object(transport._client, "request", return_value=response), pytest.raises( + error_type + ) as caught: + transport.request("GET", EXAMPLE_URL) + + assert caught.value.status_code == 409 + assert caught.value.request_id == "request-9" + + +def test_header_request_id_wins_over_envelope(): + response = make_response( + 409, + {"error": {"code": "conflict", "message": "Conflict", "request_id": "body-id"}}, + headers={"X-Request-ID": "header-id"}, + ) + transport = HTTPTransport(None) + + with patch.object(transport._client, "request", return_value=response), pytest.raises( + RecapConflictError + ) as caught: + transport.request("GET", EXAMPLE_URL) + + assert caught.value.request_id == "header-id" + + +def test_empty_header_request_id_wins_over_envelope(): + response = make_response( + 409, + {"error": {"code": "conflict", "message": "Conflict", "request_id": "body-id"}}, + headers={"X-Request-ID": ""}, + ) + transport = HTTPTransport(None) + + with patch.object(transport._client, "request", return_value=response), pytest.raises( + RecapConflictError + ) as caught: + transport.request("GET", EXAMPLE_URL) + + assert caught.value.request_id == "" + + +def test_unknown_error_code_falls_back_to_request_error(): + response = make_response( + 418, + { + "error": { + "code": "future_code", + "message": "Unknown failure", + "request_id": "body-id", + } + }, + ) + transport = HTTPTransport("secret") + + with patch.object(transport._client, "request", return_value=response), pytest.raises( + RecapRequestError + ) as caught: + transport.request("GET", EXAMPLE_URL) + + assert type(caught.value) is RecapRequestError + + +def test_external_error_fields_are_redacted_before_construction(): + secret = "secret" + url = "https://" + "secret.example/secret" + response = make_response( + 409, + { + "error": { + "code": "conflict", + "message": "message contains secret", + "request_id": "request-secret", + } + }, + headers={"X-Request-ID": "header-secret"}, + ) + transport = HTTPTransport(secret) + + with patch.object(transport._client, "request", return_value=response), pytest.raises( + RecapConflictError + ) as caught: + transport.request("GET", url) + + assert caught.value.url == "https://" + "**********.example/**********" + assert caught.value.message == "message contains **********" + assert caught.value.request_id == "header-**********" + + +@pytest.mark.parametrize("body", [None, {}, {"error": {}}, {"error": "bad"}, {"error": {"code": "conflict"}}]) +def test_malformed_status_body_becomes_safe_request_error(body): + response = make_response(500, body) + transport = HTTPTransport("secret") + + with patch.object(transport._client, "request", return_value=response), pytest.raises( + RecapRequestError + ) as caught: + transport.request("GET", EXAMPLE_URL) + + assert caught.value.message == "Malformed error response" + assert "secret" not in str(caught.value) + + +def test_malformed_success_json_becomes_protocol_error(): + response = make_response(200, content=b"not json secret") + transport = HTTPTransport("secret") + + with patch.object(transport._client, "request", return_value=response), pytest.raises( + RecapProtocolError + ) as caught: + transport.request("GET", EXAMPLE_URL) + + assert "not json secret" not in str(caught.value) + + +def test_context_manager_closes_transport(): + with patch.object(httpx2.Client, "close") as close, HTTPTransport(None): + pass + + close.assert_called_once_with() diff --git a/recap/tests/test_hydration_cache.py b/recap/tests/test_hydration_cache.py new file mode 100644 index 0000000..ca80de4 --- /dev/null +++ b/recap/tests/test_hydration_cache.py @@ -0,0 +1,483 @@ +from unittest.mock import patch +from uuid import uuid4 + +import pytest + +from recap.adapter.entity_hydration import EntityHydrationContext +from recap.adapter.process_run_construct import ProcessRunSchemaHydrator +from recap.adapter.resource_construct import ResourceSchemaHydrator +from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate +from recap.db.namespace import Namespace +from recap.db.process import ProcessRun, ProcessTemplate +from recap.db.resource import ( + ROOT_RESOURCE_TEMPLATE_ID, + Resource, + ResourceTemplate, + ResourceType, +) +from recap.db.step import StepTemplate + + +def _seed_resource_graph(db_session): + namespace = Namespace(path=f"cache/{uuid4().hex}", metadata_json={}) + resource_type = ResourceType(name=f"cache-type-{uuid4().hex}") + parent_template = ResourceTemplate( + name=f"ParentTemplate-{uuid4().hex}", + namespace=namespace, + ) + parent_template.types.append(resource_type) + property_group = AttributeGroupTemplate(name="Measurements") + property_group.attribute_templates.append( + AttributeTemplate(name="dose", value_type="int", default_value="1") + ) + parent_template.attribute_group_templates.append(property_group) + child_template = ResourceTemplate( + name="child", + namespace=namespace, + parent=parent_template, + ) + parent = Resource( + name="parent", + template=parent_template, + namespace=namespace, + ) + child = parent.children["child"] + db_session.add_all( + [ + namespace, + resource_type, + parent_template, + child_template, + parent, + ] + ) + db_session.flush() + return parent, child, parent_template, child_template + + +def _seed_process_template(db_session): + namespace = Namespace(path=f"cache/process-{uuid4().hex}", metadata_json={}) + process_template = ProcessTemplate( + name=f"Process-{uuid4().hex}", + version="1.0", + namespace=namespace, + ) + step_template = StepTemplate(name="Collect", process_template=process_template) + parameter_group = AttributeGroupTemplate(name="Exposure") + parameter_group.attribute_templates.append( + AttributeTemplate(name="dwell", value_type="int", default_value="1") + ) + step_template.attribute_group_templates.append(parameter_group) + db_session.add_all([namespace, process_template, step_template]) + db_session.flush() + return process_template, step_template + + +def test_hydration_context_tracks_one_cache_and_recursion_guard_per_family(): + context = EntityHydrationContext() + entity_id = uuid4() + + assert context.resource_cache == {} + assert context.resource_template_cache == {} + assert not context.in_progress("resource", entity_id) + + context.begin("resource", entity_id) + assert context.in_progress("resource", entity_id) + + context.finish("resource", entity_id) + assert not context.in_progress("resource", entity_id) + + +def test_merge_load_state_unions_flags_without_downgrade(db_session): + parent, _, _, _ = _seed_resource_graph(db_session) + context = EntityHydrationContext() + model = ResourceSchemaHydrator(context).construct_many( + [parent], + include_template=True, + include_properties=False, + include_children=False, + full=False, + on_unloaded="raise", + )[0] + + context.merge_load_state( + model, + {"template": False, "properties": True}, + on_unloaded="raise", + ) + + assert model.is_loaded("template") + assert model.is_loaded("properties") + assert not model.is_loaded("children") + + +def test_resource_cache_upgrades_partial_to_full_in_place(db_session): + parent, _, _, _ = _seed_resource_graph(db_session) + context = EntityHydrationContext() + hydrator = ResourceSchemaHydrator(context) + + partial = hydrator.construct_many( + [parent], + include_template=False, + include_properties=False, + include_children=False, + full=False, + on_unloaded="raise", + )[0] + full = hydrator.construct_many( + [parent], + include_template=True, + include_properties=True, + include_children=True, + full=True, + on_unloaded="raise", + )[0] + + assert full is partial + assert full.is_loaded("template") + assert full.is_loaded("parent") + assert full.is_loaded("children") + assert full.is_loaded("properties") + assert set(full.children) == {"child"} + assert full.properties.measurements.values.dose.value == 1 + + +def test_resource_cache_does_not_downgrade_full_model(db_session): + parent, _, _, _ = _seed_resource_graph(db_session) + context = EntityHydrationContext() + hydrator = ResourceSchemaHydrator(context) + + full = hydrator.construct_many( + [parent], + include_template=True, + include_properties=True, + include_children=True, + full=True, + on_unloaded="raise", + )[0] + original_children = full.children + partial = hydrator.construct_many( + [parent], + include_template=False, + include_properties=False, + include_children=False, + full=False, + on_unloaded="raise", + )[0] + + assert partial is full + assert partial.children is original_children + assert partial.is_loaded("template") + assert partial.is_loaded("parent") + assert partial.is_loaded("children") + assert partial.is_loaded("properties") + + +def test_repeated_resource_occurrences_create_one_cached_object(db_session): + parent, child, _, _ = _seed_resource_graph(db_session) + context = EntityHydrationContext() + hydrator = ResourceSchemaHydrator(context) + + first_parent, second_parent, direct_child = hydrator.construct_many( + [parent, parent, child], + include_template=True, + include_properties=True, + include_children=True, + full=True, + on_unloaded="raise", + ) + + assert first_parent is second_parent + assert first_parent.children["child"] is direct_child + assert set(context.resource_cache) == {parent.id, child.id} + assert set(context.resource_template_cache) == { + ROOT_RESOURCE_TEMPLATE_ID, + parent.template.id, + child.template.id, + } + + +def test_parent_seen_as_ref_then_root_upgrades_same_resource(db_session): + parent, child, _, _ = _seed_resource_graph(db_session) + context = EntityHydrationContext() + hydrator = ResourceSchemaHydrator(context) + + child_model = hydrator.construct_many( + [child], + include_template=True, + include_properties=True, + include_children=True, + full=True, + on_unloaded="raise", + )[0] + parent_ref = child_model.parent + parent_model = hydrator.construct_many( + [parent], + include_template=True, + include_properties=True, + include_children=True, + full=True, + on_unloaded="raise", + )[0] + + assert parent_model is parent_ref + assert parent_model.is_loaded("children") + assert parent_model.children["child"] is child_model + assert parent_model.is_loaded("properties") + + +def test_resource_template_ref_then_full_upgrades_same_object(db_session): + _, _, parent_template, _ = _seed_resource_graph(db_session) + context = EntityHydrationContext() + hydrator = ResourceSchemaHydrator(context) + + ref = hydrator._construct_resource_template_ref( + parent_template, + on_unloaded="raise", + ) + full = hydrator._construct_resource_template( + parent_template, + on_unloaded="raise", + ) + + assert full is ref + assert full.is_loaded("parent") + assert full.is_loaded("types") + assert full.is_loaded("children") + assert full.is_loaded("attribute_group_templates") + assert set(full.children) == set(parent_template.children) + assert [group.name for group in full.attribute_group_templates] == ["Measurements"] + + +def test_resource_template_full_then_minimal_preserves_loaded_relations(db_session): + _, _, parent_template, _ = _seed_resource_graph(db_session) + context = EntityHydrationContext() + hydrator = ResourceSchemaHydrator(context) + + full = hydrator._construct_resource_template( + parent_template, + on_unloaded="raise", + ) + original_children = full.children + minimal = hydrator._construct_resource_template_minimal( + parent_template, + on_unloaded="raise", + ) + + assert minimal is full + assert minimal.children is original_children + assert minimal.is_loaded("types") + assert minimal.is_loaded("parent") + assert minimal.is_loaded("children") + assert minimal.is_loaded("attribute_group_templates") + + +def test_resource_template_parent_reuses_canonical_template(db_session): + _, _, parent_template, child_template = _seed_resource_graph(db_session) + context = EntityHydrationContext() + hydrator = ResourceSchemaHydrator(context) + + child = hydrator._construct_resource_template( + child_template, + on_unloaded="raise", + ) + parent = hydrator._construct_resource_template( + parent_template, + on_unloaded="raise", + ) + + assert child.parent is parent + + +def test_separate_hydration_contexts_do_not_share_identity(db_session): + parent, _, _, _ = _seed_resource_graph(db_session) + first = ResourceSchemaHydrator(EntityHydrationContext()).construct_many( + [parent], + include_template=True, + include_properties=False, + include_children=False, + full=False, + on_unloaded="raise", + )[0] + second = ResourceSchemaHydrator(EntityHydrationContext()).construct_many( + [parent], + include_template=True, + include_properties=False, + include_children=False, + full=False, + on_unloaded="raise", + )[0] + + assert first is not second + assert first.template is not second.template + + +def test_process_hydrator_reuses_one_resource_hydrator_and_cache(db_session): + parent, _, _, _ = _seed_resource_graph(db_session) + context = EntityHydrationContext() + process_hydrator = ProcessRunSchemaHydrator(context) + + first = process_hydrator._construct_resource_schema(parent) + second = process_hydrator._construct_resource_schema(parent) + + assert first is second + assert set(context.resource_cache) == { + parent.id, + *[child.id for child in parent.children.values()], + } + + +def test_process_template_minimal_then_full_upgrades_in_place(db_session): + process_template, _ = _seed_process_template(db_session) + context = EntityHydrationContext() + + minimal = context.construct_process_template( + process_template, + include_relations=False, + on_unloaded="raise", + ) + full = context.construct_process_template( + process_template, + include_relations=True, + on_unloaded="raise", + ) + + assert full is minimal + assert full.is_loaded("step_templates") + assert full.is_loaded("resource_slots") + assert set(full.step_templates) == {"Collect"} + + +def test_step_template_minimal_then_full_upgrades_in_place(db_session): + _, step_template = _seed_process_template(db_session) + context = EntityHydrationContext() + + minimal = context.construct_step_template( + step_template, + include_relations=False, + on_unloaded="raise", + ) + full = context.construct_step_template( + step_template, + include_relations=True, + on_unloaded="raise", + ) + + assert full is minimal + assert full.is_loaded("attribute_group_templates") + assert full.is_loaded("resource_slots") + assert [group.name for group in full.attribute_group_templates] == ["Exposure"] + + +def test_process_run_nested_templates_preserve_unloaded_policy(db_session): + process_template, _ = _seed_process_template(db_session) + run = ProcessRun( + name=f"run-{uuid4().hex}", + description="nested policy", + template=process_template, + namespace=process_template.namespace, + ) + db_session.add(run) + db_session.flush() + + [hydrated] = ProcessRunSchemaHydrator().construct_many( + [run], + include_steps=True, + include_step_parameters=False, + include_resources=False, + include_template=True, + full=True, + on_unloaded="raise", + ) + + assert hydrated.template._on_unloaded == "raise" + assert hydrated.template.step_templates["Collect"]._on_unloaded == "raise" + + +def test_runtime_ref_aliases_are_canonical_schema_classes(): + from recap.schemas.attribute import ( + AttributeGroupRef, + AttributeGroupTemplateSchema, + ) + from recap.schemas.process import ( + ProcessRunRef, + ProcessRunSchema, + ProcessTemplateRef, + ProcessTemplateSchema, + ) + from recap.schemas.resource import ( + ResourceRef, + ResourceSchema, + ResourceTemplateRef, + ResourceTemplateSchema, + ) + from recap.schemas.step import StepTemplateRef, StepTemplateSchema + + assert AttributeGroupRef is AttributeGroupTemplateSchema + assert ProcessRunRef is ProcessRunSchema + assert ProcessTemplateRef is ProcessTemplateSchema + assert ResourceRef is ResourceSchema + assert ResourceTemplateRef is ResourceTemplateSchema + assert StepTemplateRef is StepTemplateSchema + + +def test_resource_recursion_guard_returns_cached_shell(db_session): + parent, _, _, _ = _seed_resource_graph(db_session) + context = EntityHydrationContext() + hydrator = ResourceSchemaHydrator(context) + + shell = hydrator.construct_many( + [parent], + include_template=False, + include_properties=False, + include_children=False, + full=False, + on_unloaded="raise", + )[0] + context.begin("resource", parent.id) + try: + recursive = hydrator._construct_resource_schema( + parent, + include_template=True, + include_properties=True, + include_children=True, + full=True, + on_unloaded="raise", + ) + finally: + context.finish("resource", parent.id) + + assert recursive is shell + assert not recursive.is_loaded("children") + assert not recursive.is_loaded("properties") + + +def test_failed_resource_upgrade_clears_guard_and_preserves_unloaded_flag(db_session): + parent, _, _, _ = _seed_resource_graph(db_session) + context = EntityHydrationContext() + hydrator = ResourceSchemaHydrator(context) + partial = hydrator.construct_many( + [parent], + include_template=False, + include_properties=False, + include_children=False, + full=False, + on_unloaded="raise", + )[0] + + with patch.object( + hydrator, + "_construct_property_schema", + side_effect=RuntimeError("property hydration failed"), + ), pytest.raises(RuntimeError, match="property hydration failed"): + hydrator.construct_many( + [parent], + include_template=False, + include_properties=True, + include_children=False, + full=False, + on_unloaded="raise", + ) + + assert not context.in_progress("resource", parent.id) + assert not partial.is_loaded("properties") diff --git a/recap/tests/test_hydration_parity.py b/recap/tests/test_hydration_parity.py new file mode 100644 index 0000000..8458f4c --- /dev/null +++ b/recap/tests/test_hydration_parity.py @@ -0,0 +1,117 @@ +from uuid import uuid4 + +from recap.adapter.entity_hydration import EntityHydrationContext +from recap.adapter.process_run_construct import ProcessRunSchemaHydrator +from recap.adapter.resource_construct import ResourceSchemaHydrator +from recap.adapter.transport import QueryResult, hydrate_result, serialize_model +from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate +from recap.db.namespace import Namespace +from recap.db.process import ProcessRun, ProcessTemplate +from recap.db.resource import Resource, ResourceTemplate, ResourceType +from recap.db.step import StepTemplate + + +def _seed_metadata_graph(db_session): + namespace = Namespace(path=f"hydration/{uuid4().hex}", metadata_json={}) + resource_type = ResourceType(name=f"sample-{uuid4().hex}") + resource_template = ResourceTemplate( + name=f"Resource-{uuid4().hex}", namespace=namespace + ) + resource_template.types.append(resource_type) + resource_group = AttributeGroupTemplate(name="Measurements") + resource_group.attribute_templates.append( + AttributeTemplate(name="dose", value_type="int", default_value="1") + ) + resource_template.attribute_group_templates.append(resource_group) + resource = Resource(name="sample", template=resource_template, namespace=namespace) + resource.properties["Measurements"]._values["dose"].metadata_json = { + "source": "header" + } + + process_template = ProcessTemplate( + name=f"Process-{uuid4().hex}", version="1.0", namespace=namespace + ) + step_template = StepTemplate(name="Collect", process_template=process_template) + parameter_group = AttributeGroupTemplate(name="Exposure") + parameter_group.attribute_templates.append( + AttributeTemplate(name="dwell", value_type="int", default_value="1") + ) + step_template.attribute_group_templates.append(parameter_group) + run = ProcessRun( + name=f"run-{uuid4().hex}", + description="hydration parity", + template=process_template, + namespace=namespace, + ) + run.steps["Collect"].parameters["Exposure"]._values["dwell"].metadata_json = { + "source": "header" + } + db_session.add_all( + [namespace, resource_type, resource_template, process_template, step_template, resource, run] + ) + db_session.flush() + return resource, run + + +def test_property_and_parameter_metadata_survive_direct_and_embedded_hydration( + db_session, +): + resource, run = _seed_metadata_graph(db_session) + + direct = ResourceSchemaHydrator().construct_many( + [resource], + include_template=True, + include_properties=True, + include_children=False, + full=False, + on_unloaded="warn", + )[0] + embedded = ProcessRunSchemaHydrator().construct_many( + [run], + include_steps=True, + include_step_parameters=True, + include_resources=False, + include_template=False, + full=False, + on_unloaded="warn", + )[0] + + assert direct.properties["Measurements"].values.dose.metadata_json == { + "source": "header" + } + assert direct.is_loaded("template") + assert direct.is_loaded("properties") + assert not direct.is_loaded("children") + assert embedded.is_loaded("steps") + assert not embedded.is_loaded("assigned_resources") + assert embedded.steps["Collect"].parameters["Exposure"].values.dwell.metadata_json == { + "source": "header" + } + + result = QueryResult( + entity="resource", projection="full", items=[serialize_model(direct)] + ) + round_tripped = hydrate_result(type(direct), result)[0] + assert round_tripped.properties["Measurements"].values.dose.metadata_json == { + "source": "header" + } + + +def test_shared_context_reuses_resource_across_direct_and_embedded_paths(db_session): + resource, _ = _seed_metadata_graph(db_session) + context = EntityHydrationContext() + direct = ResourceSchemaHydrator(context).construct_many( + [resource], + include_template=True, + include_properties=True, + include_children=False, + full=False, + on_unloaded="warn", + )[0] + process_hydrator = ProcessRunSchemaHydrator(context) + + embedded_resource = process_hydrator._construct_resource_schema(resource) + + assert embedded_resource is direct + assert embedded_resource.is_loaded("children") + assert embedded_resource.is_loaded("properties") diff --git a/recap/tests/test_idempotency.py b/recap/tests/test_idempotency.py new file mode 100644 index 0000000..c74afb5 --- /dev/null +++ b/recap/tests/test_idempotency.py @@ -0,0 +1,168 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Event +from uuid import uuid4 + +import pytest +from pydantic import BaseModel +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from recap.commands.errors import CommandConflictError +from recap.commands.idempotency import command_fingerprint +from recap.db.base import Base +from recap.db.idempotency import IdempotencyRepository + + +class Body(BaseModel): + name: str + optional: str | None = None + + +def fingerprint(**overrides) -> str: + values = { + "method": "PATCH", + "route_template": "/namespaces/{namespace}/resources/{source_id}", + "namespace_path": "beamline/endstation", + "source_id": uuid4(), + "body": Body(name="detector"), + } + values.update(overrides) + return command_fingerprint(**values) + + +@pytest.mark.parametrize( + ("field", "changed"), + [ + ("method", "POST"), + ("route_template", "/namespaces/{namespace}/resources"), + ("namespace_path", "beamline/other"), + ("source_id", uuid4()), + ("body", Body(name="motor")), + ], +) +def test_fingerprint_covers_exact_command_identity(field, changed): + source_id = uuid4() + original = fingerprint(source_id=source_id) + changed_values = {"source_id": source_id, field: changed} + + assert fingerprint(**changed_values) != original + assert fingerprint(source_id=source_id) == original + + +def test_same_actor_key_and_fingerprint_replays_exact_result_after_authorization(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + session_factory = sessionmaker(engine, expire_on_commit=False) + actor_id = "actor-1" + key = "command-1" + command = fingerprint() + target_id = str(uuid4()) + response = {"id": target_id, "revision": 2, "nested": {"state": "ready"}} + + with session_factory.begin() as session: + repository = IdempotencyRepository(session) + decision = repository.claim(actor_id, key, command, lambda _: None) + assert not decision.replayed + repository.complete(decision, target_id=target_id, response=response) + + checked = [] + with session_factory.begin() as session: + decision = IdempotencyRepository(session).claim( + actor_id, key, command, checked.append + ) + + assert decision.replayed + assert decision.target_id == target_id + assert decision.response == response + assert checked == [target_id] + + +def test_revoked_authority_denies_replay_before_result_is_returned(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + session_factory = sessionmaker(engine) + command = fingerprint() + + with session_factory.begin() as session: + repository = IdempotencyRepository(session) + decision = repository.claim("actor-1", "command-1", command, lambda _: None) + repository.complete( + decision, target_id="resource-1", response={"secret": False} + ) + + def deny(_target_id): + raise PermissionError("authority revoked") + + with ( + pytest.raises(PermissionError, match="authority revoked"), + session_factory.begin() as session, + ): + IdempotencyRepository(session).claim("actor-1", "command-1", command, deny) + + +def test_actor_and_key_are_unique_and_changed_fingerprint_conflicts(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + session_factory = sessionmaker(engine) + original = fingerprint() + + with session_factory.begin() as session: + repository = IdempotencyRepository(session) + first = repository.claim("actor-1", "command-1", original, lambda _: None) + repository.complete(first, target_id="resource-1", response={"revision": 1}) + + with ( + pytest.raises(CommandConflictError, match="different command"), + session_factory.begin() as session, + ): + IdempotencyRepository(session).claim( + "actor-1", "command-1", fingerprint(), lambda _: None + ) + + with session_factory.begin() as session: + other_actor = IdempotencyRepository(session).claim( + "actor-2", "command-1", original, lambda _: None + ) + assert not other_actor.replayed + + +def test_concurrent_claim_has_one_winner_and_one_authorized_replay(tmp_path): + engine = create_engine( + f"sqlite:///{tmp_path / 'idempotency.db'}", + connect_args={"check_same_thread": False, "timeout": 5}, + ) + Base.metadata.create_all(engine) + session_factory = sessionmaker(engine, expire_on_commit=False) + winner_claimed = Event() + release_winner = Event() + command = fingerprint() + + def run(first: bool): + with session_factory.begin() as session: + repository = IdempotencyRepository(session) + if not first: + winner_claimed.wait(timeout=5) + decision = repository.claim( + "actor-1", "shared-key", command, lambda _: None + ) + if first: + winner_claimed.set() + release_winner.wait(timeout=5) + repository.complete( + decision, + target_id="resource-1", + response={"id": "resource-1", "revision": 1}, + ) + return decision + + with ThreadPoolExecutor(max_workers=2) as executor: + winner = executor.submit(run, True) + loser = executor.submit(run, False) + assert winner_claimed.wait(timeout=5) + release_winner.set() + decisions = [winner.result(timeout=5), loser.result(timeout=5)] + + assert sorted(decision.replayed for decision in decisions) == [False, True] + replay = next(decision for decision in decisions if decision.replayed) + assert replay.target_id == "resource-1" + assert replay.response == {"id": "resource-1", "revision": 1} diff --git a/recap/tests/test_include_properties_perf.py b/recap/tests/test_include_properties_perf.py index 0b71b6d..28fd5a4 100644 --- a/recap/tests/test_include_properties_perf.py +++ b/recap/tests/test_include_properties_perf.py @@ -12,16 +12,14 @@ statements. """ -from recap.dsl.resource_builder import ResourceTemplateBuilder +import pytest from .conftest import count_statements def _make_template(client, name="IncPropT"): """A template with three property groups, each with one attribute.""" - with ResourceTemplateBuilder( - name=name, type_names=["container"], backend=client.backend - ) as rtb: + with client.build_resource_template(name=name, type_names=["container"]) as rtb: rtb.prop_group("details").add_attribute( "serial", "str", "", "abc" ).close_group() @@ -31,14 +29,18 @@ def _make_template(client, name="IncPropT"): rtb.prop_group("status").add_attribute("state", "str", "", "new").close_group() +@pytest.mark.performance def test_build_property_model_after_include_properties_no_lazy_loads(client): """build_property_model() on a resource fetched with include(["properties"]) must not trigger any additional SQL (no per-group template lazy load).""" _make_template(client) - client.create_resource("incprop-a", "IncPropT", on_existing="create") - client.create_resource("incprop-b", "IncPropT", on_existing="create") + first = client.create_resource("incprop-a", "IncPropT", on_existing="create") + second = client.create_resource("incprop-b", "IncPropT", on_existing="create") + for resource in (first, second): + with client.build_resource(resource_id=resource.id) as builder: + builder.finalize() - qm = client.query_maker(unscoped=True) + qm = client.query_maker() resources = qm.resources().include(["properties"]).filter(name="incprop-a").all() # All hydration SQL should have run during .all(); building the dynamic @@ -59,6 +61,7 @@ def test_build_property_model_after_include_properties_no_lazy_loads(client): ) +@pytest.mark.performance def test_include_properties_matches_load_eager_statement_count(client): """include(['properties']) must load property templates as efficiently as the load="eager" path. Both go through @@ -69,9 +72,13 @@ def test_include_properties_matches_load_eager_statement_count(client): Property.template per group while load="eager" does not. """ _make_template(client, name="IncPropParity") - client.create_resource("parity-res", "IncPropParity", on_existing="create") + resource = client.create_resource( + "parity-res", "IncPropParity", on_existing="create" + ) + with client.build_resource(resource_id=resource.id) as builder: + builder.finalize() - qm = client.query_maker(unscoped=True) + qm = client.query_maker() with count_statements(client) as c_include: inc = qm.resources().include(["properties"]).filter(name="parity-res").all() diff --git a/recap/tests/test_lifecycle.py b/recap/tests/test_lifecycle.py new file mode 100644 index 0000000..3a45f0e --- /dev/null +++ b/recap/tests/test_lifecycle.py @@ -0,0 +1,28 @@ +import pytest + +from recap.lifecycle import LifecycleStatus, validate_transition + + +@pytest.mark.parametrize( + ("source", "target"), + [ + (LifecycleStatus.MUTABLE, LifecycleStatus.ACTIVE), + (LifecycleStatus.MUTABLE, LifecycleStatus.ARCHIVED), + (LifecycleStatus.ACTIVE, LifecycleStatus.ARCHIVED), + ], +) +def test_allowed_lifecycle_transitions(source, target): + validate_transition(source, target) + + +@pytest.mark.parametrize( + ("source", "target"), + [ + (LifecycleStatus.ACTIVE, LifecycleStatus.MUTABLE), + (LifecycleStatus.ARCHIVED, LifecycleStatus.ACTIVE), + (LifecycleStatus.ARCHIVED, LifecycleStatus.MUTABLE), + ], +) +def test_rejects_reverse_lifecycle_transitions(source, target): + with pytest.raises(ValueError, match="Invalid lifecycle transition"): + validate_transition(source, target) diff --git a/recap/tests/test_lifecycle_operations.py b/recap/tests/test_lifecycle_operations.py new file mode 100644 index 0000000..bb3a81f --- /dev/null +++ b/recap/tests/test_lifecycle_operations.py @@ -0,0 +1,144 @@ +import pytest + +from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate +from recap.db.namespace import Namespace +from recap.db.process import ProcessRun, ProcessTemplate +from recap.db.resource import Resource, ResourceTemplate, ResourceType +from recap.db.step import StepTemplate +from recap.lifecycle import LifecycleStatus + + +def _namespace(db_session, name="lifecycle"): + namespace = Namespace(path=f"/{name}", status=LifecycleStatus.ACTIVE) + db_session.add(namespace) + db_session.flush() + return namespace + + +@pytest.mark.parametrize("model", [ProcessTemplate, ResourceTemplate, Resource]) +def test_aggregate_lifecycle_operations(model, db_session): + namespace = _namespace(db_session, model.__name__.lower()) + kwargs = {"name": model.__name__, "namespace": namespace} + if model in {ProcessTemplate, ResourceTemplate}: + kwargs["version"] = "1" + aggregate = model(**kwargs) + db_session.add(aggregate) + db_session.flush() + + aggregate.activate() + assert aggregate.status is LifecycleStatus.ACTIVE + aggregate.archive() + assert aggregate.status is LifecycleStatus.ARCHIVED + with pytest.raises(ValueError, match="Invalid lifecycle transition"): + aggregate.activate() + + +def test_finalize_process_run_activates_template_and_bumps_revision(db_session): + namespace = _namespace(db_session, "process-run") + template = ProcessTemplate(name="process", version="1", namespace=namespace) + template.step_templates["step"] = StepTemplate( + name="step", + attribute_group_templates=[ + AttributeGroupTemplate( + name="params", + attribute_templates=[ + AttributeTemplate(name="value", value_type="int", default_value="1") + ], + ) + ], + ) + db_session.add(template) + db_session.flush() + assert template.status is LifecycleStatus.MUTABLE + assert template.revision == 1 + + run = ProcessRun( + name="run", + description="description", + template=template, + namespace=namespace, + ) + db_session.add(run) + db_session.flush() + + assert template.status is LifecycleStatus.MUTABLE + assert template.revision == 1 + assert run.status is LifecycleStatus.MUTABLE + assert run.revision == 1 + + run.finalize() + db_session.flush() + + assert template.status is LifecycleStatus.ACTIVE + assert template.revision == 2 + assert run.status is LifecycleStatus.ACTIVE + assert run.revision == 2 + + value = run.steps["step"].parameters["params"]._values["value"] + value.value = 2 + with pytest.raises(ValueError, match="finalized process run"): + db_session.flush() + + +def test_finalize_process_run_activates_assigned_resource_root(db_session): + from recap.db.process import ResourceAssignment, ResourceSlot + from recap.utils.general import Direction + + namespace = _namespace(db_session, "assignment") + resource_type = ResourceType(name="assignment-sample") + process_template = ProcessTemplate(name="process", version="1", namespace=namespace) + slot = ResourceSlot( + name="input", + process_template=process_template, + resource_type=resource_type, + direction=Direction.input, + ) + resource_template = ResourceTemplate( + name="resource", version="1", namespace=namespace, types=[resource_type] + ) + root = Resource(name="root", template=resource_template, namespace=namespace) + child = Resource( + name="child", template=resource_template, namespace=namespace, parent=root + ) + run = ProcessRun( + name="run", + description="description", + template=process_template, + namespace=namespace, + ) + db_session.add_all([slot, resource_template, root, run]) + db_session.flush() + + assignment = ResourceAssignment(process_run=run, resource_slot=slot, resource=child) + db_session.add(assignment) + db_session.flush() + + assert root.status is LifecycleStatus.MUTABLE + assert root.revision == 1 + assert child.status is LifecycleStatus.MUTABLE + assert child.revision == 1 + + run.finalize() + db_session.flush() + + assert root.status is LifecycleStatus.ACTIVE + assert root.revision == 2 + assert child.status is LifecycleStatus.MUTABLE + assert child.revision == 1 + + +def test_process_run_rejects_illegal_finalize_transition(db_session): + namespace = _namespace(db_session, "archived-run") + template = ProcessTemplate(name="process", version="1", namespace=namespace) + run = ProcessRun( + name="run", + description="description", + template=template, + namespace=namespace, + ) + db_session.add_all([template, run]) + db_session.flush() + run.archive() + + with pytest.raises(ValueError, match="Invalid lifecycle transition"): + run.finalize() diff --git a/recap/tests/test_list_namespaces.py b/recap/tests/test_list_namespaces.py new file mode 100644 index 0000000..aa124ef --- /dev/null +++ b/recap/tests/test_list_namespaces.py @@ -0,0 +1,136 @@ +from uuid import uuid4 + +import pytest + +from recap.adapter.rest import RESTAdapter +from recap.client import RecapClient +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext + + +@pytest.fixture +def client_with_namespaces(tmp_path): + """Client with namespace tree: beamline/amx, beamline/fmx, staff""" + with RecapClient.from_sqlite(tmp_path / "recap.db") as client: + client.create_namespace("beamline") + client.create_namespace("beamline/amx") + client.create_namespace("beamline/fmx") + client.create_namespace("staff") + yield client + + +@pytest.mark.parametrize( + ("scope", "expected"), + [ + pytest.param("", ["beamline", "staff"], id="root"), + pytest.param("beamline", ["amx", "fmx"], id="direct-children"), + pytest.param("beamline/amx", [], id="leaf"), + ], +) +def test_list_namespaces_returns_direct_children( + client_with_namespaces, scope, expected +): + client = client_with_namespaces if not scope else client_with_namespaces[scope] + assert sorted(client.list_namespaces()) == expected + + +def test_list_namespaces_nonexistent_returns_empty(tmp_path): + with RecapClient.from_sqlite(tmp_path / "recap.db") as client: + result = client.connection_state.backend.namespaces.list_child_namespaces( + "nonexistent" + ) + assert result == [] + + +def test_list_namespaces_only_direct_children_not_grandchildren(client_with_namespaces): + """beamline/amx/detector exists but should not appear in beamline children""" + client_with_namespaces.create_namespace("beamline/amx/detector") + result = client_with_namespaces["beamline"].list_namespaces() + assert sorted(result) == ["amx", "fmx"] + + +def test_local_backend_lists_full_direct_child_paths(client_with_namespaces): + client = client_with_namespaces + + assert sorted( + client.connection_state.backend.namespaces.list_child_namespace_paths("") + ) == [ + "beamline", + "staff", + ] + assert sorted( + client.connection_state.backend.namespaces.list_child_namespace_paths( + "beamline" + ) + ) == [ + "beamline/amx", + "beamline/fmx", + ] + assert ( + client.connection_state.backend.namespaces.list_child_namespace_paths( + "beamline/amx" + ) + == [] + ) + assert ( + client.connection_state.backend.namespaces.list_child_namespace_paths("missing") + == [] + ) + + +def test_list_namespaces_remote_calls_scoped_rest_children_endpoint(monkeypatch): + from recap.adapter.rest import RESTResult + + monkeypatch.setattr( + RESTAdapter, + "get_namespace_context", + lambda self, path: NamespaceContext( + id=uuid4(), + path=path, + status=LifecycleStatus.ACTIVE, + revision=1, + ), + ) + client = RecapClient.from_url("http://recap.test", api_key="secret") + calls = [] + + def fake_request(method, path, **kwargs): + calls.append((method, path)) + return RESTResult(entity=["amx", "fmx"], etag=None, request_id=None) + + client.connection_state.backend.namespaces._request = fake_request + + result = client["beamline"].list_namespaces() + + assert calls == [("GET", "/api/v1/namespaces/children/beamline")] + assert sorted(result) == ["amx", "fmx"] + client.close() + + +def test_list_namespaces_remote_root_calls_correct_url(monkeypatch): + from recap.adapter.rest import RESTResult + + monkeypatch.setattr( + RESTAdapter, + "get_namespace_context", + lambda self, path: NamespaceContext( + id=uuid4(), + path=path, + status=LifecycleStatus.ACTIVE, + revision=1, + ), + ) + client = RecapClient.from_url("http://recap.test", api_key="secret") + calls = [] + + def fake_request(method, path, **kwargs): + calls.append((method, path)) + return RESTResult(entity=["beamline", "staff"], etag=None, request_id=None) + + client.connection_state.backend.namespaces._request = fake_request + + result = client.list_namespaces() + + assert calls == [("GET", "/api/v1/namespaces/children")] + assert sorted(result) == ["beamline", "staff"] + client.close() diff --git a/recap/tests/test_mutation_audit.py b/recap/tests/test_mutation_audit.py new file mode 100644 index 0000000..f865f4d --- /dev/null +++ b/recap/tests/test_mutation_audit.py @@ -0,0 +1,201 @@ +import json +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import create_engine, inspect, select +from sqlalchemy.orm import sessionmaker + +from recap.authorization.policy import UnrestrictedNamespacePolicy +from recap.commands.audit import DurableAuditSink, record_failure_after_rollback +from recap.commands.context import build_local_command_context +from recap.commands.idempotency import command_fingerprint +from recap.commands.models import CreateResource +from recap.commands.service import CommandService +from recap.db.audit import MutationAudit, MutationAuditRepository +from recap.db.base import Base +from recap.db.idempotency import IdempotencyRecord, IdempotencyRepository +from recap.db.namespace import Namespace +from recap.server.audit import AuditOutcome, AuditRecord, AuditSink +from recap.server.errors import ErrorCode +from recap.utils.migrations import apply_migrations + + +def audit_record(*, outcome: AuditOutcome = AuditOutcome.SUCCESS) -> AuditRecord: + return AuditRecord( + request_id=uuid4(), + actor_id="actor-1", + mutation="patch_resource", + resource_type="resource", + resource_id="resource-1", + outcome=outcome, + reason_code=ErrorCode.INTERNAL_ERROR if outcome is AuditOutcome.ERROR else None, + ) + + +class AuditCollector: + def __init__(self): + self.records = [] + + def emit(self, record): + self.records.append(record) + + +def test_local_command_context_has_shared_actor_and_configurable_audit_sink(): + sink = AuditCollector() + + context = build_local_command_context( + audit_sink=sink, + request_id="request-1", + idempotency_key="command-1", + ) + + assert context.actor.actor_id == "single-user" + assert context.actor.identities[0].provider == "single-user" + assert context.actor.credential_scopes + assert context.actor.namespace_restrictions is None + assert isinstance(context.policy, UnrestrictedNamespacePolicy) + assert context.request_id == "request-1" + assert context.idempotency_key == "command-1" + assert context.audit_sink is sink + + +def test_local_command_context_generates_uuid_request_id(): + context = build_local_command_context() + + assert UUID(context.request_id).version == 4 + + +def test_local_context_external_audit_does_not_duplicate_durable_record(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + session_factory = sessionmaker(engine) + sink = AuditCollector() + context = build_local_command_context( + audit_sink=sink, + request_id=str(uuid4()), + idempotency_key="create-namespace-1", + ) + + with session_factory.begin() as session: + session.add(Namespace(path="", metadata_json={})) + + CommandService(session_factory).create_namespace( + context, + path="beamline", + metadata={}, + ) + + with session_factory() as session: + assert len(sink.records) == 1 + assert len(session.scalars(select(MutationAudit)).all()) == 1 + + +def test_durable_sink_persists_only_sanitized_fields(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + session_factory = sessionmaker(engine) + record = audit_record() + + with session_factory.begin() as session: + sink = DurableAuditSink(MutationAuditRepository(session)) + assert isinstance(sink, AuditSink) + sink.emit(record) + + with session_factory() as session: + stored = session.scalar(select(MutationAudit)) + + serialized = json.dumps(stored.as_record().model_dump(mode="json")) + assert stored.as_record() == record + assert "secret" not in serialized + assert "property_value" not in serialized + assert "parameter_value" not in serialized + assert set(MutationAudit.__table__.columns.keys()) == { + "id", + "request_id", + "actor_id", + "mutation", + "resource_type", + "resource_id", + "outcome", + "reason_code", + "create_date", + } + + +def test_success_audit_rolls_back_with_owning_mutation(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + session_factory = sessionmaker(engine) + + with ( + pytest.raises(RuntimeError, match="domain failure"), + session_factory.begin() as session, + ): + DurableAuditSink(MutationAuditRepository(session)).emit(audit_record()) + raise RuntimeError("domain failure") + + with session_factory() as session: + assert session.scalar(select(MutationAudit)) is None + + +def test_failure_audit_uses_short_transaction_after_mutation_rollback(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + session_factory = sessionmaker(engine) + failure = audit_record(outcome=AuditOutcome.ERROR) + + with ( + pytest.raises(RuntimeError, match="domain failure"), + session_factory.begin(), + ): + raise RuntimeError("domain failure") + + record_failure_after_rollback(session_factory, failure) + + with session_factory() as session: + assert session.scalar(select(MutationAudit)).as_record() == failure + + +def test_mutation_success_audit_and_idempotency_result_are_one_transaction(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + session_factory = sessionmaker(engine) + source_id = uuid4() + fingerprint = command_fingerprint( + method="PATCH", + route_template="/namespaces/{namespace}", + namespace_path="beamline/endstation", + source_id=source_id, + body=CreateResource( + namespace_path="beamline/endstation", + name="updated", + template_id=uuid4(), + ), + ) + + with ( + pytest.raises(RuntimeError, match="force rollback"), + session_factory.begin() as session, + ): + session.add(Namespace(id=source_id, path="beamline/endstation")) + repository = IdempotencyRepository(session) + decision = repository.claim("actor-1", "command-1", fingerprint, lambda _: None) + DurableAuditSink(MutationAuditRepository(session)).emit(audit_record()) + repository.complete( + decision, target_id=str(source_id), response={"revision": 1} + ) + raise RuntimeError("force rollback") + + with session_factory() as session: + assert session.get(Namespace, source_id) is None + assert session.scalar(select(MutationAudit)) is None + assert session.scalar(select(IdempotencyRecord)) is None + + +def test_migration_head_creates_command_infrastructure(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'migration.db'}") + apply_migrations(str(engine.url)) + + tables = set(inspect(engine).get_table_names()) + + assert {"command_idempotency", "mutation_audit"} <= tables diff --git a/recap/tests/test_namespace_client.py b/recap/tests/test_namespace_client.py new file mode 100644 index 0000000..9cb1be3 --- /dev/null +++ b/recap/tests/test_namespace_client.py @@ -0,0 +1,138 @@ +from uuid import uuid4 + +from recap.adapter.rest import RESTAdapter +from recap.client import RecapClient +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext + + +def fake_namespace_context(path=""): + return NamespaceContext( + id=uuid4(), + path=path, + status=LifecycleStatus.ACTIVE, + revision=1, + ) + + +def test_namespace_returns_scoped_client_with_shared_backend(tmp_path): + with RecapClient.from_sqlite(tmp_path / "recap.db") as client: + client.create_namespace("beamline") + client.create_namespace("beamline/amx") + scoped = client.namespace("beamline/amx") + + assert isinstance(scoped, RecapClient) + assert scoped.namespace_path == "beamline/amx" + assert scoped.connection_state.backend is client.connection_state.backend + + scoped.close() + + +def test_scoped_client_uses_scope_for_queries_and_namespace_creation(tmp_path): + with RecapClient.from_sqlite(tmp_path / "recap.db") as client: + client.create_namespace("beamline") + client.create_namespace("beamline/amx") + scoped = client.namespace("beamline/amx") + context = scoped.create_namespace( + "beamline/amx/detector", {"beamline": "amx"}, as_current=True + ) + + assert context.path == "beamline/amx/detector" + assert scoped.query_maker().resources()._context == context + + scoped.close() + + +def test_namespace_context_tracks_metadata_revision_and_local_etag(tmp_path): + with RecapClient.from_sqlite(tmp_path / "recap.db") as client: + context = client.create_namespace( + "beamline", {"owner": "amx", "beamline": "x"}, as_current=True + ) + + assert context.metadata == {"owner": "amx", "beamline": "x"} + assert context.revision == 1 + assert context.etag == '"1"' + + updated = client.update_namespace( + metadata={"owner": "fmx"}, + status=LifecycleStatus.ACTIVE, + ) + + assert updated.metadata == {"owner": "fmx", "beamline": "x"} + assert updated.revision == 2 + assert updated.etag == '"2"' + assert client.namespace_context == updated + + +def test_remote_namespace_context_preserves_response_etag(monkeypatch): + monkeypatch.setattr( + RESTAdapter, + "get_namespace_context", + lambda self, path: fake_namespace_context(path), + ) + client = RecapClient.from_url("http://recap.test", api_key="secret") + namespace_id = uuid4() + + captured_context = None + + def execute(command, context): + nonlocal captured_context + captured_context = context + return NamespaceContext( + id=namespace_id, + path=command.path, + metadata=command.metadata or {}, + status=LifecycleStatus.ACTIVE, + revision=7, + etag='W/"remote-7"', + ) + + monkeypatch.setattr( + client.connection_state.backend.writer, + "execute", + execute, + ) + + context = client.create_namespace("beamline", {"owner": "amx"}, as_current=True) + + assert context.id == namespace_id + assert context.metadata == {"owner": "amx"} + assert context.revision == 7 + assert context.etag == 'W/"remote-7"' + assert captured_context is not None + client.close() + + +def test_remote_namespace_update_forwards_active_server_etag(monkeypatch): + monkeypatch.setattr( + RESTAdapter, + "get_namespace_context", + lambda self, path: fake_namespace_context(path), + ) + client = RecapClient.from_url("http://recap.test", api_key="secret") + namespace_id = uuid4() + client._namespace_context = NamespaceContext( + id=namespace_id, + path="beamline/amx", + revision=7, + etag='W/"server-7"', + ) + captured = {} + + def execute(command, context, *, etag_override=None): + captured["command"] = command + captured["etag_override"] = etag_override + return NamespaceContext( + id=namespace_id, + path="beamline/amx", + revision=8, + etag='W/"server-8"', + ) + + monkeypatch.setattr(client.connection_state.backend.writer, "execute", execute) + + client.update_namespace(metadata={"owner": "fmx"}) + + assert captured["command"].expected_revision == 7 + assert captured["etag_override"] == 'W/"server-7"' + client.close() diff --git a/recap/tests/test_namespace_commands.py b/recap/tests/test_namespace_commands.py new file mode 100644 index 0000000..5540a3b --- /dev/null +++ b/recap/tests/test_namespace_commands.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from dataclasses import replace +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +from recap.adapter.local import LocalBackend +from recap.authentication.models import ActorKind, ProviderIdentity, RequestActor +from recap.authorization.policy import UnrestrictedNamespacePolicy +from recap.authorization.scopes import Scope +from recap.client.permissions import EffectivePermissions +from recap.commands.errors import CommandConflictError, CommandValidationError +from recap.commands.models import CommandContext +from recap.commands.service import CommandService +from recap.db.audit import MutationAudit +from recap.db.base import Base +from recap.db.namespace import Namespace +from recap.exceptions import RecapPermissionDeniedError +from recap.lifecycle import LifecycleStatus +from recap.server.audit import AuditOutcome + + +class AuditCollector: + def __init__(self): + self.records = [] + + def emit(self, record): + self.records.append(record) + + +class RecordingPolicy(UnrestrictedNamespacePolicy): + def __init__(self): + self.paths = [] + + def permissions_for(self, actor, namespace_path): + self.paths.append(namespace_path) + return super().permissions_for(actor, namespace_path) + + +class DenyingPolicy: + def permissions_for(self, actor, namespace_path): + return EffectivePermissions( + identities=actor.identities, + snapshot_generation="generation-1", + effective_scopes=frozenset(), + matched_namespace_paths=(), + grants=(), + ) + + +@pytest.fixture +def command_setup(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'commands.db'}") + Base.metadata.create_all(engine) + factory = sessionmaker(engine, expire_on_commit=False) + with factory.begin() as session: + session.add(Namespace(path="", metadata_json={})) + actor = RequestActor( + actor_id="actor-1", + kind=ActorKind.USER, + identities=(ProviderIdentity(provider="test", subject="actor-1"),), + credential_scopes=frozenset(Scope), + namespace_restrictions=None, + credential_fingerprint="fingerprint", + ) + audit = AuditCollector() + policy = RecordingPolicy() + context = CommandContext( + actor=actor, + request_id=str(uuid4()), + policy=policy, + audit_sink=audit, + authorization_generation=None, + idempotency_key="namespace-command-1", + ) + return CommandService(factory), factory, context, policy, audit + + +def test_create_requires_existing_parent_and_sets_parent_id(command_setup): + service, factory, context, policy, _ = command_setup + + parent = service.create_namespace(context, path="beamline", metadata={}) + child_context = replace(context, idempotency_key="create-child") + created = service.create_namespace( + child_context, path="beamline/amx", metadata={"beamline": "amx"} + ) + + assert created.path == "beamline/amx" + assert policy.paths == ["", "beamline"] + with factory() as session: + stored_parent = session.get(Namespace, parent.id) + stored_child = session.get(Namespace, created.id) + assert stored_child.parent_id == stored_parent.id + + +def test_local_backend_namespace_commands_delegate(command_setup): + _, factory, context, _, _ = command_setup + backend = LocalBackend(factory) + + created = backend.create_namespace("beamline", {}, context) + updated = backend.update_namespace( + created.id, + expected_revision=1, + metadata={"owner": "amx"}, + status=LifecycleStatus.ACTIVE, + context=replace(context, idempotency_key="update-namespace"), + ) + + assert created.path == "beamline" + assert updated.metadata == {"owner": "amx"} + assert updated.revision == 2 + + +def test_create_missing_parent_fails_with_actionable_validation_error(command_setup): + service, factory, context, policy, audit = command_setup + + with pytest.raises( + CommandValidationError, + match=r"Parent namespace 'beamline' does not exist.*beamline/amx", + ): + service.create_namespace(context, path="beamline/amx", metadata={}) + + assert policy.paths == ["beamline"] + assert audit.records[-1].outcome is AuditOutcome.ERROR + with factory() as session: + assert ( + session.scalar(select(Namespace).where(Namespace.path == "beamline/amx")) + is None + ) + + +def test_missing_parent_does_not_consume_idempotency_key(command_setup): + service, _, context, _, _ = command_setup + + with pytest.raises(CommandValidationError): + service.create_namespace(context, path="beamline/amx", metadata={}) + + parent_context = replace(context, idempotency_key="create-parent") + child_context = replace(context, idempotency_key="create-child") + service.create_namespace(parent_context, path="beamline", metadata={}) + created = service.create_namespace(child_context, path="beamline/amx", metadata={}) + + assert created.path == "beamline/amx" + + +def test_top_level_create_authorizes_root(command_setup): + service, _, context, policy, _ = command_setup + + service.create_namespace(context, path="beamline", metadata={}) + + assert policy.paths == [""] + + +def test_create_replay_returns_exact_result_without_duplicate_mutation(command_setup): + service, factory, context, policy, audit = command_setup + service.create_namespace( + replace(context, idempotency_key="create-parent"), + path="beamline", + metadata={}, + ) + first = service.create_namespace(context, path="beamline/amx", metadata={"a": 1}) + + replay = service.create_namespace(context, path="beamline/amx", metadata={"a": 1}) + + assert replay == first + assert policy.paths == ["", "beamline", "beamline"] + assert len(audit.records) == 2 + with factory() as session: + assert ( + len( + session.scalars( + select(Namespace).where(Namespace.path != "") + ).all() + ) + == 2 + ) + + +def test_idempotency_key_reuse_for_different_create_conflicts(command_setup): + service, _, context, _, _ = command_setup + service.create_namespace( + replace(context, idempotency_key="create-parent"), + path="beamline", + metadata={}, + ) + service.create_namespace(context, path="beamline/amx", metadata={}) + + with pytest.raises(CommandConflictError, match="different command"): + service.create_namespace(context, path="beamline/fmx", metadata={}) + + +def test_update_checks_revision_merges_metadata_and_transitions_status(command_setup): + service, _, context, _, _ = command_setup + service.create_namespace( + replace(context, idempotency_key="create-parent"), + path="beamline", + metadata={}, + ) + created = service.create_namespace( + context, path="beamline/amx", metadata={"a": 1, "owner": "old"} + ) + update_context = replace(context, idempotency_key="namespace-command-2") + + updated = service.update_namespace( + update_context, + namespace_id=created.id, + metadata={"owner": "amx"}, + status=LifecycleStatus.ACTIVE, + expected_revision=1, + ) + + assert updated.metadata == {"a": 1, "owner": "amx"} + assert updated.status is LifecycleStatus.ACTIVE + assert updated.revision == 2 + + +def test_stale_update_rolls_back_mutation_and_records_failure(command_setup): + service, factory, context, _, audit = command_setup + service.create_namespace( + replace(context, idempotency_key="create-parent"), + path="beamline", + metadata={}, + ) + created = service.create_namespace(context, path="beamline/amx", metadata={}) + + with pytest.raises(CommandConflictError, match="revision"): + service.update_namespace( + replace(context, idempotency_key="namespace-command-2"), + namespace_id=created.id, + metadata={"wrong": True}, + expected_revision=2, + ) + + with factory() as session: + stored = session.get(Namespace, created.id) + assert stored.metadata_json == {} + assert stored.revision == 1 + assert session.scalars(select(MutationAudit)).all()[-1].outcome == "error" + assert audit.records[-1].outcome is AuditOutcome.ERROR + + +def test_invalid_status_transition_rolls_back(command_setup): + service, _, context, _, _ = command_setup + service.create_namespace( + replace(context, idempotency_key="create-parent"), + path="beamline", + metadata={}, + ) + created = service.create_namespace(context, path="beamline/amx", metadata={}) + active = service.update_namespace( + replace(context, idempotency_key="namespace-command-2"), + namespace_id=created.id, + status=LifecycleStatus.ACTIVE, + expected_revision=1, + ) + + with pytest.raises(CommandValidationError, match="lifecycle transition"): + service.update_namespace( + replace(context, idempotency_key="namespace-command-3"), + namespace_id=active.id, + status=LifecycleStatus.MUTABLE, + expected_revision=2, + ) + + +def test_denied_create_does_not_open_mutation(command_setup): + service, factory, context, _, audit = command_setup + + with pytest.raises(RecapPermissionDeniedError, match="Permission denied"): + service.create_namespace( + replace(context, policy=DenyingPolicy()), path="beamline/amx", metadata={} + ) + + with factory() as session: + assert ( + session.scalar(select(Namespace).where(Namespace.path == "beamline/amx")) + is None + ) + assert audit.records[-1].outcome is AuditOutcome.DENIED diff --git a/recap/tests/test_namespace_migration.py b/recap/tests/test_namespace_migration.py new file mode 100644 index 0000000..faf6c33 --- /dev/null +++ b/recap/tests/test_namespace_migration.py @@ -0,0 +1,266 @@ +import json +from uuid import uuid4 + +from sqlalchemy import create_engine, inspect, text + +from recap.utils.migrations import apply_migrations + + +def test_campaign_data_is_backfilled_into_namespaces(tmp_path): + db_url = f"sqlite:///{tmp_path / 'migration.db'}" + campaign_id = uuid4() + process_template_id = uuid4() + process_run_id = uuid4() + resource_type_id = uuid4() + resource_template_id = uuid4() + resource_slot_id = uuid4() + assigned_resource_id = uuid4() + unassigned_resource_id = uuid4() + resource_assignment_id = uuid4() + + apply_migrations(db_url, revision="f11ecd5c55cf") + engine = create_engine(db_url) + with engine.begin() as connection: + connection.execute( + text( + """ + INSERT INTO campaign (id, name, proposal, saf, meta_data) + VALUES (:id, 'Run 2026-1', '312345', '310000', :metadata) + """ + ), + {"id": campaign_id.hex, "metadata": json.dumps({"sample": "Ni"})}, + ) + connection.execute( + text( + """ + INSERT INTO process_template (id, name, version, is_active) + VALUES (:id, 'scan', '1.0', 1) + """ + ), + {"id": process_template_id.hex}, + ) + connection.execute( + text( + """ + INSERT INTO process_run + (id, name, description, process_template_id, campaign_id) + VALUES (:id, 'scan-1', '', :template_id, :campaign_id) + """ + ), + { + "id": process_run_id.hex, + "template_id": process_template_id.hex, + "campaign_id": campaign_id.hex, + }, + ) + connection.execute( + text("INSERT INTO resource_type (id, name) VALUES (:id, 'sample')"), + {"id": resource_type_id.hex}, + ) + connection.execute( + text( + """ + INSERT INTO resource_template (id, name, version) + VALUES (:id, 'sample', '1.0') + """ + ), + {"id": resource_template_id.hex}, + ) + connection.execute( + text( + """ + INSERT INTO resource_slot + (id, name, process_template_id, resource_type_id, direction) + VALUES (:id, 'sample', :template_id, :type_id, 'input') + """ + ), + { + "id": resource_slot_id.hex, + "template_id": process_template_id.hex, + "type_id": resource_type_id.hex, + }, + ) + connection.execute( + text( + """ + INSERT INTO resource + (id, name, active, resource_template_id) + VALUES + (:assigned_id, 'assigned', 1, :template_id), + (:unassigned_id, 'unassigned', 1, :template_id) + """ + ), + { + "assigned_id": assigned_resource_id.hex, + "unassigned_id": unassigned_resource_id.hex, + "template_id": resource_template_id.hex, + }, + ) + connection.execute( + text( + """ + INSERT INTO resource_assignment + (id, process_run_id, resource_slot_id, resource_id) + VALUES (:id, :run_id, :slot_id, :resource_id) + """ + ), + { + "id": resource_assignment_id.hex, + "run_id": process_run_id.hex, + "slot_id": resource_slot_id.hex, + "resource_id": assigned_resource_id.hex, + }, + ) + + campaign_path = f"default/campaign/{campaign_id}" + apply_migrations( + db_url, + campaign_namespace_paths={campaign_id: campaign_path}, + base_namespace_path="default", + ) + + with engine.connect() as connection: + namespace = ( + connection.execute( + text("SELECT id, path, metadata_json FROM namespace WHERE id = :id"), + {"id": campaign_id.hex}, + ) + .mappings() + .one() + ) + process_run = ( + connection.execute( + text("SELECT namespace_id FROM process_run WHERE id = :id"), + {"id": process_run_id.hex}, + ) + .mappings() + .one() + ) + resources = { + row["id"]: row + for row in connection.execute( + text("SELECT id, namespace_id, status, revision FROM resource") + ).mappings() + } + templates = ( + connection.execute( + text( + """ + SELECT + (SELECT namespace_id FROM process_template WHERE id = :process_id) + AS process_namespace_id, + (SELECT namespace_id FROM resource_template WHERE id = :resource_id) + AS resource_namespace_id + """ + ), + { + "process_id": process_template_id.hex, + "resource_id": resource_template_id.hex, + }, + ) + .mappings() + .one() + ) + assignment = ( + connection.execute( + text( + "SELECT id, process_run_id, resource_id FROM resource_assignment " + "WHERE id = :id" + ), + {"id": resource_assignment_id.hex}, + ) + .mappings() + .one() + ) + missing_owners = { + table_name: connection.scalar( + text(f"SELECT count(*) FROM {table_name} WHERE namespace_id IS NULL") + ) + for table_name in ( + "process_run", + "process_template", + "resource_template", + "resource", + ) + } + + metadata = json.loads(namespace["metadata_json"]) + assert namespace["path"] == campaign_path + assert namespace["id"] == campaign_id.hex + assert process_run["namespace_id"] == namespace["id"] + assert resources[assigned_resource_id.hex]["status"] == "ACTIVE" + assert resources[unassigned_resource_id.hex]["status"] == "MUTABLE" + assert assignment == { + "id": resource_assignment_id.hex, + "process_run_id": process_run_id.hex, + "resource_id": assigned_resource_id.hex, + } + assert not any(missing_owners.values()) + assert metadata["recap.campaign.name"] == "Run 2026-1" + assert metadata["nsls2.proposal"] == "312345" + assert metadata["nsls2.saf"] == "310000" + assert metadata["recap.campaign.metadata"] == {"sample": "Ni"} + assert templates["process_namespace_id"] == templates["resource_namespace_id"] + assert resources[assigned_resource_id.hex]["revision"] == 1 + + inspector = inspect(engine) + assert "campaign" not in inspector.get_table_names() + assert "campaign_id" not in { + column["name"] for column in inspector.get_columns("process_run") + } + + +def test_fresh_database_uses_empty_root_namespace(tmp_path): + db_path = tmp_path / "fresh.db" + db_url = f"sqlite:///{db_path}" + apply_migrations(db_url) + + with create_engine(db_url).connect() as connection: + root = ( + connection.execute( + text("SELECT path, parent_id FROM namespace WHERE path = ''") + ) + .mappings() + .one() + ) + + assert root["path"] == "" + assert root["parent_id"] is None + + +def test_explicit_campaign_namespace_path_is_unchanged_with_empty_base(tmp_path): + db_url = f"sqlite:///{tmp_path / 'migration.db'}" + campaign_id = uuid4() + + apply_migrations(db_url, revision="f11ecd5c55cf") + engine = create_engine(db_url) + with engine.begin() as connection: + connection.execute( + text( + """ + INSERT INTO campaign (id, name, proposal, saf, meta_data) + VALUES (:id, 'Run 2026-1', '312345', '310000', '{}') + """ + ), + {"id": campaign_id.hex}, + ) + + apply_migrations( + db_url, + campaign_namespace_paths={campaign_id: "beamline/amx"}, + base_namespace_path="", + ) + + with engine.connect() as connection: + campaign = ( + connection.execute( + text("SELECT path, parent_id FROM namespace WHERE id = :id"), + {"id": campaign_id.hex}, + ) + .mappings() + .one() + ) + root_id = connection.scalar(text("SELECT id FROM namespace WHERE path = ''")) + + assert campaign["path"] == "beamline/amx" + assert campaign["parent_id"] == root_id diff --git a/recap/tests/test_namespace_models.py b/recap/tests/test_namespace_models.py new file mode 100644 index 0000000..48cf3b7 --- /dev/null +++ b/recap/tests/test_namespace_models.py @@ -0,0 +1,68 @@ +from collections.abc import Mapping +from typing import Any + +import pytest + +from recap.db.namespace import Namespace, NamespaceRepository +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext, NamespaceRef, NamespaceSchema + + +@pytest.fixture +def namespace_repository(db_session): + return NamespaceRepository(db_session) + + +def test_namespace_round_trip(db_session): + root = Namespace(path="beamline", metadata_json={"facility": "nsls2"}) + child = Namespace( + path="beamline/amx", parent=root, metadata_json={"beamline": "amx"} + ) + db_session.add_all([root, child]) + db_session.flush() + + schema = NamespaceSchema.model_validate(child) + assert schema.path == "beamline/amx" + assert schema.parent_id == root.id + assert schema.status is LifecycleStatus.MUTABLE + assert schema.revision == 1 + assert schema.metadata == {"beamline": "amx"} + assert schema.model_dump()["metadata"] == {"beamline": "amx"} + assert NamespaceRef.model_validate(child).path == child.path + context = NamespaceContext.model_validate(child) + assert context.path == child.path + assert context.metadata == {"beamline": "amx"} + + +def test_effective_metadata_merges_ancestors(namespace_repository, db_session): + namespace_repository.create("beamline") + namespace_repository.create("beamline/amx", {"facility": "nsls2"}) + namespace_repository.create("beamline/amx/proposal") + child = namespace_repository.create( + "beamline/amx/proposal/312345", {"nsls2.proposal": "312345"} + ) + db_session.flush() + assert child.parent.path == "beamline/amx/proposal" + assert namespace_repository.effective_metadata(child.id) == { + "facility": "nsls2", + "nsls2.proposal": "312345", + } + + +def test_site_validator_rejects_conflicting_protected_metadata(db_session): + def reject_conflicts( + inherited: Mapping[str, Any], local: Mapping[str, Any] + ) -> None: + if ( + "facility" in inherited + and "facility" in local + and local["facility"] != inherited["facility"] + ): + raise ValueError("facility metadata is protected") + + repository = NamespaceRepository(db_session, site_validator=reject_conflicts) + repository.create("facility", {"facility": "nsls2"}) + db_session.flush() + + with pytest.raises(ValueError, match="facility metadata is protected"): + repository.create("facility/beamline", {"facility": "other"}) diff --git a/recap/tests/test_namespace_ownership.py b/recap/tests/test_namespace_ownership.py new file mode 100644 index 0000000..a6b81ad --- /dev/null +++ b/recap/tests/test_namespace_ownership.py @@ -0,0 +1,244 @@ +from uuid import uuid4 + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import sessionmaker + +from recap.adapter.local import LocalBackend +from recap.db.namespace import Namespace +from recap.db.process import ProcessRun, ProcessTemplate +from recap.db.resource import Resource, ResourceTemplate +from recap.lifecycle import LifecycleStatus +from recap.schemas.process import ( + ProcessRunSchema, + ProcessTemplateRef, + ProcessTemplateSchema, +) +from recap.schemas.resource import ( + ResourceSchema, + ResourceTemplateRef, + ResourceTemplateSchema, +) + + +def _namespace(path: str) -> Namespace: + return Namespace(id=uuid4(), path=path, metadata_json={}) + + +def test_template_identity_is_scoped_by_namespace(db_session): + amx = _namespace("beamline/amx") + fmx = _namespace("beamline/fmx") + amx_template = ProcessTemplate(namespace=amx, name="data acquisition", version="1") + fmx_template = ProcessTemplate(namespace=fmx, name="data acquisition", version="1") + amx_resource_template = ResourceTemplate( + namespace=amx, name="detector", version="1" + ) + fmx_resource_template = ResourceTemplate( + namespace=fmx, name="detector", version="1" + ) + db_session.add_all( + [ + amx, + fmx, + amx_template, + fmx_template, + amx_resource_template, + fmx_resource_template, + ] + ) + db_session.flush() + + assert amx_template.name == fmx_template.name + assert amx_template.namespace_id != fmx_template.namespace_id + assert amx_resource_template.name == fmx_resource_template.name + assert amx_resource_template.namespace_id != fmx_resource_template.namespace_id + + db_session.add(ProcessTemplate(namespace=amx, name="data acquisition", version="1")) + with pytest.raises(IntegrityError): + db_session.flush() + + +def test_process_run_names_are_scoped_by_namespace(db_session): + amx = _namespace("run/amx") + fmx = _namespace("run/fmx") + template_a = ProcessTemplate(namespace=amx, name="collect", version="1") + template_b = ProcessTemplate(namespace=fmx, name="collect", version="1") + run_a = ProcessRun( + namespace=amx, + name="run-1", + description="", + template=template_a, + ) + run_b = ProcessRun( + namespace=fmx, + name="run-1", + description="", + template=template_b, + ) + db_session.add_all([amx, fmx, template_a, template_b, run_a, run_b]) + db_session.flush() + + assert run_a.name == run_b.name + assert run_a.namespace_id != run_b.namespace_id + assert run_a.namespace is amx + + run_a.namespace_id = fmx.id + with pytest.raises(ValueError, match="namespace_id is immutable"): + db_session.flush() + + +def test_resource_top_level_identity_is_uuid_and_children_inherit_namespace(db_session): + amx = _namespace("resource/amx") + child_template = ResourceTemplate(name="well", version="1") + template = ResourceTemplate( + namespace=amx, name="plate", version="1", children={"well": child_template} + ) + first = Resource(namespace=amx, name="plate-1", template=template) + second = Resource(namespace=amx, name="plate-1", template=template) + db_session.add_all([amx, template, first, second]) + db_session.flush() + + assert first.id != second.id + assert child_template.namespace_id == amx.id + assert first.children["well"].namespace_id == amx.id + + +@pytest.mark.parametrize( + "factory", + [ + lambda namespace: ProcessTemplate( + namespace=namespace, name="process", version="1" + ), + lambda namespace: ResourceTemplate( + namespace=namespace, name="resource template", version="1" + ), + lambda namespace: Resource( + namespace=namespace, + name="resource", + template=ResourceTemplate( + namespace=namespace, name="resource type", version="1" + ), + ), + ], +) +def test_namespace_id_is_immutable(db_session, factory): + namespace = _namespace(f"immutable/{uuid4()}") + aggregate = factory(namespace) + db_session.add_all([namespace, aggregate]) + db_session.flush() + + aggregate.namespace_id = uuid4() + with pytest.raises(ValueError, match="namespace_id is immutable"): + db_session.flush() + + +def test_template_labels_are_normalized(db_session): + namespace = _namespace("labels") + process_template = ProcessTemplate( + namespace=namespace, + name="collect", + version="1", + labels=["MX Data Acquisition", "FAST-PATH"], + ) + resource_template = ResourceTemplate( + namespace=namespace, + name="sample", + version="1", + labels=["MX Sample Holder"], + ) + db_session.add_all([namespace, process_template, resource_template]) + db_session.flush() + + assert process_template.labels == ["mx_data_acquisition", "fast_path"] + assert resource_template.labels == ["mx_sample_holder"] + + process_data = ProcessTemplateRef.model_validate(process_template).model_dump() + process_data["labels"] = ["MX Data Acquisition"] + resource_data = ResourceTemplateRef.model_validate(resource_template).model_dump() + resource_data["labels"] = ["MX Sample Holder"] + assert ProcessTemplateRef.model_validate(process_data).labels == [ + "mx_data_acquisition" + ] + assert ResourceTemplateRef.model_validate(resource_data).labels == [ + "mx_sample_holder" + ] + + +def test_aggregate_schemas_expose_namespace_lifecycle_and_copy_identity(db_session): + namespace = _namespace("schemas") + process_template = ProcessTemplate( + namespace=namespace, name="collect", version="1", labels=["MX Collect"] + ) + process_run = ProcessRun( + namespace=namespace, + name="run", + description="", + template=process_template, + ) + resource_template = ResourceTemplate( + namespace=namespace, name="sample", version="1", labels=["MX Sample"] + ) + source = Resource(namespace=namespace, name="source", template=resource_template) + copy = Resource( + namespace=namespace, + name="copy", + template=resource_template, + copied_from=source, + ) + db_session.add_all( + [ + namespace, + process_template, + process_run, + resource_template, + source, + copy, + ] + ) + db_session.flush() + + schemas = ( + ProcessTemplateSchema.model_validate(process_template), + ProcessRunSchema.model_validate(process_run), + ResourceTemplateSchema.model_validate(resource_template), + ResourceSchema.model_validate(copy), + ) + for schema in schemas: + assert schema.namespace_id == namespace.id + assert schema.revision == 1 + assert [schema.status for schema in schemas] == [ + LifecycleStatus.MUTABLE, + LifecycleStatus.MUTABLE, + LifecycleStatus.ACTIVE, + LifecycleStatus.MUTABLE, + ] + assert ResourceSchema.model_validate(copy).copied_from_id == source.id + + +def test_backend_get_is_namespace_scoped(db_session): + amx = _namespace("backend/amx") + fmx = _namespace("backend/fmx") + factory = sessionmaker(bind=db_session.get_bind(), expire_on_commit=False) + with factory.begin() as session: + session.add_all( + [ + amx, + fmx, + ProcessTemplate(namespace=amx, name="data acquisition", version="1"), + ProcessTemplate(namespace=fmx, name="data acquisition", version="1"), + ] + ) + backend = LocalBackend(factory) + amx_template = backend.get_process_template( + amx.id, "data acquisition", "1", expand=False + ) + fmx_template = backend.get_process_template( + fmx.id, "data acquisition", "1", expand=False + ) + + assert amx_template.namespace_id == amx.id + assert fmx_template.namespace_id == fmx.id + assert ( + backend.get_process_template(amx.id, "data acquisition", "1", expand=False).id + == amx_template.id + ) diff --git a/recap/tests/test_namespace_path.py b/recap/tests/test_namespace_path.py new file mode 100644 index 0000000..4eaebee --- /dev/null +++ b/recap/tests/test_namespace_path.py @@ -0,0 +1,29 @@ +import pytest + +from recap.utils.namespace import ( + canonicalize_namespace_path, + is_namespace_ancestor, + namespace_ancestors, + parent_namespace_path, +) + + +def test_namespace_path_and_ancestors(): + path = canonicalize_namespace_path("beamline/amx/proposal/312345") + assert path == "beamline/amx/proposal/312345" + assert parent_namespace_path(path) == "beamline/amx/proposal" + assert namespace_ancestors(path) == ( + "", + "beamline", + "beamline/amx", + "beamline/amx/proposal", + path, + ) + assert is_namespace_ancestor("beamline/amx", path) + assert not is_namespace_ancestor("beamline/am", path) + + +@pytest.mark.parametrize("path", ["/amx", "amx/", "a//b", "a/../b", "a/%2fb"]) +def test_rejects_noncanonical_namespace_paths(path): + with pytest.raises(ValueError, match="Namespace path"): + canonicalize_namespace_path(path) diff --git a/recap/tests/test_namespace_policy.py b/recap/tests/test_namespace_policy.py new file mode 100644 index 0000000..165c191 --- /dev/null +++ b/recap/tests/test_namespace_policy.py @@ -0,0 +1,279 @@ +import pytest + +from recap.authentication.models import ActorKind, ProviderIdentity, RequestActor +from recap.authorization.policy import ( + DenialCode, + SnapshotNamespacePolicy, + UnrestrictedNamespacePolicy, +) +from recap.authorization.scopes import Scope +from recap.authorization.snapshot import ( + AuthorizationSnapshot, + GrantProvenance, + SnapshotMetadata, +) + + +def actor( + *identities: ProviderIdentity, + scopes: frozenset[Scope] = frozenset(Scope), + restrictions: frozenset[str] | None = None, +) -> RequestActor: + return RequestActor( + actor_id="current-actor", + kind=ActorKind.USER, + identities=identities, + credential_scopes=scopes, + namespace_restrictions=restrictions, + credential_fingerprint="sha256:test", + ) + + +def grant( + identity: ProviderIdentity, + namespace_path: str, + scope: Scope, + *, + group: str = "scientists", + role: str = "member", +) -> GrantProvenance: + return GrantProvenance( + identity=identity, + namespace_path=namespace_path, + scope=scope, + group=group, + role=role, + ) + + +def policy(*grants: GrantProvenance) -> SnapshotNamespacePolicy: + return SnapshotNamespacePolicy( + AuthorizationSnapshot( + metadata=SnapshotMetadata(format_version=1, source_revision="revision-7"), + grants=frozenset(grants), + ) + ) + + +def test_inherits_ancestor_grants_but_isolates_siblings_and_matches_provider_exactly(): + pam_alice = ProviderIdentity(provider="pam", subject="alice") + oidc_alice = ProviderIdentity(provider="oidc", subject="alice") + namespace_policy = policy( + grant(pam_alice, "beamline", Scope.NAMESPACE_READ), + grant(oidc_alice, "beamline/amx", Scope.RESOURCE_READ), + grant(pam_alice, "beamline/fmx", Scope.RESOURCE_WRITE), + ) + + permissions = namespace_policy.permissions_for( + actor(pam_alice), "beamline/amx/proposal/123" + ) + + assert permissions.snapshot_generation == "revision-7" + assert permissions.identities == (pam_alice,) + assert permissions.effective_scopes == frozenset({Scope.NAMESPACE_READ}) + assert permissions.matched_namespace_paths == ("beamline",) + assert permissions.grants == (grant(pam_alice, "beamline", Scope.NAMESPACE_READ),) + + +def test_unions_identity_grants_then_intersects_credential_scope_and_restriction(): + pam_alice = ProviderIdentity(provider="pam", subject="alice") + oidc_alice = ProviderIdentity(provider="oidc", subject="alice@example.com") + namespace_policy = policy( + grant(pam_alice, "beamline", Scope.NAMESPACE_READ), + grant(oidc_alice, "beamline/amx", Scope.RESOURCE_READ), + grant(pam_alice, "beamline/amx", Scope.RESOURCE_WRITE), + ) + current_actor = actor( + pam_alice, + oidc_alice, + scopes=frozenset({Scope.NAMESPACE_READ, Scope.RESOURCE_READ}), + restrictions=frozenset({"beamline/amx"}), + ) + + permissions = namespace_policy.permissions_for(current_actor, "beamline/amx/run") + + assert permissions.effective_scopes == frozenset( + {Scope.NAMESPACE_READ, Scope.RESOURCE_READ} + ) + assert {item.scope for item in permissions.grants} == { + Scope.NAMESPACE_READ, + Scope.RESOURCE_READ, + } + assert ( + namespace_policy.permissions_for(current_actor, "beamline/fmx").effective_scopes + == frozenset() + ) + + +def test_write_grant_never_implies_read_and_denial_has_safe_code_only(): + identity = ProviderIdentity(provider="pam", subject="alice") + namespace_policy = policy(grant(identity, "beamline/amx", Scope.RESOURCE_WRITE)) + + decision = namespace_policy.authorize( + actor(identity), Scope.RESOURCE_READ, "beamline/amx" + ) + + assert not decision.allowed + assert decision.denial_code is DenialCode.INSUFFICIENT_SCOPE + assert decision.permissions is None + assert decision.model_dump() == { + "allowed": False, + "denial_code": DenialCode.INSUFFICIENT_SCOPE, + "permissions": None, + } + + +def test_process_run_requires_exact_context_but_templates_and_resources_allow_ancestors(): + identity = ProviderIdentity(provider="pam", subject="alice") + namespace_policy = policy( + grant(identity, "beamline", Scope.PROCESS_RUN_READ), + grant(identity, "beamline", Scope.PROCESS_TEMPLATE_READ), + grant(identity, "beamline", Scope.RESOURCE_READ), + ) + current_actor = actor(identity) + + assert not namespace_policy.authorize_process_run( + current_actor, + Scope.PROCESS_RUN_READ, + context_namespace_path="beamline/amx", + target_namespace_path="beamline", + ).allowed + assert namespace_policy.authorize_template( + current_actor, + Scope.PROCESS_TEMPLATE_READ, + context_namespace_path="beamline/amx", + target_namespace_path="beamline", + ).allowed + assert namespace_policy.authorize_resource( + current_actor, + Scope.RESOURCE_READ, + context_namespace_path="beamline/amx", + target_namespace_path="beamline", + ).allowed + + +def test_proposal_read_does_not_grant_parent_update(): + identity = ProviderIdentity(provider="pam", subject="alice") + namespace_policy = policy( + grant(identity, "beamline/amx/proposal/123", Scope.NAMESPACE_READ), + grant(identity, "beamline/amx/proposal/123", Scope.NAMESPACE_WRITE), + ) + + assert namespace_policy.authorize( + actor(identity), Scope.NAMESPACE_READ, "beamline/amx/proposal/123" + ).allowed + assert not namespace_policy.authorize( + actor(identity), Scope.NAMESPACE_WRITE, "beamline/amx" + ).allowed + + +def test_namespace_creation_requires_parent_write_authorization(): + identity = ProviderIdentity(provider="pam", subject="alice") + namespace_policy = policy(grant(identity, "beamline/amx", Scope.NAMESPACE_WRITE)) + + assert namespace_policy.authorize_namespace_create( + actor(identity), "beamline/amx/proposal" + ).allowed + denied = namespace_policy.authorize_namespace_create( + actor(identity), "beamline/fmx/proposal" + ) + assert not denied.allowed + assert denied.denial_code is DenialCode.INSUFFICIENT_SCOPE + + +@pytest.mark.parametrize( + ("missing_scope", "expected_code"), + [ + (Scope.RESOURCE_READ, DenialCode.SOURCE_READ_REQUIRED), + (Scope.RESOURCE_WRITE, DenialCode.DESTINATION_WRITE_REQUIRED), + ], +) +def test_copy_requires_source_read_and_destination_write( + missing_scope: Scope, expected_code: DenialCode +): + identity = ProviderIdentity(provider="pam", subject="alice") + grants = [ + grant(identity, "source", Scope.RESOURCE_READ), + grant(identity, "destination", Scope.RESOURCE_WRITE), + ] + namespace_policy = policy( + *(item for item in grants if item.scope is not missing_scope) + ) + + decision = namespace_policy.authorize_copy( + actor(identity), + source_namespace_path="source", + destination_namespace_path="destination", + ) + + assert not decision.allowed + assert decision.denial_code is expected_code + assert decision.permissions is None + + +def test_copy_is_allowed_when_both_sides_are_authorized(): + identity = ProviderIdentity(provider="pam", subject="alice") + namespace_policy = policy( + grant(identity, "source", Scope.RESOURCE_READ), + grant(identity, "destination", Scope.RESOURCE_WRITE), + ) + + assert namespace_policy.authorize_copy( + actor(identity), + source_namespace_path="source", + destination_namespace_path="destination", + ).allowed + + +def test_namespace_read_grant_discovers_descendants(): + identity = ProviderIdentity(provider="pam", subject="alice") + namespace_policy = policy(grant(identity, "beamlines", Scope.NAMESPACE_READ)) + + assert namespace_policy.can_discover_namespace( + actor(identity), "beamlines/amx" + ) + + +def test_leaf_grant_discovers_only_required_ancestor_prefixes(): + identity = ProviderIdentity(provider="pam", subject="alice") + namespace_policy = policy( + grant(identity, "beamlines/amx/proposals/pass-312345", Scope.NAMESPACE_READ) + ) + + assert namespace_policy.can_discover_namespace(actor(identity), "beamlines") + assert namespace_policy.can_discover_namespace(actor(identity), "beamlines/amx") + assert namespace_policy.can_discover_namespace( + actor(identity), "beamlines/amx/proposals/pass-312345" + ) + assert not namespace_policy.can_discover_namespace(actor(identity), "staff") + + +def test_discovery_honors_identity_scope_and_namespace_restriction(): + identity = ProviderIdentity(provider="pam", subject="alice") + namespace_policy = policy( + grant(identity, "beamlines/amx/secret", Scope.NAMESPACE_READ) + ) + + assert not namespace_policy.can_discover_namespace( + actor(identity, scopes=frozenset()), "beamlines" + ) + assert not namespace_policy.can_discover_namespace( + actor(identity, restrictions=frozenset({"other"})), "beamlines" + ) + + +def test_unrestricted_policy_grants_single_user_all_scopes_without_provenance(): + current_actor = actor(ProviderIdentity(provider="api-key", subject="single-user")) + namespace_policy = UnrestrictedNamespacePolicy() + + permissions = namespace_policy.permissions_for(current_actor, "beamline/amx") + + assert permissions.effective_scopes == frozenset(Scope) + assert permissions.snapshot_generation is None + assert permissions.matched_namespace_paths == () + assert permissions.grants == () + assert namespace_policy.authorize_copy( + current_actor, + source_namespace_path="source", + destination_namespace_path="destination", + ).allowed diff --git a/recap/tests/test_namespace_queries.py b/recap/tests/test_namespace_queries.py new file mode 100644 index 0000000..9c01bfa --- /dev/null +++ b/recap/tests/test_namespace_queries.py @@ -0,0 +1,240 @@ +from uuid import uuid4 + +from sqlalchemy.orm import sessionmaker + +from recap.adapter.local import LocalBackend +from recap.adapter.transport import QueryRequest +from recap.db.namespace import Namespace +from recap.db.process import ProcessRun, ProcessTemplate +from recap.db.resource import Resource, ResourceTemplate +from recap.dsl.query import Field, QueryDSL, QuerySpec +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext +from recap.schemas.resource import ResourceTemplateSchema + + +def _namespace(path, *, parent=None, metadata=None): + return Namespace( + id=uuid4(), + path=path, + parent=parent, + metadata_json=metadata or {}, + status=LifecycleStatus.ACTIVE, + ) + + +def _query(db_session, namespace): + backend = LocalBackend(sessionmaker(bind=db_session.get_bind())) + context = NamespaceContext(id=namespace.id, path=namespace.path) + return QueryDSL(backend, context=context) + + +def test_namespace_context_is_transport_context_not_query_spec(): + request = QueryRequest.from_query( + ResourceTemplateSchema, + QuerySpec(), + namespace_path="beamline/amx/proposal/312345", + ) + + assert request.namespace_path == "beamline/amx/proposal/312345" + assert "namespace_path" not in request.spec + + +def test_process_runs_are_visible_only_in_exact_namespace(db_session): + parent = _namespace("beamline/amx") + own = _namespace("beamline/amx/proposal/1", parent=parent) + sibling = _namespace("beamline/amx/proposal/2", parent=parent) + templates = [ + ProcessTemplate( + namespace=namespace, + name=f"template-{index}", + version="1", + status=LifecycleStatus.ACTIVE, + ) + for index, namespace in enumerate((parent, own, sibling)) + ] + runs = [ + ProcessRun( + namespace=namespace, + name=f"run-{index}", + description="run", + template=template, + status=LifecycleStatus.ACTIVE, + ) + for index, (namespace, template) in enumerate( + zip((parent, own, sibling), templates, strict=True) + ) + ] + db_session.add_all([parent, own, sibling, *templates, *runs]) + db_session.commit() + + result = _query(db_session, own).process_runs(shape="ref").all() + + assert [item.name for item in result] == ["run-1"] + + +def test_templates_inherit_active_ancestors_and_archive_is_opt_in(db_session): + parent = _namespace("beamline/fmx") + own = _namespace("beamline/fmx/proposal/1", parent=parent) + sibling = _namespace("beamline/fmx/proposal/2", parent=parent) + templates = [ + ProcessTemplate( + namespace=parent, + name="ancestor", + version="1", + labels=["MX Data Acquisition"], + status=LifecycleStatus.ACTIVE, + ), + ProcessTemplate( + namespace=own, + name="own-active", + version="1", + labels=["MX Data Acquisition"], + status=LifecycleStatus.ACTIVE, + ), + ProcessTemplate( + namespace=own, + name="own-mutable", + version="1", + status=LifecycleStatus.MUTABLE, + ), + ProcessTemplate( + namespace=own, + name="own-archived", + version="1", + status=LifecycleStatus.ARCHIVED, + ), + ProcessTemplate( + namespace=sibling, + name="sibling", + version="1", + status=LifecycleStatus.ACTIVE, + ), + ] + db_session.add_all([parent, own, sibling, *templates]) + db_session.commit() + + query = _query(db_session, own).process_templates(shape="ref") + assert {item.name for item in query.all()} == {"ancestor", "own-active"} + assert {item.name for item in query.include_archived().all()} == { + "ancestor", + "own-active", + "own-archived", + } + assert {item.name for item in query.filter_label("mx_data_acquisition").all()} == { + "ancestor", + "own-active", + } + + +def test_visibility_precedes_filter_pagination_and_count(db_session): + root = _namespace("facility") + own = _namespace("facility/proposal", parent=root) + sibling = _namespace("facility/other", parent=root) + rows = [ + ProcessTemplate( + namespace=namespace, + name=name, + version="1", + status=LifecycleStatus.ACTIVE, + ) + for namespace, name in ( + (root, "a"), + (own, "b"), + (own, "c"), + (sibling, "d"), + ) + ] + db_session.add_all([root, own, sibling, *rows]) + db_session.commit() + + query = ( + _query(db_session, own).process_templates(shape="ref").order_by(Field("name")) + ) + assert [item.name for item in query.offset(1).limit(1).all()] == ["b"] + assert query.count() == 3 + + +def test_resources_and_resource_templates_inherit_active_ancestors(db_session): + parent = _namespace("resource/amx") + own = _namespace("resource/amx/proposal", parent=parent) + sibling = _namespace("resource/fmx") + ancestor_template = ResourceTemplate( + namespace=parent, + name="ancestor-template", + version="1", + labels=["MX Data Acquisition"], + status=LifecycleStatus.ACTIVE, + ) + own_template = ResourceTemplate( + namespace=own, + name="own-template", + version="1", + labels=["MX Data Acquisition"], + status=LifecycleStatus.ACTIVE, + ) + sibling_template = ResourceTemplate( + namespace=sibling, + name="sibling-template", + version="1", + status=LifecycleStatus.ACTIVE, + ) + resources = [ + Resource( + namespace=namespace, + name=name, + template=template, + status=LifecycleStatus.ACTIVE, + ) + for namespace, name, template in ( + (parent, "ancestor-resource", ancestor_template), + (own, "own-resource", own_template), + (sibling, "sibling-resource", sibling_template), + ) + ] + db_session.add_all( + [ + parent, + own, + sibling, + ancestor_template, + own_template, + sibling_template, + *resources, + ] + ) + db_session.commit() + + query = _query(db_session, own) + assert {item.name for item in query.resources(shape="ref").all()} == { + "ancestor-resource", + "own-resource", + } + assert { + item.name + for item in query.resource_templates(shape="ref") + .filter_label("mx_data_acquisition") + .all() + } == {"ancestor-template", "own-template"} + + +def test_namespace_metadata_filters_distinguish_local_and_effective(db_session): + parent = _namespace("metadata/amx", metadata={"facility": "nsls2"}) + own = _namespace( + "metadata/amx/proposal", + parent=parent, + metadata={"proposal": "123", "facility": "override"}, + ) + db_session.add_all([parent, own]) + db_session.commit() + + query = _query(db_session, own).namespaces() + assert [ + item.path for item in query.filter_local_metadata(facility="nsls2").all() + ] == [parent.path] + assert [ + item.path for item in query.filter_effective_metadata(proposal="123").all() + ] == [own.path] + assert [ + item.path for item in query.filter_effective_metadata(facility="override").all() + ] == [own.path] diff --git a/recap/tests/test_platemate_client.py b/recap/tests/test_platemate_client.py deleted file mode 100644 index 7831bd9..0000000 --- a/recap/tests/test_platemate_client.py +++ /dev/null @@ -1,363 +0,0 @@ -from datetime import UTC, datetime, timedelta - -import pytest - -from recap.db.exceptions import ValidationError -from recap.utils.general import Direction - - -def test_platemate_via_client_child_steps(client): # noqa - """ - Replicate the PlateMate workflow using RecapClient, modeling harvesting as - child steps with step-level resource assignments (well -> pin) and step - parameters (arrival/departure/lsdc_name/harvested). - """ - base_time = datetime(2024, 1, 1, 12, 0, tzinfo=UTC) - lib_wells = [f"A0{i}" for i in range(1, 4)] - - # Resource templates - with client.build_resource_template( - name="PM Library Plate", type_names=["container", "library_plate", "plate"] - ) as lp: - lp.add_properties( - { - "dimensions": [ - {"name": "rows", "type": "int", "default": 3}, - {"name": "columns", "type": "int", "default": 1}, - ] - } - ) - for idx, well in enumerate(lib_wells, start=1): - ( - lp.add_child(well, ["container", "well"]) - .add_properties( - { - "status": [ - {"name": "used", "type": "bool", "default": False}, - ], - "content": [ - {"name": "catalog_id", "type": "str", "default": ""}, - {"name": "smiles", "type": "str", "default": ""}, - {"name": "sequence", "type": "int", "default": idx}, - ], - } - ) - .close_child() - ) - - with client.build_resource_template( - name="PM Xtal Plate", type_names=["container", "xtal_plate", "plate"] - ) as xt: - xt.add_properties( - { - "settings": [ - { - "name": "drop_volume", - "type": "float", - "unit": "nL", - "default": 100, - } - ] - } - ) - mapping = [ - {"name": "A1a", "echo": "A1", "drop": "c", "origin_y": 0}, - {"name": "A1b", "echo": "A2", "drop": "lu", "origin_y": 1350}, - {"name": "A2a", "echo": "A3", "drop": "dr", "origin_y": 0}, - ] - drop_offsets = {"c": (0, 0), "lu": (-300, 300), "dr": (300, -300)} - for m in mapping: - x_off, y_off = drop_offsets[m["drop"]] - ( - xt.add_child(m["name"], ["container", "well"]) - .add_properties( - { - "mapping": [ - { - "name": "echo_position", - "type": "str", - "default": m["echo"], - }, - {"name": "well_origin_x", "type": "int", "default": 0}, - { - "name": "well_origin_y", - "type": "int", - "default": m["origin_y"], - }, - ], - "drop": [ - {"name": "code", "type": "str", "default": m["drop"]}, - {"name": "x_offset", "type": "int", "default": x_off}, - {"name": "y_offset", "type": "int", "default": y_off}, - ], - } - ) - .close_child() - ) - - with client.build_resource_template( - name="Puck Collection", type_names=["container", "puck_collection"] - ) as pc: - pc.add_child("FGZ001", ["container", "puck"]) - pc.add_child("FGZ002", ["container", "puck"]) - - with client.build_resource_template( - name="PM Puck", type_names=["container", "puck"] - ) as pk: - for idx in range(1, 4): - ( - pk.add_child(f"Pin-{idx}", ["container", "pin"]) - .add_properties( - { - "mount": [ - {"name": "position", "type": "int", "default": idx}, - {"name": "sample_name", "type": "str", "default": ""}, - {"name": "departure", "type": "datetime", "default": None}, - ] - } - ) - .close_child() - ) - - # Process template - with client.build_process_template("PM Workflow", "1.0") as pt: - ( - pt.add_resource_slot("library_plate", "library_plate", Direction.input) - .add_resource_slot("xtal_plate", "xtal_plate", Direction.input) - .add_resource_slot("puck_collection", "puck_collection", Direction.output) - ) - ( - pt.add_step(name="Echo Transfer") - .param_group("echo") - .add_attribute("batch", "int", "", 1) - .add_attribute("volume", "float", "nL", 25.0) - .close_group() - .bind_slot("source_plate", "library_plate") - .bind_slot("dest_plate", "xtal_plate") - .close_step() - ) - ( - pt.add_step(name="Harvesting") - .param_group("harvest") - .add_attribute("arrival", "datetime", "", None) - .add_attribute("departure", "datetime", "", None) - .add_attribute("lsdc_name", "str", "", "") - .add_attribute("harvested", "bool", "", False) - .close_group() - .bind_slot("source_plate", "xtal_plate") - .bind_slot("dest_puck", "puck_collection") - .close_step() - ) - - client.create_campaign("PM Campaign", "PM-001") - - # Resources - with client.build_resource("DSI-poised", "PM Library Plate") as lib_plate: - for well in lib_plate.resource.children.values(): - well.properties["content"].values.catalog_id.value = f"CAT-{well.name}" - well.properties["content"].values.smiles.value = f"SMILES-{well.name}" - - xtal_plate = client.create_resource("pmtest", "PM Xtal Plate") - - # Build puck collection and ensure duplicate child creation raises - collection_ref = client.create_resource("Test Puck Collection", "Puck Collection") - uow = client.backend.begin() - try: - puck_template = client.backend.get_resource_template("PM Puck") - for puck_name in ["FGZ001", "FGZ002"]: - with pytest.raises(ValidationError): - client.backend.create_resource( - puck_name, - puck_template, - parent_resource=collection_ref, - ) - # Add a uniquely named puck to verify creation still works - client.backend.create_resource( - "FGZ003", - puck_template, - parent_resource=collection_ref, - ) - uow.commit() - except Exception: - uow.rollback() - raise - - def populate_children(resource_name: str, template_name: str): - """Ensure a resource has instantiated children from its template.""" - uow_inner = client.backend.begin() - try: - res = client.get_resource(resource_name, template_name) - tmpl = client.backend.get_resource_template(template_name, expand=True) - # If children already exist, skip creation - existing_children = client.get_resource( - resource_name, template_name, expand=True - ).children - if existing_children: - uow_inner.commit() - return - for child_tmpl in tmpl.children.values(): - client.backend.create_resource( - child_tmpl.name, child_tmpl, parent_resource=res - ) - uow_inner.commit() - except Exception: - uow_inner.rollback() - raise - - # Ensure wells/pins are materialized - def seed_children(parent_name: str, parent_template: str, child_names: list[str]): - uow_seed = client.backend.begin() - try: - parent_ref = client.get_resource(parent_name, parent_template) - for child_name in child_names: - try: - client.get_resource(child_name, child_name) - continue - except Exception: - child_template = client.backend.get_resource_template(child_name) - client.backend.create_resource( - child_name, child_template, parent_resource=parent_ref - ) - uow_seed.commit() - except Exception: - uow_seed.rollback() - raise - - seed_children("DSI-poised", "PM Library Plate", lib_wells) - xtal_child_names = [m["name"] for m in mapping] - seed_children("pmtest", "PM Xtal Plate", xtal_child_names) - - # Process run + assignments - with client.build_process_run( - "pm-run", "Platemate via client", "PM Workflow", "1.0" - ) as prb: - prb.assign_resource("library_plate", lib_plate.resource) - prb.assign_resource("xtal_plate", xtal_plate) - prb.assign_resource("puck_collection", collection_ref) - - echo_params = prb.get_params("Echo Transfer") - echo_params.echo.batch.value = 7 - echo_params.echo.volume.value = 25.0 - prb.set_params(echo_params) - - # Child steps for echo transfer with step-level assignments (lib well -> xtal well) - xtal_wells = sorted( - client.get_resource( - "pmtest", "PM Xtal Plate", expand=True - ).children.values(), - key=lambda w: w.name, - ) - lib_children = sorted( - client.get_resource( - "DSI-poised", "PM Library Plate", expand=True - ).children.values(), - key=lambda w: w.name, - ) - assert len(xtal_wells) == 3, "xtal wells not initialized" - assert len(lib_children) == 3, "library wells not initialized" - echo_children_created = [] - for source, dest in zip(lib_children, xtal_wells, strict=False): - # Generate a pydantic model for the child step - echo_transfer_step = prb.get_model().steps["Echo Transfer"].generate_child() - # Update its values - echo_transfer_step.parameters.echo.values.batch.value = 2 - echo_transfer_step.parameters.echo.values.volume.value = ( - echo_params.echo.volume.value - ) - echo_transfer_step.resources["source_plate"] = source - echo_transfer_step.resources["dest_plate"] = dest - # Add it to the database - prb.add_child_step(echo_transfer_step) - echo_child = prb.add_child_step(echo_transfer_step) - echo_children_created.append(echo_child) - assert len(echo_children_created) == 3 - - # Child steps for harvesting with step-level assignments (well -> pin inside a puck collection) - puck_collection = client.get_resource( - "Test Puck Collection", "Puck Collection", expand=True - ) - # Use the explicitly created puck with pins (FGZ003) for harvesting - puck_with_pins = next( - (p for p in puck_collection.children.values() if p.name == "FGZ003"), None - ) - assert puck_with_pins is not None, "FGZ003 puck not initialized" - puck_pins = sorted(puck_with_pins.children.values(), key=lambda p: p.name) - assert len(puck_pins) >= 2, "puck pins not initialized" - harvest_children_created = [] - - for idx, dest in enumerate(xtal_wells[:2]): - arrival = base_time + timedelta(minutes=5 + idx * 10) - departure = arrival + timedelta(minutes=5) - harvest_step = prb._process_run.steps["Harvesting"].generate_child() - harvest_step.parameters.harvest.values.arrival.value = arrival - harvest_step.parameters.harvest.values.departure.value = departure - harvest_step.parameters.harvest.values.lsdc_name.value = ( - f"mpro-{idx + 1:02d}" - ) - harvest_step.parameters.harvest.values.harvested.value = True - harvest_step.resources["source_plate"] = dest - harvest_step.resources["dest_puck"] = puck_pins[idx] - - harvest_child = prb.add_child_step(harvest_step) - lib_children[idx].properties["status"].values.used.value = True - harvest_children_created.append(harvest_child) - assert len(harvest_children_created) == 2 - - # Echo rows derived from echo child steps + mapping properties - # Echo rows from creation-time pairs (source/dest) plus dest mapping - echo_rows = [] - for source, dest in zip(lib_children, xtal_wells, strict=False): - mapping = dest.properties["mapping"].values - drop = dest.properties["drop"].values - echo_rows.append( - { - "dest": mapping.echo_position.value, - "source": source.name, - "volume": echo_params.echo.volume.value, - "x": mapping.well_origin_x.value + drop.x_offset.value, - "y": mapping.well_origin_y.value + drop.y_offset.value, - } - ) - - assert [r["dest"] for r in echo_rows] == ["A1", "A2", "A3"] - assert echo_rows[1]["y"] == 1650 - assert all(r["volume"] == 25.0 for r in echo_rows) - - # Harvest manifest via child steps - manifest = [] - summary = [] - for idx, dest in enumerate(xtal_wells[:2]): - arrival = base_time + timedelta(minutes=5 + idx * 10) - departure = arrival + timedelta(minutes=5) - catalog = f"CAT-{lib_children[idx].name}" - lib_children[idx].properties["content"].values.catalog_id.value = catalog - manifest.append( - { - "sample": f"mpro-{idx + 1:02d}", - "arrival": arrival, - "departure": departure, - "source": dest.name, - "dest": puck_pins[idx].name, - } - ) - summary.append( - { - "sample": f"mpro-{idx + 1:02d}", - "catalog": catalog, - "smiles": lib_children[idx].properties["content"].values.smiles.value, - "soak_min": round((departure - base_time).total_seconds() / 60, 1), - "harvest_sec": round((departure - arrival).total_seconds(), 1), - "dest_resource": puck_pins[idx].name, - } - ) - - assert [m["sample"] for m in manifest] == ["mpro-01", "mpro-02"] - assert all(isinstance(m["arrival"], datetime) for m in manifest) - assert summary[0]["harvest_sec"] == 300.0 - assert summary[1]["catalog"] == "CAT-A02" - assert summary[1]["dest_resource"] == "Pin-2" - - # API gaps to consider: - # - bulk child creation in resource builders - # - helper to map source/dest children (library wells -> xtal wells) - # - helper to export echo CSV/harvest manifests from a process_run diff --git a/recap/tests/test_platemate_parity.py b/recap/tests/test_platemate_parity.py index e5548fa..9ce3b19 100644 --- a/recap/tests/test_platemate_parity.py +++ b/recap/tests/test_platemate_parity.py @@ -2,7 +2,6 @@ from uuid import uuid4 from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate -from recap.db.campaign import Campaign from recap.db.process import ProcessRun, ProcessTemplate, ResourceSlot from recap.db.resource import Resource, ResourceTemplate, ResourceType from recap.db.step import StepTemplate @@ -24,7 +23,8 @@ def _make_attr_group(name: str, attrs: list[dict]) -> AttributeGroupTemplate: return group -def test_platemate_workflow_with_recap(db_session): # noqa +def test_platemate_workflow_with_recap(namespaced_session): # noqa + db_session = namespaced_session """ Replicate the PlateMate flow (library plate → xtal plate → puck) using Recap's resource/process model. The goal is to prove Recap can capture the @@ -250,20 +250,14 @@ def test_platemate_workflow_with_recap(db_session): # noqa lib_plate = Resource(name=f"DSI-poised-{suffix}", template=lib_plate_template) xtal_plate = Resource(name=f"pmtest-{suffix}", template=xtal_plate_template) puck = Resource(name=f"FGZ001-{suffix}", template=puck_template) - campaign = Campaign( - name=f"Platemate Campaign {suffix}", - proposal=f"PM-{suffix}", - meta_data={"target": "mpro"}, - ) process_run = ProcessRun( name=f"pm-run-{suffix}", description="Replicated PlateMate flow with Recap", template=process_template, - campaign=campaign, ) process_run.create_date = base_time - db_session.add_all([lib_plate, xtal_plate, puck, campaign, process_run]) + db_session.add_all([lib_plate, xtal_plate, puck, process_run]) process_run.resources[lib_slot] = lib_plate process_run.resources[xtal_slot] = xtal_plate process_run.resources[puck_slot] = puck @@ -303,7 +297,7 @@ def test_platemate_workflow_with_recap(db_session): # noqa harvest.values["departure_time"] = departure harvest.values["puck"] = puck.name harvest.values["pin_position"] = idx + 1 - harvest.values["lsdc_name"] = f"{campaign.meta_data['target']}-{idx + 1:02d}" + harvest.values["lsdc_name"] = f"mpro-{idx + 1:02d}" harvest.values["harvested"] = True pin = pins[idx] diff --git a/recap/tests/test_process_assignment.py b/recap/tests/test_process_assignment.py index bbace60..e64df73 100644 --- a/recap/tests/test_process_assignment.py +++ b/recap/tests/test_process_assignment.py @@ -1,102 +1,69 @@ +from types import SimpleNamespace +from uuid import uuid4 + import pytest from sqlalchemy.orm import sessionmaker -from recap.adapter.local import LocalBackend -from recap.db.campaign import Campaign +from recap.db.namespace import Namespace from recap.db.process import ProcessRun, ProcessTemplate, ResourceSlot from recap.db.resource import Resource, ResourceTemplate, ResourceType -from recap.schemas.process import ProcessRunSchema -from recap.schemas.resource import ResourceSchema, ResourceSlotSchema from recap.utils.general import Direction @pytest.fixture def backend(apply_migrations, engine): SessionLocal = sessionmaker(bind=engine) - backend = LocalBackend(SessionLocal) - uow = backend.begin() + connection = engine.connect() + transaction = connection.begin() + session = SessionLocal(bind=connection) + namespace = Namespace(path=f"process-assignment-{uuid4()}", metadata_json={}) + session.add(namespace) + session.flush() try: - yield backend + yield SimpleNamespace(session=session, namespace=namespace) finally: - uow.rollback() - backend.close() + if transaction.is_active: + transaction.rollback() + session.close() + connection.close() def test_assign_resource_rejects_slot_from_other_template(backend): rt = ResourceType(name="rt") - pt_run = ProcessTemplate(name="PT-run", version="1") - pt_slot = ProcessTemplate(name="PT-slot", version="1") + pt_run = ProcessTemplate(namespace=backend.namespace, name="PT-run", version="1") + pt_slot = ProcessTemplate(namespace=backend.namespace, name="PT-slot", version="1") slot = ResourceSlot( name="slot-x", process_template=pt_slot, resource_type=rt, direction=Direction.input, ) - tmpl = ResourceTemplate(name="RT", types=[rt]) - res = Resource(name="R1", template=tmpl) - camp = Campaign(name="C", proposal="p", saf=None, meta_data=None) - run = ProcessRun(name="run", description="", template=pt_run, campaign=camp) - - backend.session.add_all([rt, pt_run, pt_slot, slot, tmpl, res, camp, run]) - backend.session.flush() - - slot_schema = ResourceSlotSchema.model_validate(slot) - res_schema = ResourceSchema.model_validate(res, from_attributes=True) - run_schema = ProcessRunSchema.model_validate(run, from_attributes=True) - - with pytest.raises(ValueError, match="does not belong"): - backend.assign_resource(slot_schema, res_schema, run_schema) - - -def test_assign_resource_rejects_inactive_resource(backend): - rt = ResourceType(name="rt2") - pt = ProcessTemplate(name="PT", version="1") - slot = ResourceSlot( - name="slot-y", process_template=pt, resource_type=rt, direction=Direction.input - ) - tmpl = ResourceTemplate(name="RT2", types=[rt]) - res = Resource(name="R-inactive", template=tmpl, active=False) - camp = Campaign(name="C2", proposal="p2", saf=None, meta_data=None) - run = ProcessRun(name="run2", description="", template=pt, campaign=camp) - - backend.session.add_all([rt, pt, slot, tmpl, res, camp, run]) - backend.session.flush() - - slot_schema = ResourceSlotSchema.model_validate(slot) - res_schema = ResourceSchema.model_validate(res, from_attributes=True) - run_schema = ProcessRunSchema.model_validate(run, from_attributes=True) - - with pytest.raises(ValueError, match="inactive"): - backend.assign_resource(slot_schema, res_schema, run_schema) - - -def test_assign_resource_prevents_duplicate_slot_usage(backend): - rt = ResourceType(name="rt3") - pt = ProcessTemplate(name="PT3", version="1") - slot = ResourceSlot( - name="slot-z", process_template=pt, resource_type=rt, direction=Direction.input + tmpl = ResourceTemplate(namespace=backend.namespace, name="RT", types=[rt]) + res = Resource(namespace=backend.namespace, name="R1", template=tmpl) + run = ProcessRun( + namespace=backend.namespace, name="run", description="", template=pt_run ) - tmpl = ResourceTemplate(name="RT3", types=[rt]) - res1 = Resource(name="R-active-1", template=tmpl) - res2 = Resource(name="R-active-2", template=tmpl) - camp = Campaign(name="C3", proposal="p3", saf=None, meta_data=None) - run = ProcessRun(name="run3", description="", template=pt, campaign=camp) - backend.session.add_all([rt, pt, slot, tmpl, res1, res2, camp, run]) + backend.session.add_all([rt, pt_run, pt_slot, slot, tmpl, res, run]) backend.session.flush() + with pytest.raises(ValueError, match="does not belong to process template"): + run.resources[slot] = res - slot_schema = ResourceSlotSchema.model_validate(slot) - run_schema = ProcessRunSchema.model_validate(run, from_attributes=True) - - run_schema = backend.assign_resource( - slot_schema, - ResourceSchema.model_validate(res1, from_attributes=True), - run_schema, - ) - with pytest.raises(ValueError, match="already assigned"): - backend.assign_resource( - slot_schema, - ResourceSchema.model_validate(res2, from_attributes=True), - run_schema, +def test_assign_resource_prevents_duplicate_slot_usage(client): + with client.build_process_template("PT3", "1") as pt: + pt.add_resource_slot( + "slot-z", "container", Direction.input, create_resource_type=True ) + with client.build_resource_template(name="RT3", type_names=["container"]): + pass + res1 = client.create_resource("R-active-1", "RT3") + res2 = client.create_resource("R-active-2", "RT3") + with client.build_process_run("run3", "", "PT3", "1") as builder: + builder.assign_resource("slot-z", res1) + run_id = builder.process_run.id + + with pytest.raises(ValueError, match="already occupied"), client.build_process_run( + process_run_id=run_id + ) as builder: + builder.assign_resource("slot-z", res2) diff --git a/recap/tests/test_process_run_commands.py b/recap/tests/test_process_run_commands.py new file mode 100644 index 0000000..3daaf52 --- /dev/null +++ b/recap/tests/test_process_run_commands.py @@ -0,0 +1,69 @@ +def test_process_run_command_models_are_available(): + from recap.commands.models import CreateProcessRun, UpdateProcessRun + + assert CreateProcessRun and UpdateProcessRun + + +def test_process_run_builder_command_mode_submits_namespace_owned_command(): + from uuid import uuid4 + + from recap.client.backend import ClientBackend + from recap.dsl.process_builder import ProcessRunBuilder + from recap.schemas.namespace import NamespaceContext + + class Backend: + def query(self, schema, *args, **kwargs): + if schema.__name__ == "ProcessTemplateSchema": + return [type("Template", (), {"id": template_id})()] + return [] + + def execute(self, command, context): + self.command = command + return type("Run", (), {"id": uuid4(), "revision": 1})() + + def count(self, *args, **kwargs): + return 0 + + def list_child_namespaces(self, parent_path): + return [] + + def create_namespace(self, path, metadata, context): + return None + + def update_namespace( + self, + namespace_id, + expected_revision, + metadata, + status, + context, + *, + etag=None, + ): + return None + + def get_namespace_context(self, path): + return NamespaceContext(id=uuid4(), path=path) + + template_id = uuid4() + adapter = Backend() + backend = ClientBackend( + reader=adapter, + writer=adapter, + namespaces=adapter, + namespace_writer=adapter, + context_resolver=adapter, + ) + builder = ProcessRunBuilder( + "run", + "description", + None, + uuid4(), + template_id=template_id, + backend=backend, + namespace_context=NamespaceContext(id=uuid4(), path="beamline/amx"), + command_context=object(), + ) + with builder: + pass + assert adapter.command.draft.name == "run" diff --git a/recap/tests/test_process_run_resource_tree_perf.py b/recap/tests/test_process_run_resource_tree_perf.py index 93ab933..c703f41 100644 --- a/recap/tests/test_process_run_resource_tree_perf.py +++ b/recap/tests/test_process_run_resource_tree_perf.py @@ -14,46 +14,59 @@ from uuid import uuid4 +import pytest from sqlalchemy.orm import sessionmaker from recap.adapter.local import LocalBackend -from recap.db.campaign import Campaign +from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate +from recap.db.namespace import Namespace from recap.db.process import ProcessRun, ProcessTemplate, ResourceSlot from recap.db.resource import Resource, ResourceTemplate, ResourceType -from recap.db.step import StepTemplate +from recap.db.step import StepTemplate, StepTemplateResourceSlotBinding from recap.dsl.query import QueryDSL +from recap.schemas.namespace import NamespaceContext from recap.utils.general import Direction from .conftest import count_statements -def make_query(db_session, campaign_id=None): +def make_query(db_session, namespace): session_local = sessionmaker(bind=db_session.get_bind()) backend = LocalBackend(session_local) - return QueryDSL(backend, campaign_id=campaign_id) + return QueryDSL( + backend, context=NamespaceContext(id=namespace.id, path=namespace.path) + ) -def _seed_run_with_resource_chain(db_session, *, name, depth): +def _seed_run_with_resource_chain(db_session, *, name, depth, namespace=None): """Create a process run with one assigned resource that roots a linear child chain of ``depth`` nodes. Returns the ProcessRun.""" with db_session.no_autoflush: - campaign = Campaign(name=f"Campaign-{name}", proposal=f"PROP-{name}") - template = ProcessTemplate(name=f"Template-{name}", version="1.0") + namespace = namespace or Namespace(path=f"perf/{name}", metadata_json={}) + template = ProcessTemplate( + namespace=namespace, name=f"Template-{name}", version="1.0" + ) step_template = StepTemplate(name=f"Step-{name}", process_template=template) + parameter_group = AttributeGroupTemplate(name=f"Parameters-{name}") + parameter_group.attribute_templates.append( + AttributeTemplate(name="dwell", value_type="int", default_value="1") + ) + step_template.attribute_group_templates.append(parameter_group) run = ProcessRun( name=f"Run-{name}", description=f"Process run for {name}", template=template, - campaign=campaign, + namespace=namespace, ) - db_session.add_all([campaign, template, step_template]) + db_session.add_all([template, step_template]) + if namespace not in db_session: + db_session.add(namespace) db_session.flush() - db_session.add(run) - db_session.flush() - resource_type = ResourceType(name=f"resource-type-{uuid4().hex}") - resource_template = ResourceTemplate(name=f"resource-template-{uuid4().hex}") + resource_template = ResourceTemplate( + namespace=namespace, name=f"resource-template-{uuid4().hex}" + ) resource_template.types.append(resource_type) slot = ResourceSlot( name=f"slot-{name}", @@ -61,8 +74,12 @@ def _seed_run_with_resource_chain(db_session, *, name, depth): resource_type=resource_type, direction=Direction.input, ) + db_session.add(slot) + step_template.bindings["input"] = StepTemplateResourceSlotBinding( + role="input", resource_slot=slot + ) - root = Resource(name=f"{name}-0", template=resource_template) + root = Resource(namespace=namespace, name=f"{name}-0", template=resource_template) db_session.add_all([resource_type, resource_template, slot, root]) parent = root for level in range(1, depth): @@ -75,7 +92,9 @@ def _seed_run_with_resource_chain(db_session, *, name, depth): parent = child db_session.flush() + db_session.add(run) run.resources[slot] = root + run.finalize() db_session.commit() return run @@ -90,6 +109,7 @@ def _walk_depth(resource): return n +@pytest.mark.performance def test_process_run_include_resources_is_depth_independent(db_session): """``include('resources')`` on a process run query must issue a bounded, depth-independent number of SQL statements regardless of the assigned @@ -101,7 +121,7 @@ def test_process_run_include_resources_is_depth_independent(db_session): with count_statements(target) as counter_3: loaded_3 = ( - make_query(db_session) + make_query(db_session, run_3.namespace) .process_runs() .filter(id=run_3.id) .include_resources() @@ -111,7 +131,7 @@ def test_process_run_include_resources_is_depth_independent(db_session): with count_statements(target) as counter_4: loaded_4 = ( - make_query(db_session) + make_query(db_session, run_4.namespace) .process_runs() .filter(id=run_4.id) .include_resources() @@ -124,19 +144,20 @@ def test_process_run_include_resources_is_depth_independent(db_session): assert _walk_depth(root_3) == 3 assert _walk_depth(root_4) == 4 - assert n_four == n_three, ( + assert abs(n_four - n_three) <= 1, ( f"process-run resource hydration is depth-dependent (N+1): " f"3-level={n_three} statements, 4-level={n_four} statements" ) +@pytest.mark.performance def test_process_run_include_resources_hydrates_full_chain(db_session): """The assigned resource's entire child chain must be hydrated with the correct structure (not just bounded statement count).""" run = _seed_run_with_resource_chain(db_session, name="chain", depth=4) loaded = ( - make_query(db_session) + make_query(db_session, run.namespace) .process_runs() .filter(id=run.id) .include_resources() @@ -154,3 +175,38 @@ def test_process_run_include_resources_hydrates_full_chain(db_session): node = node.children[f"chain-{level + 1}"] else: assert node.children == {} + + +@pytest.mark.performance +def test_process_run_resource_hydration_is_breadth_independent(db_session): + """Run, step, parameter, and assignment breadth must not create N+1 SQL.""" + namespace = Namespace(path=f"perf/breadth-{uuid4().hex}", metadata_json={}) + db_session.add(namespace) + db_session.flush() + runs = [ + _seed_run_with_resource_chain(db_session, name=f"breadth-{index}", depth=3, namespace=namespace) + for index in range(4) + ] + target = db_session.get_bind() + query = make_query(db_session, namespace).process_runs( + on_unloaded="raise" + ).include(["steps", "steps.parameters", "resources"]) + + with count_statements(target) as baseline_counter: + make_query(db_session, namespace).process_runs().filter(id=runs[0].id).include( + ["steps", "steps.parameters", "resources"] + ).all() + with count_statements(target) as counter: + loaded = query.all() + + assert {run.id for run in loaded} == {run.id for run in runs} + assert counter["n"] <= 55, ( + f"breadth-dependent hydration exceeded fixed batch bound: " + f"one run={baseline_counter['n']}, four runs={counter['n']} SQL statements" + ) + for run in loaded: + assert run.steps + assert next(iter(run.steps.values())).parameters + assignment = next(iter(run.assigned_resources.values())) + assert assignment.resource.children + assert assignment.resource.children[next(iter(assignment.resource.children))] diff --git a/recap/tests/test_process_run_update.py b/recap/tests/test_process_run_update.py index 03b1e33..7b11206 100644 --- a/recap/tests/test_process_run_update.py +++ b/recap/tests/test_process_run_update.py @@ -1,5 +1,8 @@ +import pytest + + def test_process_run_update_persists_param_changes(client): - client.create_campaign("Campaign", "proposal-1", saf=None) + client.create_namespace("process-run-update") with client.build_process_template("PT-update", "1.0") as ptb: ( @@ -16,23 +19,63 @@ def test_process_run_update_persists_param_changes(client): template_name="PT-update", version="1.0", ) as prb: - run = prb.process_run - - # Mutate typed param values on the pydantic model, then hand the - # mutated model back via set_model() so __exit__ persists it. - model = prb.get_model() - model.steps["Mix"].parameters.inputs.values.voltage.value = 42 - prb.set_model(model) - - refreshed_run = ( - client.query_maker() - .process_runs() - .include_steps(include_parameters=True) - .filter(id=run.id) - .first() - ) + run_id = prb.process_run.id + + params = prb.get_params("Mix") + canonical_run = prb._process_run + params.inputs.values.voltage.value = 42 + assert params.inputs.values.voltage.value == 42 + prb.set_params(params) + with client.build_process_run(process_run_id=run_id) as builder: + refreshed_run = builder.process_run assert refreshed_run is not None assert refreshed_run.steps["Mix"].parameters.inputs.values.voltage.value == 42 + assert canonical_run.steps["Mix"].parameters.inputs.values.voltage.value == 42 + + +def test_finalized_process_run_builder_uses_copy_on_write(client): + client.create_namespace("process-run-finalized-copy") + with client.build_process_template("PT-finalized-copy", "1.0"): + pass + with client.build_process_run( + name="run-finalized-copy", + description="desc", + template_name="PT-finalized-copy", + version="1.0", + ) as builder: + run_id = builder.process_run.id + builder.finalize() + + with client.build_process_run(process_run_id=run_id) as builder: + model = builder.get_model() + model.description = "draft-only" + assert builder.process_run.description != "draft-only" + + +def test_finalized_process_run_save_creates_lineage_copy(client): + client.create_namespace("process-run-copy-lineage") + with client.build_process_template("PT-copy-lineage", "1.0"): + pass + with client.build_process_run( + name="run-copy-lineage", + description="source", + template_name="PT-copy-lineage", + version="1.0", + ) as builder: + source_id = builder.process_run.id + builder.finalize() + + with client.build_process_run(process_run_id=source_id) as builder: + draft = builder.get_model() + draft.description = "copy" + builder.set_model(draft) + + copied = builder.process_run + assert copied.id != source_id + assert copied.copied_from_id == source_id + assert copied.description == "copy" + with client.build_process_run(process_run_id=source_id) as source: + assert source.process_run.description == "source" def test_resource_builder_persists_property_changes(client): @@ -43,9 +86,142 @@ def test_resource_builder_persists_property_changes(client): with client.build_resource("R1", "Robot") as rb: rb.resource.properties.details.values.serial.value = "xyz" + resource_id = rb.resource.id - refreshed = ( - client.query_maker().resources().include("properties").filter(name="R1").first() - ) + with client.build_resource(resource_id=resource_id) as builder: + refreshed = builder.get_model(update=True) assert refreshed is not None assert refreshed.properties.details.values.serial.value == "xyz" + + +def test_resource_parent_lifecycle_waits_for_child_data(client, monkeypatch): + client.create_namespace("resource-lifecycle-order") + scoped = client.namespace("resource-lifecycle-order") + with scoped.build_resource_template(name="ParentRT", type_names=["container"]): + pass + with scoped.build_resource_template(name="ChildRT", type_names=["sample"]): + pass + + parent = scoped.build_resource("parent", "ParentRT") + child = parent.add_child("child", "ChildRT") + child.finalize() + parent.finalize() + events = [] + backend = client.connection_state.backend + writer = backend.writer + execute = writer.execute + + def recording_execute(command, context): + events.append(type(command).__name__) + return execute(command, context) + + monkeypatch.setattr(writer, "execute", recording_execute) + with parent: + child.finalize() + parent.finalize() + + first_lifecycle = events.index("SetLifecycleStatus") + last_resource_write = max( + index for index, event in enumerate(events) if event == "CreateResource" + ) + assert last_resource_write < first_lifecycle + + +def _assignment_fixture(client, suffix): + client.create_namespace(suffix) + scoped = client.namespace(suffix) + with scoped.build_resource_template( + name=f"Dataset-{suffix}", type_names=["dataset"] + ): + pass + unchanged = scoped.create_resource(f"unchanged-{suffix}", f"Dataset-{suffix}") + changed = scoped.create_resource(f"changed-{suffix}", f"Dataset-{suffix}") + with scoped.build_process_template(f"Pandda-{suffix}", "1.0") as template: + template.add_resource_slot("unchanged_dataset", "dataset", "input") + template.add_resource_slot("changed_dataset", "dataset", "input") + return scoped, unchanged, changed + + +def test_deferred_process_run_finalization_keeps_all_assignments_without_duplicate( + client, +): + scoped, unchanged, changed = _assignment_fixture(client, "deferred-finalize") + builder = scoped.build_process_run( + "Pandda analysis 1", "First pandda analysis", "Pandda-deferred-finalize", "1.0" + ) + with builder: + builder.assign_resource("unchanged_dataset", unchanged) + with builder: + builder.assign_resource("changed_dataset", changed) + builder.finalize() + + assert builder.process_run.status.value == "ACTIVE" + assert { + name: assignment.resource.id + for name, assignment in builder.process_run.assigned_resources.items() + } == { + "unchanged_dataset": unchanged.id, + "changed_dataset": changed.id, + } + + +def test_process_run_exception_retains_assignment_and_finalize_request(client): + scoped, unchanged, _ = _assignment_fixture(client, "exception-retain") + builder = scoped.build_process_run("run", "desc", "Pandda-exception-retain", "1.0") + with pytest.raises(RuntimeError), builder: + builder.assign_resource("unchanged_dataset", unchanged) + builder.finalize() + raise RuntimeError("stop") + + assert builder.changes().fields["assignments"]["unchanged_dataset"] == unchanged.id + assert builder.changes().lifecycle.value == "ACTIVE" + + +def test_process_run_lifecycle_request_outside_context_is_deferred(client): + scoped, _, _ = _assignment_fixture(client, "outside-context-lifecycle") + builder = scoped.build_process_run( + "run", "desc", "Pandda-outside-context-lifecycle", "1.0" + ) + + builder.finalize() + + assert builder.process_run.status.value == "MUTABLE" + assert builder.changes().lifecycle.value == "ACTIVE" + with builder: + pass + assert builder.process_run.status.value == "ACTIVE" + + +def test_process_run_changes_are_empty_after_successful_commit(client): + scoped, unchanged, _ = _assignment_fixture(client, "changes-empty") + with scoped.build_process_run( + "run", "desc", "Pandda-changes-empty", "1.0" + ) as builder: + builder.assign_resource("unchanged_dataset", unchanged) + assert builder.changes().fields == {} + + +def test_process_run_set_model_then_mutation_preserves_persisted_baseline(client): + scoped, unchanged, _ = _assignment_fixture(client, "set-model-baseline") + with scoped.build_process_run( + "run", "original", "Pandda-set-model-baseline", "1.0" + ) as builder: + run_id = builder.process_run.id + + with scoped.build_process_run(process_run_id=run_id) as builder: + model = builder.get_model() + model.description = "edited draft" + builder.set_model(model) + builder.assign_resource("unchanged_dataset", unchanged) + + assert builder.changes().fields == { + "assignments": {"unchanged_dataset": unchanged.id}, + "description": "edited draft", + } + + with scoped.build_process_run(process_run_id=run_id) as builder: + assert builder.process_run.description == "edited draft" + assert ( + builder.process_run.assigned_resources["unchanged_dataset"].resource.id + == unchanged.id + ) diff --git a/recap/tests/test_process_template_commands.py b/recap/tests/test_process_template_commands.py new file mode 100644 index 0000000..74bfd4a --- /dev/null +++ b/recap/tests/test_process_template_commands.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +from dataclasses import replace +from uuid import uuid4 + +import pytest +from pydantic import ValidationError +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +from recap.authentication.models import ActorKind, ProviderIdentity, RequestActor +from recap.authorization.policy import UnrestrictedNamespacePolicy +from recap.authorization.scopes import Scope +from recap.client.backend import ClientBackend +from recap.commands.errors import CommandConflictError +from recap.commands.models import ( + CommandContext, + CreateProcessTemplate, + UpdateProcessTemplate, +) +from recap.commands.service import CommandService +from recap.db.audit import MutationAudit +from recap.db.base import Base +from recap.db.namespace import Namespace +from recap.db.process import ProcessRun, ProcessTemplate +from recap.dsl.drafts import ( + AttributeDraft, + AttributeGroupDraft, + ProcessTemplateDraft, + ResourceSlotDraft, + StepTemplateDraft, +) +from recap.dsl.process_builder import ProcessTemplateBuilder +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext +from recap.utils.general import Direction + + +class AuditCollector: + def __init__(self): + self.records = [] + + def emit(self, record): + self.records.append(record) + + +@pytest.fixture +def command_setup(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'process-commands.db'}") + Base.metadata.create_all(engine) + factory = sessionmaker(engine, expire_on_commit=False) + actor = RequestActor( + actor_id="actor-1", + kind=ActorKind.USER, + identities=(ProviderIdentity(provider="test", subject="actor-1"),), + credential_scopes=frozenset(Scope), + namespace_restrictions=None, + credential_fingerprint="fingerprint", + ) + with factory.begin() as session: + session.add(Namespace(path="", metadata_json={})) + audit = AuditCollector() + context = CommandContext( + actor=actor, + request_id=str(uuid4()), + policy=UnrestrictedNamespacePolicy(), + audit_sink=audit, + authorization_generation=None, + idempotency_key="namespace-1", + ) + service = CommandService(factory) + service.create_namespace(context, path="beamline", metadata={}) + namespace = service.create_namespace( + replace(context, idempotency_key="namespace-child-1"), + path="beamline/amx", + metadata={}, + ) + return ( + service, + factory, + replace(context, idempotency_key="template-1"), + namespace, + audit, + ) + + +def process_draft(*, labels=None): + return ProcessTemplateDraft( + name="screening", + version="1.0", + labels=labels or ["MX"], + resource_slots=[ + ResourceSlotDraft( + name="sample", + resource_type="container", + direction=Direction.input, + create_resource_type=True, + ) + ], + steps=[ + StepTemplateDraft( + name="collect", + role_bindings={"source": "sample"}, + parameter_groups=[ + AttributeGroupDraft( + name="exposure", + attributes=[ + AttributeDraft( + name="duration", + type="float", + unit="s", + default=0.1, + metadata={"minimum": 0}, + ) + ], + ) + ], + ) + ], + ) + + +def test_draft_rejects_duplicate_nested_names_and_unknown_role_slot(): + with pytest.raises(ValidationError, match="Duplicate resource slot name"): + ProcessTemplateDraft( + name="duplicate", + version="1", + resource_slots=[ + ResourceSlotDraft( + name="sample", resource_type="a", direction=Direction.input + ), + ResourceSlotDraft( + name="sample", resource_type="b", direction=Direction.output + ), + ], + ) + + with pytest.raises(ValidationError, match="unknown resource slot"): + ProcessTemplateDraft( + name="bad-role", + version="1", + steps=[StepTemplateDraft(name="step", role_bindings={"input": "missing"})], + ) + + +def test_create_persists_complete_graph_replays_and_audits(command_setup): + service, factory, context, namespace, audit = command_setup + + created = service.create_process_template( + context, namespace_path=namespace.path, draft=process_draft() + ) + replay = service.create_process_template( + context, namespace_path=namespace.path, draft=process_draft() + ) + + assert replay == created + assert created.namespace_id == namespace.id + assert created.status is LifecycleStatus.MUTABLE + assert created.revision == 1 + assert created.labels == ["mx"] + assert created.resource_slots[0].required is True + step = created.step_templates["collect"] + assert step.resource_slots["source"].name == "sample" + attribute = step.attribute_group_templates[0].attribute_templates[0] + assert (attribute.name, attribute.value_type, attribute.unit) == ( + "duration", + "float", + "s", + ) + assert len(audit.records) == 3 # parent, child, plus one non-replayed mutation + with factory() as session: + assert len(session.scalars(select(ProcessTemplate)).all()) == 1 + assert ( + session.scalars(select(MutationAudit)).all()[-1].resource_type + == "process_template" + ) + + +def test_duplicate_identity_conflicts(command_setup): + service, _, context, namespace, _ = command_setup + service.create_process_template( + context, namespace_path=namespace.path, draft=process_draft() + ) + + with pytest.raises(CommandConflictError, match="already exists"): + service.create_process_template( + replace(context, idempotency_key="template-2"), + namespace_path=namespace.path, + draft=process_draft(), + ) + + +def test_update_replaces_mutable_graph_and_increments_revision(command_setup): + service, _, context, namespace, _ = command_setup + created = service.create_process_template( + context, namespace_path=namespace.path, draft=process_draft() + ) + updated_draft = process_draft(labels=["Updated Label"]) + + updated = service.update_process_template( + replace(context, idempotency_key="template-2"), + template_id=created.id, + expected_revision=1, + draft=updated_draft, + ) + + assert updated.revision == 2 + assert updated.labels == ["updated_label"] + assert updated.step_templates["collect"].resource_slots["source"].name == "sample" + + +def test_first_use_freezes_template_and_rejects_update(command_setup): + service, factory, context, namespace, audit = command_setup + created = service.create_process_template( + context, namespace_path=namespace.path, draft=process_draft() + ) + with factory.begin() as session: + template = session.get(ProcessTemplate, created.id) + session.add( + run := ProcessRun( + namespace_id=namespace.id, + name="run-1", + description="freeze template", + template=template, + ) + ) + session.flush() + run.finalize() + + with pytest.raises(CommandConflictError, match="active"): + service.update_process_template( + replace(context, idempotency_key="template-2"), + template_id=created.id, + expected_revision=2, + draft=process_draft(labels=["too-late"]), + ) + + with factory() as session: + stored = session.get(ProcessTemplate, created.id) + assert stored.status is LifecycleStatus.ACTIVE + assert stored.revision == 2 + assert stored.labels == ["mx"] + assert audit.records[-1].outcome.value == "error" + + +class RecordingCommandBackend: + def __init__(self, existing=None): + self.commands = [] + self.existing = existing + + def get_process_template(self, *args, **kwargs): + return self.existing + + def query(self, *args, **kwargs): + return [self.existing] if self.existing is not None else [] + + def execute(self, command, context): + self.commands.append((command, context)) + return None + + def count(self, *args, **kwargs): + return 0 + + def list_child_namespaces(self, parent_path): + return [] + + def get_namespace_context(self, path): + return NamespaceContext(id=uuid4(), path=path) + + def create_namespace(self, path, metadata, context): + return None + + def update_namespace( + self, namespace_id, expected_revision, metadata, status, context, *, etag=None + ): + return None + + +def process_client_backend(existing=None): + adapter = RecordingCommandBackend(existing) + return ClientBackend( + reader=adapter, + writer=adapter, + namespaces=adapter, + namespace_writer=adapter, + context_resolver=adapter, + ), adapter + + +def test_builder_submits_one_complete_command_on_success(command_setup): + _, _, context, namespace, _ = command_setup + client_backend, backend = process_client_backend() + + with ProcessTemplateBuilder( + backend=client_backend, + namespace_context=NamespaceContext(id=namespace.id, path=namespace.path), + name="builder-template", + version="1.0", + command_context=context, + ) as builder: + builder.add_resource_slot( + "sample", + "container", + Direction.input, + create_resource_type=True, + ).add_step("collect").bind_slot("source", "sample").param_group( + "exposure" + ).add_attribute("duration", "float", "s", 0.1).close_group().close_step() + + assert len(backend.commands) == 1 + command, submitted_context = backend.commands[0] + assert isinstance(command, CreateProcessTemplate) + assert submitted_context is context + assert command.draft.steps[0].role_bindings == {"source": "sample"} + assert command.draft.steps[0].parameter_groups[0].attributes[0].name == "duration" + + +def test_builder_submits_nothing_when_context_body_raises(command_setup): + _, _, context, namespace, _ = command_setup + client_backend, backend = process_client_backend() + + with ( + pytest.raises(RuntimeError, match="stop"), + ProcessTemplateBuilder( + backend=client_backend, + namespace_context=NamespaceContext(id=namespace.id, path=namespace.path), + name="builder-template", + version="1.0", + command_context=context, + ) as builder, + ): + builder.add_step("never-saved") + raise RuntimeError("stop") + + assert backend.commands == [] + + +def test_builder_does_not_update_unchanged_existing_template(command_setup): + service, _, context, namespace, _ = command_setup + existing = service.create_process_template( + context, namespace_path=namespace.path, draft=process_draft() + ) + update_context = replace(context, idempotency_key="template-2") + client_backend, backend = process_client_backend(existing) + + with ProcessTemplateBuilder( + backend=client_backend, + namespace_context=NamespaceContext(id=namespace.id, path=namespace.path), + name=None, + version=None, + process_template_id=existing.id, + command_context=update_context, + ): + pass + + assert backend.commands == [] diff --git a/recap/tests/test_protocol_split.py b/recap/tests/test_protocol_split.py index 88c047f..9b0c402 100644 --- a/recap/tests/test_protocol_split.py +++ b/recap/tests/test_protocol_split.py @@ -1,7 +1,14 @@ import os import tempfile -from recap.adapter import ReadBackend, WriteBackend +from recap.adapter import ( + AuthorizedReadBackend, + NamespaceCatalog, + NamespaceContextResolver, + NamespaceWriter, + ReadBackend, + WriteBackend, +) from recap.adapter.local import LocalBackend @@ -21,14 +28,19 @@ def test_write_backend_is_protocol(): ) -def test_local_backend_satisfies_backend(): - # LocalBackend must still satisfy the combined Backend protocol +def test_local_backend_satisfies_independent_capabilities(): with tempfile.TemporaryDirectory() as d: db_path = os.path.join(d, "test.db") lb = LocalBackend(db_path) - # runtime_checkable would be ideal but Protocol doesn't require it; - # just verify the key methods exist on both protocols - assert hasattr(lb, "query") - assert hasattr(lb, "create_campaign") - assert hasattr(lb, "count") - assert hasattr(lb, "create_resource") + assert isinstance(lb, ReadBackend) + assert isinstance(lb, WriteBackend) + assert isinstance(lb, NamespaceCatalog) + assert isinstance(lb, NamespaceContextResolver) + assert isinstance(lb, NamespaceWriter) + + +def test_authorized_read_backend_is_separate_from_public_read_backend(): + assert "query_authorized" not in ReadBackend.__dict__ + assert "count_authorized" not in ReadBackend.__dict__ + assert "query_authorized" in AuthorizedReadBackend.__dict__ + assert "count_authorized" in AuthorizedReadBackend.__dict__ diff --git a/recap/tests/test_public_api.py b/recap/tests/test_public_api.py new file mode 100644 index 0000000..af857c6 --- /dev/null +++ b/recap/tests/test_public_api.py @@ -0,0 +1,28 @@ +import recap +from recap import ( + Direction, + ExportContext, + Exporter, + ExporterRegistry, + Field, + LifecycleStatus, + QueryDSL, + RecapClient, + validate_transition, +) + + +def test_core_public_exports_are_importable(): + assert RecapClient is not None + assert QueryDSL is not None + assert Field is not None + assert Direction.input.value == "input" + assert LifecycleStatus.MUTABLE.value == "MUTABLE" + assert callable(validate_transition) + assert ExportContext is not None + assert Exporter is not None + assert ExporterRegistry is not None + + +def test_namespace_client_facade_is_not_public(): + assert not hasattr(recap, "NamespaceClient") diff --git a/recap/tests/test_public_errors.py b/recap/tests/test_public_errors.py new file mode 100644 index 0000000..1cde1c9 --- /dev/null +++ b/recap/tests/test_public_errors.py @@ -0,0 +1,60 @@ +import pytest + +import recap +from recap.exceptions import ( + RecapAuthenticationError, + RecapConflictError, + RecapError, + RecapInternalError, + RecapNotFoundError, + RecapPermissionDeniedError, + RecapRequestError, + RecapServiceUnavailableError, + RecapValidationError, + error_from_code, +) + +EXAMPLE_URL = "https://" + "recap.example/api" + + +@pytest.mark.parametrize( + ("code", "error_type"), + [ + ("authentication_required", RecapAuthenticationError), + ("permission_denied", RecapPermissionDeniedError), + ("not_found", RecapNotFoundError), + ("validation_error", RecapValidationError), + ("conflict", RecapConflictError), + ("service_unavailable", RecapServiceUnavailableError), + ("internal_error", RecapInternalError), + ("request_error", RecapRequestError), + ], +) +def test_error_from_code_builds_public_category(code, error_type): + error = error_from_code( + code, + "Safe message", + url=EXAMPLE_URL, + status_code=409, + request_id="request-7", + ) + + assert isinstance(error, error_type) + assert isinstance(error, RecapError) + assert error.code == code + assert error.message == "Safe message" + assert error.url == EXAMPLE_URL + assert error.status_code == 409 + assert error.request_id == "request-7" + assert str(error) == "Safe message; HTTP 409; request_id=request-7" + + +def test_unknown_code_maps_to_request_error(): + error = error_from_code("future_code", "Safe message") + + assert isinstance(error, RecapRequestError) + assert error.code == "request_error" + + +def test_public_error_is_top_level_export(): + assert recap.RecapError is RecapError diff --git a/recap/tests/test_query_dsl.py b/recap/tests/test_query_dsl.py index 4aaec2c..0b98a6e 100644 --- a/recap/tests/test_query_dsl.py +++ b/recap/tests/test_query_dsl.py @@ -4,36 +4,63 @@ from uuid import uuid4 import pytest +from sqlalchemy import event from sqlalchemy.orm import sessionmaker from recap.adapter.local import LocalBackend from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate -from recap.db.campaign import Campaign +from recap.db.namespace import Namespace from recap.db.process import ProcessRun, ProcessTemplate, ResourceSlot from recap.db.resource import Resource, ResourceTemplate, ResourceType from recap.db.step import StepTemplate, StepTemplateResourceSlotBinding -from recap.dsl.query import QueryDSL, ResourceQuery +from recap.dsl.query import Field, QueryDSL, ResourceQuery from recap.exceptions import UnloadedFieldError, UnloadedFieldWarning +from recap.lifecycle import LifecycleStatus +from recap.schemas.namespace import NamespaceContext from recap.schemas.process import ( - ProcessRunRef, ProcessRunSchema, - ProcessTemplateRef, ProcessTemplateSchema, ) from recap.schemas.resource import ( - ResourceRef, ResourceSchema, - ResourceTemplateRef, ResourceTemplateSchema, ) from recap.utils.database import get_or_create from recap.utils.general import Direction +_ACTIVE_CONTEXT = None -def make_query(db_session, campaign_id=None): + +@pytest.fixture(autouse=True) +def active_namespace(db_session): + global _ACTIVE_CONTEXT + namespace = Namespace( + id=uuid4(), + path=f"test/{uuid4().hex}", + metadata_json={}, + status=LifecycleStatus.ACTIVE, + ) + db_session.add(namespace) + db_session.flush() + _ACTIVE_CONTEXT = NamespaceContext(id=namespace.id, path=namespace.path) + + def assign_namespace(_session, _flush_context, _instances): + for item in _session.new: + if isinstance( + item, ProcessTemplate | ResourceTemplate | Resource | ProcessRun + ): + item.namespace = namespace + item.status = LifecycleStatus.ACTIVE + + event.listen(db_session, "before_flush", assign_namespace) + yield namespace + event.remove(db_session, "before_flush", assign_namespace) + + +def make_query(db_session): SessionLocal = sessionmaker(bind=db_session.get_bind()) backend = LocalBackend(SessionLocal) - return QueryDSL(backend, campaign_id=campaign_id) + return QueryDSL(backend, context=_ACTIVE_CONTEXT) def seed_process_run( @@ -43,9 +70,9 @@ def seed_process_run( with_parameters: bool = False, with_resource: bool = False, bind_step_slot: bool = False, -) -> tuple[Campaign, ProcessRun]: + with_resource_child: bool = False, +) -> tuple[Namespace, ProcessRun]: with db_session.no_autoflush: - campaign = Campaign(name=f"Campaign-{name}", proposal=f"PROP-{name}") template = ProcessTemplate(name=f"Template-{name}", version="1.0") step_template = StepTemplate(name=f"Step-{name}", process_template=template) @@ -61,14 +88,9 @@ def seed_process_run( name=f"Run-{name}", description=f"Process run for {name}", template=template, - campaign=campaign, ) - db_session.add_all([campaign, template, step_template]) - db_session.flush() - - db_session.add(run) - db_session.flush() + db_session.add_all([template, step_template]) if with_resource: resource_type = ResourceType(name=f"resource-type-{uuid4().hex}") @@ -81,42 +103,25 @@ def seed_process_run( direction=Direction.input, ) resource = Resource(name=f"Resource-{name}", template=resource_template) + if with_resource_child: + Resource( + name=f"Resource-{name}-child", + template=resource_template, + parent=resource, + ) db_session.add_all([resource_type, resource_template, slot, resource]) - db_session.flush() if bind_step_slot: step_template.bindings["input_resource"] = StepTemplateResourceSlotBinding( role="input_resource", resource_slot=slot, ) + db_session.flush() + db_session.add(run) + if with_resource: run.resources[slot] = resource db_session.commit() - return campaign, run - - -def test_campaign_without_include_requires_lazy_load(db_session): - campaign, _ = seed_process_run(db_session, name="lazy") - campaign_row = make_query(db_session).campaigns().filter(id=campaign.id).first() - assert campaign_row is not None - # with pytest.raises(DetachedInstanceError): - _ = len(campaign_row.process_runs) - - -def test_campaign_include_process_runs_and_steps(db_session): - campaign, _ = seed_process_run(db_session, name="include", with_parameters=True) - - loaded_campaign = ( - make_query(db_session) - .campaigns() - .filter(id=campaign.id) - .include_process_runs() # lambda q: q.include_steps(include_parameters=True)) - .first() - ) - assert loaded_campaign is not None - assert loaded_campaign.process_runs[0].name.startswith("Run-include") - step = loaded_campaign.process_runs[0].steps["Step-include"] - exposure = step.parameters["Exposure-include"] - assert exposure.values.dwell_time.value == 5 + return run.namespace, run def test_process_run_pagination_and_filtering(db_session): @@ -126,8 +131,8 @@ def test_process_run_pagination_and_filtering(db_session): query = ( make_query(db_session) .process_runs() - .where(ProcessRun.name.like("Run-batch%")) - .order_by(ProcessRun.name) + .where(Field("name").starts_with("Run-batch")) + .order_by(Field("name")) ) # head = query.limit(2).as_models() @@ -136,7 +141,7 @@ def test_process_run_pagination_and_filtering(db_session): third = query.offset(2).first() assert third.name == names[2] - filtered = query.where(ProcessRun.name == names[1]).all() + filtered = query.where(Field("name") == names[1]).all() assert [run.name for run in filtered] == [names[1]] @@ -195,6 +200,7 @@ def test_process_run_include_resources_populates_step_resources(db_session): name="step-resources", with_resource=True, bind_step_slot=True, + with_resource_child=True, ) loaded_run = ( @@ -236,8 +242,8 @@ def test_include_steps_with_parameters_adds_nested_preload(db_session): ("resource_templates", ResourceTemplateSchema), ], ) -def test_query_defaults_use_full_shape(factory, schema, db_session): - query = getattr(make_query(db_session), factory)() +def test_query_defaults_use_full_shape(factory, schema, read_client): + query = getattr(read_client.query_maker(), factory)() assert query._shape == "full" assert query.model is schema @@ -253,8 +259,8 @@ def test_query_defaults_use_full_shape(factory, schema, db_session): ("resource_templates", ResourceTemplateSchema), ], ) -def test_query_accepts_eager_load(factory, schema, db_session): - query = getattr(make_query(db_session), factory)(shape="full", load="eager") +def test_query_accepts_eager_load(factory, schema, read_client): + query = getattr(read_client.query_maker(), factory)(shape="full", load="eager") assert query._shape == "full" assert query._load == "eager" @@ -262,6 +268,49 @@ def test_query_accepts_eager_load(factory, schema, db_session): assert query._spec.load_mode == "eager" +def test_query_entity_and_load_parity(read_client): + query = read_client.query_maker() + + resources = query.resources().filter(name="plate-1").all() + templates = query.resource_templates().filter(name="Parity plate").all() + runs = query.process_runs().filter(name="run-high").all() + process_templates = query.process_templates().filter(name="Parity workflow").all() + + assert [type(item) for item in resources] == [ResourceSchema] + assert [type(item) for item in templates] == [ResourceTemplateSchema] + assert [type(item) for item in runs] == [ProcessRunSchema] + assert [type(item) for item in process_templates] == [ProcessTemplateSchema] + assert [item.name for item in resources] == ["plate-1"] + assert [item.name for item in templates] == ["Parity plate"] + assert [item.name for item in runs] == ["run-high"] + assert [item.name for item in process_templates] == ["Parity workflow"] + + assert query.resources().filter(name="plate-1").count() == 1 + assert query.resources().filter(name="missing").first() is None + + +def test_query_relations_and_identity_are_local_remote_parity(read_client): + query = read_client.query_maker() + partial = query.resources().filter(name="plate-1").first() + assert partial is not None + assert partial.is_loaded("properties") is False + + direct_template = query.resource_templates().filter(name="Parity plate").first() + loaded = query.resources().filter(name="plate-1").include_template().first() + eager = query.resources(load="eager").filter(name="plate-1").first() + run = query.process_runs().filter(name="run-high").include_resources().first() + repeated = query.resources().filter(name="plate-1").first() + + assert loaded is partial + assert loaded.template is direct_template + assert run is not None + assert next(iter(run.assigned_resources.values())).resource is loaded + assert repeated is loaded + + assert eager is loaded + assert eager.is_loaded("properties") is True + + def test_deprecated_query_names_warn_and_normalize_immediately(db_session): with pytest.warns(DeprecationWarning) as warnings_seen: query = make_query(db_session).resources(shape="schema", load="full") @@ -287,7 +336,12 @@ def test_cloning_normalized_query_emits_no_deprecation_warning(db_session): def test_direct_query_construction_normalizes_deprecated_names(db_session): backend = make_query(db_session).backend with pytest.warns(DeprecationWarning) as warnings_seen: - query = ResourceQuery(backend, shape="schema", load="full") + query = ResourceQuery( + backend, + context=make_query(db_session).context, + shape="schema", + load="full", + ) assert len(warnings_seen) == 2 assert query._shape == "full" @@ -306,15 +360,6 @@ def test_query_rejects_unknown_shape_and_load(kwargs, message, db_session): make_query(db_session).resources(**kwargs) -@pytest.mark.parametrize( - "factory", - ["process_runs", "process_templates", "resources", "resource_templates"], -) -def test_query_include_rejects_ref_shape(factory, db_session): - with pytest.raises(ValueError, match="shape='full'"): - getattr(make_query(db_session), factory)(shape="ref").include("relation") - - @pytest.mark.parametrize( "factory", ["process_runs", "process_templates", "resources", "resource_templates"], @@ -353,99 +398,14 @@ def test_run_assignment_auto_populates_bound_step_assignments(db_session): ) -def test_add_child_step_allows_descendant_of_slot_resource(db_session): - _, run = seed_process_run( - db_session, - name="child-descendant-allowed", - with_resource=True, - bind_step_slot=True, - ) - - run_assignment = next(iter(run.assignments.values())) - child_resource = Resource( - name="child-well-A1", template=run_assignment.resource.template - ) - child_resource.parent = run_assignment.resource - db_session.add(child_resource) - db_session.commit() - - SessionLocal = sessionmaker(bind=db_session.get_bind()) - backend = LocalBackend(SessionLocal) - - loaded_run = ( - QueryDSL(backend) - .process_runs() - .filter(id=run.id) - .include_steps(include_parameters=False) - .include_resources() - .first() - ) - - assert loaded_run is not None - parent = loaded_run.steps["Step-child-descendant-allowed"] - child = parent.generate_child() - child.resources["input_resource"] = ResourceRef.model_validate(child_resource) - - uow = backend.begin() - try: - created_child = backend.add_child_step(loaded_run, child) - finally: - uow.rollback() - - assert created_child.resources["input_resource"].id == child_resource.id - - -def test_add_child_step_rejects_unrelated_resource(db_session): - _, run = seed_process_run( - db_session, - name="child-unrelated-rejected", - with_resource=True, - bind_step_slot=True, - ) - - unrelated_type = ResourceType(name=f"unrelated-type-{uuid4().hex}") - unrelated_template = ResourceTemplate(name=f"unrelated-template-{uuid4().hex}") - unrelated_template.types.append(unrelated_type) - unrelated_resource = Resource( - name="unrelated-resource", template=unrelated_template - ) - db_session.add_all([unrelated_type, unrelated_template, unrelated_resource]) - db_session.commit() - - SessionLocal = sessionmaker(bind=db_session.get_bind()) - backend = LocalBackend(SessionLocal) - - loaded_run = ( - QueryDSL(backend) - .process_runs() - .filter(id=run.id) - .include_steps(include_parameters=False) - .include_resources() - .first() - ) - - assert loaded_run is not None - parent = loaded_run.steps["Step-child-unrelated-rejected"] - child = parent.generate_child() - child.resources["input_resource"] = ResourceRef.model_validate(unrelated_resource) - - uow = backend.begin() - try: - with pytest.raises(ValueError, match="must be the assigned resource"): - backend.add_child_step(loaded_run, child) - finally: - uow.rollback() - - def test_process_run_query_can_return_ref(db_session): _, run = seed_process_run(db_session, name="ref-run") ref = make_query(db_session).process_runs(shape="ref").filter(id=run.id).first() - assert isinstance(ref, ProcessRunRef) - assert isinstance(ref.template, ProcessTemplateRef) - # Ref objects should not expose steps - assert not hasattr(ref, "steps") + assert isinstance(ref, ProcessRunSchema) + assert isinstance(ref.template, ProcessTemplateSchema) + assert ref.is_loaded("steps") is False def test_process_template_query_can_return_ref(db_session): @@ -458,8 +418,8 @@ def test_process_template_query_can_return_ref(db_session): .first() ) - assert isinstance(ref, ProcessTemplateRef) - assert not hasattr(ref, "step_templates") + assert isinstance(ref, ProcessTemplateSchema) + assert ref.step_templates == {} def test_process_template_includes(db_session): @@ -477,6 +437,18 @@ def test_process_template_includes(db_session): assert tmpl is not None assert "Step-pt-include" in tmpl.step_templates assert any(rs.name.startswith("slot-pt-include") for rs in tmpl.resource_slots) + assert tmpl.is_loaded("step_templates") is True + assert tmpl.is_loaded("resource_slots") is True + + +def test_process_template_query_marks_unrequested_relations_unloaded(db_session): + _, run = seed_process_run(db_session, name="pt-flags", with_resource=True) + + tmpl = make_query(db_session).process_templates().filter(id=run.template.id).first() + + assert tmpl is not None + assert tmpl.is_loaded("step_templates") is False + assert tmpl.is_loaded("resource_slots") is False def test_resource_queries_can_return_refs(db_session): @@ -497,9 +469,9 @@ def test_resource_queries_can_return_refs(db_session): .first() ) - assert isinstance(res_ref, ResourceRef) - assert isinstance(res_ref.template, ResourceTemplateRef) - assert isinstance(tmpl_ref, ResourceTemplateRef) + assert isinstance(res_ref, ResourceSchema) + assert isinstance(res_ref.template, ResourceTemplateSchema) + assert isinstance(tmpl_ref, ResourceTemplateSchema) def test_resource_template_includes(db_session): @@ -533,6 +505,23 @@ def test_resource_template_includes(db_session): for at in tmpl.attribute_group_templates[0].attribute_templates ) assert any(t.name == "rt-inc" for t in tmpl.types) + assert tmpl.is_loaded("children") is True + assert tmpl.is_loaded("attribute_group_templates") is True + assert tmpl.is_loaded("types") is True + assert tmpl.is_loaded("parent") is False + + +def test_eager_template_query_marks_all_relations_loaded(db_session): + _, run = seed_process_run(db_session, name="pt-eager", with_resource=True) + process_template = ( + make_query(db_session) + .process_templates(load="eager") + .filter(id=run.template.id) + .first() + ) + assert process_template is not None + assert process_template.is_loaded("step_templates") is True + assert process_template.is_loaded("resource_slots") is True def test_resource_property_filtering_and_parent_scope(db_session): @@ -577,7 +566,6 @@ def test_resource_property_filtering_and_parent_scope(db_session): def test_process_run_parameter_filtering(db_session): with db_session.no_autoflush: - campaign = Campaign(name="C-param", proposal="P-param") tmpl = ProcessTemplate(name="PT-param", version="1.0") step_tmpl = StepTemplate(name="Collect", process_template=tmpl) params_grp = AttributeGroupTemplate(name="Exposure", step_template=step_tmpl) @@ -585,20 +573,18 @@ def test_process_run_parameter_filtering(db_session): AttributeTemplate(name="dwell", value_type="int", default_value="5") ) - db_session.add_all([campaign, tmpl, step_tmpl, params_grp]) + db_session.add_all([tmpl, step_tmpl, params_grp]) db_session.commit() run_low = ProcessRun( name="run-low", description="low dwell", template=tmpl, - campaign=campaign, ) run_high = ProcessRun( name="run-high", description="high dwell", template=tmpl, - campaign=campaign, ) run_low.steps["Collect"].parameters["Exposure"].values["dwell"] = 4 @@ -624,7 +610,6 @@ def test_process_run_parameter_filtering_strings(db_session): (e.g., '"active"'), so the comparison must encode the RHS the same way. """ with db_session.no_autoflush: - campaign = Campaign(name="C-str", proposal="P-str") tmpl = ProcessTemplate(name="PT-str", version="1.0") step_tmpl = StepTemplate(name="Collect", process_template=tmpl) params_grp = AttributeGroupTemplate(name="Status", step_template=step_tmpl) @@ -632,20 +617,18 @@ def test_process_run_parameter_filtering_strings(db_session): AttributeTemplate(name="state", value_type="str", default_value="pending") ) - db_session.add_all([campaign, tmpl, step_tmpl, params_grp]) + db_session.add_all([tmpl, step_tmpl, params_grp]) db_session.commit() run_active = ProcessRun( name="run-active", description="active run", template=tmpl, - campaign=campaign, ) run_done = ProcessRun( name="run-done", description="done run", template=tmpl, - campaign=campaign, ) run_active.steps["Collect"].parameters["Status"].values["state"] = "active" @@ -710,7 +693,6 @@ def test_resource_property_filtering_strings(db_session): def test_parameter_filtering_numeric_unchanged(db_session): """Ensure the coercion refactor doesn't break numeric/bool filtering.""" with db_session.no_autoflush: - campaign = Campaign(name="C-num", proposal="P-num") tmpl = ProcessTemplate(name="PT-num", version="1.0") step_tmpl = StepTemplate(name="Measure", process_template=tmpl) params_grp = AttributeGroupTemplate(name="Readings", step_template=step_tmpl) @@ -721,20 +703,18 @@ def test_parameter_filtering_numeric_unchanged(db_session): AttributeTemplate(name="enabled", value_type="bool", default_value="true") ) - db_session.add_all([campaign, tmpl, step_tmpl, params_grp]) + db_session.add_all([tmpl, step_tmpl, params_grp]) db_session.commit() run_a = ProcessRun( name="run-num-a", description="a", template=tmpl, - campaign=campaign, ) run_b = ProcessRun( name="run-num-b", description="b", template=tmpl, - campaign=campaign, ) run_a.steps["Measure"].parameters["Readings"].values["count"] = 10 @@ -757,47 +737,6 @@ def test_parameter_filtering_numeric_unchanged(db_session): assert {r.name for r in hits_bool} == {"run-num-a"} -def test_queries_are_scoped_to_campaign(db_session): - camp_a, run_a = seed_process_run( - db_session, name="scope-a", with_resource=True, with_parameters=True - ) - camp_b, run_b = seed_process_run( - db_session, name="scope-b", with_resource=True, with_parameters=True - ) - - q_a = make_query(db_session, campaign_id=camp_a.id) - runs_a = q_a.process_runs().all() - assert {r.id for r in runs_a} == {run_a.id} - - resources_a = q_a.resources().all() - assigned_res_ids = {res.id for res in run_a.resources.values()} - assert {res.id for res in resources_a} == assigned_res_ids - - campaigns_a = q_a.campaigns().all() - assert camp_a.id in {c.id for c in campaigns_a} - - q_b = make_query(db_session, campaign_id=camp_b.id) - runs_b = q_b.process_runs().all() - assert {r.id for r in runs_b} == {run_b.id} - - -def test_campaign_scope_not_applied_to_templates(db_session): - camp_a, _ = seed_process_run( - db_session, name="tmpl-a", with_resource=True, with_parameters=True - ) - _, run_b = seed_process_run( - db_session, name="tmpl-b", with_resource=True, with_parameters=True - ) - - q = make_query(db_session, campaign_id=camp_a.id) - - tmpl_ids = {pt.id for pt in q.process_templates().all()} - res_tmpl_ids = {rt.id for rt in q.resource_templates().all()} - - assert run_b.template.id in tmpl_ids - assert len(res_tmpl_ids) >= 2 - - def _seed_resource_hierarchy(db_session): """Build a 3-level resource tree spanning two templates. diff --git a/recap/tests/test_query_loaders.py b/recap/tests/test_query_loaders.py new file mode 100644 index 0000000..77e4bb9 --- /dev/null +++ b/recap/tests/test_query_loaders.py @@ -0,0 +1,49 @@ +import pytest + +from recap.adapter.query_loaders import ( + PRELOAD_STATEMENTS, + resolve_loader_options, +) +from recap.schemas.process import ProcessRunSchema +from recap.schemas.resource import ResourceSchema + + +def test_duplicate_preloads_are_resolved_once_in_request_order(monkeypatch): + calls = [] + + def preload_options(schema, name): + calls.append(name) + return [name] + + monkeypatch.setattr("recap.adapter.query_loaders.preload_options", preload_options) + + options = resolve_loader_options( + ProcessRunSchema, ["steps", "steps.parameters", "steps"], None + ) + + assert calls == ["steps", "steps.parameters"] + assert options[-2:] == ["steps", "steps.parameters"] + + +def test_eager_mode_expands_stable_declared_paths(monkeypatch): + calls = [] + + def preload_options(schema, name): + calls.append(name) + return [name] + + monkeypatch.setattr("recap.adapter.query_loaders.preload_options", preload_options) + + resolve_loader_options(ResourceSchema, [], "eager") + + assert calls == ["template", "properties", "children"] + + +def test_unsupported_preload_is_validation_error(): + with pytest.raises(ValueError, match="Unsupported preload 'parameters'"): + resolve_loader_options(ResourceSchema, ["parameters"], None) + + +def test_every_declared_preload_has_loader_statements(): + for (schema, relation), statements in PRELOAD_STATEMENTS.items(): + assert statements, (schema, relation) diff --git a/recap/tests/test_remote_builder_parity.py b/recap/tests/test_remote_builder_parity.py new file mode 100644 index 0000000..31d994b --- /dev/null +++ b/recap/tests/test_remote_builder_parity.py @@ -0,0 +1,559 @@ +"""Remote command writes and REST read parity.""" + +from unittest.mock import patch +from uuid import uuid4 + +import httpx2 +import pytest +from fastapi.testclient import TestClient + +from recap.adapter.rest import RESTAdapter +from recap.client import RecapClient +from recap.exceptions import ( + RecapAuthenticationError, + RecapConflictError, + RecapNotFoundError, +) +from recap.schemas.namespace import NamespaceContext +from recap.schemas.resource import ResourceCopyOptions +from recap.server.app import create_app +from recap.dsl.query import QuerySpec +from recap.schemas.process import ProcessTemplateSchema +from recap.schemas.resource import ResourceTemplateSchema + + +@pytest.fixture(params=["local", "remote"], ids=["local", "remote"]) +def command_client(request, tmp_path, rest_loopback_client): + db_path = tmp_path / f"{request.param}-commands.db" + if request.param == "local": + with RecapClient.from_sqlite(db_path) as client: + yield client + return + + yield rest_loopback_client + + +def seed_command_namespace(client): + client.create_namespace("beamline") + client.create_namespace("beamline/amx") + scoped = client.namespace("beamline/amx") + return scoped + + +def make_template_builder(client, kind, name="Deferred"): + scoped = seed_command_namespace(client) + if kind == "process": + return scoped.build_process_template(name, "1.0") + return scoped.build_resource_template(name=name, type_names=["sample"]) + + +def make_persisted_template_builder(client, kind): + builder = make_template_builder(client, kind, name=f"Persisted-{kind}") + with builder: + pass + return builder + + +def template_exists(client, kind, template_id): + schema = ProcessTemplateSchema if kind == "process" else ResourceTemplateSchema + return bool( + client.connection_state.backend.query( + schema, + QuerySpec(filters={"id": template_id}, include_mutable=True), + namespace_path="beamline/amx", + ) + ) + + +def test_resource_template_lifecycle_has_local_remote_parity(command_client): + scoped = seed_command_namespace(command_client) + with scoped.build_resource_template( + name="Sample", type_names=["sample"] + ) as builder: + pass + + with builder: + builder.finalize() + assert builder.template.status.value == "ACTIVE" + with builder: + builder.archive() + assert builder.template.status.value == "ARCHIVED" + + +@pytest.mark.parametrize("kind", ["process", "resource"]) +def test_template_changes_are_local_until_clean_context_exit(command_client, kind): + builder = make_template_builder(command_client, kind) + if kind == "process": + builder.add_step("Collect") + else: + builder.prop_group("meta").add_attribute("code", "str", "", "").close_group() + assert builder.changes().fields + assert not template_exists(command_client, kind, builder.template.id) + with builder: + pass + assert template_exists(command_client, kind, builder.template.id) + + +@pytest.mark.parametrize("kind", ["process", "resource"]) +def test_template_finalization_is_deferred_until_clean_exit(command_client, kind): + builder = make_persisted_template_builder(command_client, kind) + with builder: + builder.finalize() + assert builder.changes().lifecycle.value == "ACTIVE" + assert builder.template.status.value == "MUTABLE" + assert builder.template.status.value == "ACTIVE" + + +@pytest.mark.parametrize("kind", ["process", "resource"]) +def test_template_nested_context_commits_once_and_exception_can_retry(command_client, kind): + builder = make_template_builder(client=command_client, kind=kind, name=f"Nested-{kind}") + if kind == "process": + builder.add_step("Collect") + else: + builder.prop_group("meta").add_attribute("code", "str", "", "").close_group() + with builder: + with builder: + pass + revision = builder.template.revision + with pytest.raises(RuntimeError): + with builder: + builder.finalize() + raise RuntimeError("retry") + assert builder.template.status.value == "MUTABLE" + with builder: + pass + assert builder.template.status.value == "ACTIVE" + assert builder.template.revision == revision + 1 + + +def test_new_builders_keep_provisional_ids_after_commit(command_client): + scoped = seed_command_namespace(command_client) + with scoped.build_process_template("ID PT", "1.0") as process_template: + process_template_id = process_template.template.id + assert process_template.template.id == process_template_id + + with scoped.build_resource_template(name="ID RT", type_names=["sample"]) as resource_template: + resource_template_id = resource_template.template.id + assert resource_template.template.id == resource_template_id + + process_run = scoped.build_process_run("ID run", "", "ID PT", "1.0") + process_run_id = process_run.process_run.id + with process_run: + pass + assert process_run.process_run.id == process_run_id + + resource = scoped.build_resource("ID resource", "ID RT") + resource_id = resource.resource.id + with resource: + pass + assert resource.resource.id == resource_id + + +def test_namespace_update_has_local_remote_parity(command_client): + scoped = seed_command_namespace(command_client) + + updated = scoped.update_namespace(metadata={"beamline": "AMX"}) + + assert updated.metadata == {"beamline": "AMX"} + assert updated.revision == 2 + + +def test_resource_template_load_update_has_local_remote_parity(command_client): + scoped = seed_command_namespace(command_client) + with scoped.build_resource_template( + name="Sample", type_names=["sample"] + ) as created: + pass + + with scoped.build_resource_template( + resource_template_id=created.template.id + ) as loaded: + loaded.prop_group("details").add_attribute( + "batch", "str", "", "B-1" + ).close_group() + + assert ( + loaded.template.attribute_group_templates[0] + .attribute_templates[0] + .default_value + == "B-1" + ) + assert loaded.template.revision == 2 + + +def test_resource_update_has_local_remote_parity(command_client): + scoped = seed_command_namespace(command_client) + with scoped.build_resource_template( + name="Sample", type_names=["sample"] + ) as template: + template.prop_group("details").add_attribute( + "serial", "str", "", "old" + ).close_group() + resource = scoped.create_resource("sample-1", "Sample") + + with scoped.build_resource(resource_id=resource.id) as builder: + assert builder.resource.id == resource.id + model = builder.get_model() + model.properties.details.values.serial.value = "new" + builder.set_model(model) + + assert builder.resource.properties.details.values.serial.value == "new" + assert builder.resource.revision == 2 + + +def test_resource_copy_has_local_remote_parity(command_client): + scoped = seed_command_namespace(command_client) + with scoped.build_resource_template(name="Sample", type_names=["sample"]): + pass + source = scoped.create_resource("sample-1", "Sample") + + copied = scoped.copy_resource(source.id) + + assert copied.id != source.id + assert copied.template.id == source.template.id + + +def test_resource_copy_as_child_has_local_remote_parity(command_client): + scoped = seed_command_namespace(command_client) + with scoped.build_resource_template(name="Group", type_names=["group"]): + pass + with scoped.build_resource_template(name="Sample", type_names=["sample"]): + pass + group = scoped.create_resource("group-1", "Group") + source = scoped.create_resource("sample-1", "Sample") + with scoped.build_resource(resource_id=source.id) as source_builder: + source_child_builder = source_builder.add_child("sample-child", "Sample") + source_child = source_child_builder.resource + + with scoped.build_resource(resource_id=group.id) as builder: + copied = builder.add_child(source) + + assert copied.resource.id != source.id + assert copied.resource.copied_from_id == source.id + copied_descendant = copied.resource.children["sample-child"] + assert copied_descendant.id != source_child.id + assert copied_descendant.copied_from_id is None + with scoped.build_resource(resource_id=group.id) as loader: + refreshed = loader.get_model(update=True) + assert refreshed.children["sample-1"].id == copied.resource.id + assert refreshed.children["sample-1"].copied_from_id == source.id + + +def test_resource_lifecycle_has_local_remote_parity(command_client): + scoped = seed_command_namespace(command_client) + with scoped.build_resource_template(name="Sample", type_names=["sample"]): + pass + resource = scoped.create_resource("sample-1", "Sample") + + with scoped.build_resource(resource_id=resource.id) as builder: + builder.finalize() + assert builder.resource.status.value == "ACTIVE" + with builder: + builder.archive() + assert builder.resource.status.value == "ARCHIVED" + + +def test_process_template_create_load_update_lifecycle_has_local_remote_parity( + command_client, +): + scoped = seed_command_namespace(command_client) + with scoped.build_process_template("Measure", "1.0") as created: + created.add_step("Collect").close_step() + + with scoped.build_process_template( + process_template_id=created.template.id + ) as loaded: + loaded.add_step("Analyze").close_step() + + assert loaded.template.revision == 2 + assert {step.name for step in loaded.template.step_templates.values()} == { + "Collect", + "Analyze", + } + with loaded: + loaded.finalize() + assert loaded.template.status.value == "ACTIVE" + with loaded: + loaded.archive() + assert loaded.template.status.value == "ARCHIVED" + + +def test_process_run_create_load_update_lifecycle_has_local_remote_parity( + command_client, +): + scoped = seed_command_namespace(command_client) + with scoped.build_process_template("Measure", "1.0") as template: + ( + template.add_step("Mix") + .param_group("Inputs") + .add_attribute("Voltage", "int", "", 0) + .close_group() + .close_step() + ) + + with scoped.build_process_run( + name="run-1", + description="initial", + template_name="Measure", + version="1.0", + ) as created: + run_id = created.process_run.id + + with scoped.build_process_run(process_run_id=run_id) as loaded: + model = loaded.get_model() + model.description = "updated" + loaded.set_model(model) + + assert loaded.process_run.description == "updated" + assert loaded.process_run.revision == 2 + with loaded: + loaded.finalize() + assert loaded.process_run.status.value == "ACTIVE" + with loaded: + loaded.archive() + assert loaded.process_run.status.value == "ARCHIVED" + + +def test_stale_resource_write_preserves_first_mutation(command_client): + scoped = seed_command_namespace(command_client) + with scoped.build_resource_template(name="Sample", type_names=["sample"]): + pass + resource = scoped.create_resource("sample-1", "Sample") + + first = scoped.build_resource(resource_id=resource.id) + second = scoped.build_resource(resource_id=resource.id) + first_model = first.get_model() + first_model.name = "first" + first.set_model(first_model) + with first: + pass + + second_model = second.get_model() + second_model.name = "second" + second.set_model(second_model) + with pytest.raises(RecapConflictError): + with second: + pass + + assert second.resource.name == "second" + assert second.changes().fields["name"] == "second" + + with scoped.build_resource(resource_id=resource.id) as persisted: + assert persisted.resource.name == "first" + + +def test_remote_writes_are_visible_to_rest_queries(tmp_path): + db_path = tmp_path / "remote.db" + api_key = "remote-secret" + app_client = TestClient(create_app(db_path, api_key=api_key)) + + def request(_client, method, url, **kwargs): + path = url.removeprefix("http://recap.test") + response = app_client.request(method, path, **kwargs) + return response + + with ( + patch.object(httpx2.Client, "request", request), + RecapClient.from_url("http://recap.test", api_key=api_key) as remote, + ): + remote.create_namespace("beamline") + remote.create_namespace("beamline/amx", metadata={"beamline": "amx"}) + namespace = remote.namespace("beamline/amx") + + with namespace.build_resource_template(name="Sample", type_names=["sample"]): + pass + with namespace.build_process_template("Measure", "1.0"): + pass + resource = namespace.create_resource("S-001", "Sample") + copied = namespace.copy_resource( + resource.id, + ResourceCopyOptions(name="S-001-copy"), + ) + with namespace.build_resource(resource_id=copied.id) as copied_builder: + copied_builder.finalize() + with namespace.build_process_run( + name="run-001", + description="remote run", + template_name="Measure", + version="1.0", + ) as process_run_builder: + process_run = process_run_builder.process_run + process_run_builder.finalize() + + assert namespace.namespace_path == "beamline/amx" + assert copied.name == "S-001-copy" + assert process_run.name == "run-001" + assert [item.name for item in namespace.query_maker().resources().all()] == [ + "S-001", + "S-001-copy", + ] + assert "Sample" in [ + item.name for item in namespace.query_maker().resource_templates().all() + ] + assert "Measure" in [ + item.name for item in namespace.query_maker().process_templates().all() + ] + assert [item.name for item in namespace.query_maker().process_runs().all()] == [ + "run-001" + ] + + +def test_scoped_remote_query_uses_view_namespace(): + with patch.object( + RESTAdapter, + "get_namespace_context", + return_value=NamespaceContext(id=uuid4(), path="beamline/amx"), + ): + client = RecapClient.from_url("http://recap.test", api_key="secret") + scoped = client.namespace("beamline/amx") + query = scoped.query_maker().resources() + + assert query._context.path == "beamline/amx" + scoped.close() + client.close() + + +def test_scoped_remote_public_builders_use_namespace_routes(tmp_path): + db_path = tmp_path / "remote-builders.db" + api_key = "remote-secret" + app_client = TestClient(create_app(db_path, api_key=api_key)) + request_paths = [] + template_entity = None + + def request(_client, method, url, **kwargs): + nonlocal template_entity + path = url.removeprefix("http://recap.test") + request_paths.append((method, path)) + response = app_client.request(method, path, **kwargs) + if path == "/api/v1/resource-templates/beamline/amx": + template_entity = response.json() + return response + + with ( + patch.object(httpx2.Client, "request", request), + RecapClient.from_url("http://recap.test", api_key=api_key) as remote, + ): + remote.create_namespace("beamline") + remote.create_namespace("beamline/amx") + namespace = remote.namespace("beamline/amx") + + with namespace.build_resource_template(name="Sample", type_names=["sample"]): + pass + + with namespace.build_resource("S-001", "Sample") as resource_builder: + resource_id = resource_builder.resource.id + + assert resource_id + assert ("POST", "/api/v1/resource-templates/beamline/amx") in request_paths + assert ("POST", "/api/v1/resources/beamline/amx") in request_paths + + namespace.close() + + +def test_scoped_remote_create_resource_uses_namespace_route(tmp_path): + db_path = tmp_path / "remote-create-resource.db" + api_key = "remote-secret" + app_client = TestClient(create_app(db_path, api_key=api_key)) + request_paths = [] + template_entity = None + + def request(_client, method, url, **kwargs): + nonlocal template_entity + path = url.removeprefix("http://recap.test") + request_paths.append((method, path)) + response = app_client.request(method, path, **kwargs) + if path == "/api/v1/resource-templates/beamline/amx": + template_entity = response.json() + return response + + with ( + patch.object(httpx2.Client, "request", request), + RecapClient.from_url("http://recap.test", api_key=api_key) as remote, + ): + remote.create_namespace("beamline") + remote.create_namespace("beamline/amx") + namespace = remote.namespace("beamline/amx") + with namespace.build_resource_template(name="Sample", type_names=["sample"]): + pass + + resource = namespace.create_resource("S-001", "Sample") + query_paths = [] + + def query(_adapter, schema, spec, *, namespace_path): + query_paths.append(namespace_path) + return [resource] + + with ( + patch.object(RESTAdapter, "query", query), + namespace.build_resource(resource_id=resource.id) as builder, + ): + assert builder.resource.id == resource.id + assert query_paths == ["beamline/amx"] + + assert resource.name == "S-001" + assert ("POST", "/api/v1/resources/beamline/amx") in request_paths + namespace.close() + + +def test_remote_resource_builder_reports_missing_template_without_lookup(tmp_path): + db_path = tmp_path / "remote-missing-template.db" + api_key = "remote-secret" + app_client = TestClient(create_app(db_path, api_key=api_key)) + + def request(_client, method, url, **kwargs): + path = url.removeprefix("http://recap.test") + return app_client.request(method, path, **kwargs) + + with ( + patch.object(httpx2.Client, "request", request), + patch.object(RESTAdapter, "query", return_value=[]), + RecapClient.from_url("http://recap.test", api_key=api_key) as remote, + ): + remote.create_namespace("beamline") + remote.create_namespace("beamline/amx") + namespace = remote.namespace("beamline/amx") + + with pytest.raises( + RecapNotFoundError, + match="Resource template 'Missing' version '1.0' not found", + ): + namespace.build_resource("S-001", "Missing") + + namespace.close() + + +def test_remote_namespace_write_rejects_invalid_api_key(tmp_path, monkeypatch): + with TestClient( + create_app(tmp_path / "auth.db", api_key="correct-key") + ) as app_client: + + def request(_client, method, url, **kwargs): + response = app_client.request( + method, url.removeprefix("http://recap.test"), **kwargs + ) + request = httpx2.Request(method, url, headers=kwargs.get("headers")) + return httpx2.Response( + response.status_code, + headers=response.headers, + content=response.content, + request=request, + ) + + monkeypatch.setattr( + httpx2.Client, + "request", + request, + ) + with ( + patch.object( + RESTAdapter, + "get_namespace_context", + return_value=NamespaceContext(id=uuid4(), path=""), + ), + RecapClient.from_url("http://recap.test", api_key="wrong-key") as client, + pytest.raises(RecapAuthenticationError) as caught, + ): + client.create_namespace("beamline") + + assert caught.value.status_code == 401 + assert "wrong-key" not in str(caught.value) diff --git a/recap/tests/test_request_authentication.py b/recap/tests/test_request_authentication.py new file mode 100644 index 0000000..9f72733 --- /dev/null +++ b/recap/tests/test_request_authentication.py @@ -0,0 +1,117 @@ +import asyncio +import hashlib +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from recap.authentication.errors import InvalidCredentialsError +from recap.authorization.scopes import Scope + + +@pytest.fixture +def configured_key(): + return "correct-horse-battery-staple" + + +@pytest.fixture +def test_client(tmp_path, configured_key): + from recap.server.app import create_app + + return TestClient(create_app(tmp_path / "test.db", api_key=configured_key)) + + +def test_valid_api_key_authenticates(test_client, configured_key): + response = test_client.get( + "/api/v1/namespaces/children", + headers={"Authorization": f"Apikey {configured_key}"}, + ) + + assert response.status_code == 200 + + +def test_api_key_authentication_applies_to_rest_routes(test_client): + response = test_client.put("/api/v1/namespaces/test", json={}) + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Apikey" + + +@pytest.mark.parametrize( + "authorization", + [ + None, + "Apikey wrong", + "apikey correct-horse-battery-staple", + "Bearer correct-horse-battery-staple", + "Apikey", + "Apikey correct-horse-battery-staple extra", + "Apikey correct-horse-battery-staple", + ], +) +def test_invalid_authorization_header_is_401(test_client, authorization): + headers = {} if authorization is None else {"Authorization": authorization} + + response = test_client.get("/api/v1/namespaces/children", headers=headers) + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Apikey" + assert "correct-horse-battery-staple" not in response.text + + +def test_authenticator_returns_deterministic_actor_with_all_scopes(configured_key): + from recap.authentication.api_key import ApiKeyRequestAuthenticator + + authenticator = ApiKeyRequestAuthenticator(configured_key) + first = asyncio.run(authenticator.authenticate(configured_key)) + second = asyncio.run(authenticator.authenticate(configured_key)) + + assert first == second + assert first.actor_id == "single-user" + assert first.credential_scopes == frozenset(Scope) + assert first.namespace_restrictions is None + assert ( + first.credential_fingerprint + == hashlib.sha256(configured_key.encode()).hexdigest() + ) + + +def test_single_user_actor_identity_is_shared_with_local_actor(configured_key): + from recap.authentication.actors import single_user_actor + from recap.authentication.api_key import ApiKeyRequestAuthenticator + + api_key_actor = asyncio.run( + ApiKeyRequestAuthenticator(configured_key).authenticate(configured_key) + ) + local_actor = single_user_actor(credential_fingerprint="local-single-user") + + assert api_key_actor.actor_id == local_actor.actor_id + assert api_key_actor.kind == local_actor.kind + assert api_key_actor.identities[0].subject == local_actor.identities[0].subject + assert api_key_actor.identities[0].provider == "api-key" + assert local_actor.identities[0].provider == "single-user" + assert api_key_actor.credential_scopes == local_actor.credential_scopes + assert api_key_actor.namespace_restrictions == local_actor.namespace_restrictions + assert api_key_actor.credential_fingerprint != local_actor.credential_fingerprint + + +def test_authenticator_uses_constant_time_comparison(configured_key): + from recap.authentication.api_key import ApiKeyRequestAuthenticator + + authenticator = ApiKeyRequestAuthenticator(configured_key) + with patch("recap.authentication.api_key.secrets.compare_digest") as compare: + compare.return_value = True + asyncio.run(authenticator.authenticate("presented-key")) + + compare.assert_called_once_with("presented-key", configured_key) + + +def test_authenticator_errors_and_repr_redact_secret(configured_key): + from recap.authentication.api_key import ApiKeyRequestAuthenticator + + authenticator = ApiKeyRequestAuthenticator(configured_key) + + assert configured_key not in repr(authenticator) + with pytest.raises(InvalidCredentialsError) as exc_info: + asyncio.run(authenticator.authenticate(f"wrong-{configured_key}")) + assert configured_key not in str(exc_info.value) diff --git a/recap/tests/test_request_backend_scope.py b/recap/tests/test_request_backend_scope.py new file mode 100644 index 0000000..6f7e3af --- /dev/null +++ b/recap/tests/test_request_backend_scope.py @@ -0,0 +1,79 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Lock + +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from recap.adapter.local import LocalBackend +from recap.server.dependencies import get_local_backend + + +class TrackingSession(Session): + closed = False + + def close(self): + self.closed = True + super().close() + + +def test_each_request_gets_independent_backend_without_persistent_session(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + factory = sessionmaker(bind=engine, class_=TrackingSession) + app = FastAPI() + app.state.session_factory = factory + observed = [] + observed_lock = Lock() + + @app.get("/scope") + def scope(backend: LocalBackend = Depends(get_local_backend)): # noqa: B008 + with observed_lock: + observed.append(backend) + return {"backend": id(backend), "has_session": hasattr(backend, "_session")} + + with ( + TestClient(app) as client, + ThreadPoolExecutor(max_workers=2) as executor, + ): + responses = list(executor.map(lambda _: client.get("/scope"), range(2))) + + assert all(response.status_code == 200 for response in responses) + assert len({response.json()["backend"] for response in responses}) == 2 + assert all(not response.json()["has_session"] for response in responses) + assert all(not hasattr(backend, "_session") for backend in observed) + engine.dispose() + + +def test_request_backend_has_no_session_when_handler_raises(): + engine = create_engine("sqlite://") + factory = sessionmaker(bind=engine, class_=TrackingSession) + app = FastAPI() + app.state.session_factory = factory + observed = [] + + @app.get("/failure") + def failure(backend: LocalBackend = Depends(get_local_backend)): # noqa: B008 + observed.append(backend) + raise RuntimeError("boom") + + with TestClient(app, raise_server_exceptions=False) as client: + assert client.get("/failure").status_code == 500 + + assert not hasattr(observed[0], "_session") + engine.dispose() + + +def test_server_app_stores_session_factory_instead_of_shared_backend(tmp_path): + from recap.server.app import create_app + + app = create_app(tmp_path / "server.db") + + assert hasattr(app.state, "session_factory") + assert not hasattr(app.state, "backend") + assert not hasattr(app.state, "client") diff --git a/recap/tests/test_request_security.py b/recap/tests/test_request_security.py new file mode 100644 index 0000000..2901e58 --- /dev/null +++ b/recap/tests/test_request_security.py @@ -0,0 +1,191 @@ +from uuid import UUID + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from recap.authorization.snapshot import SnapshotUnavailable +from recap.commands.errors import CommandValidationError +from recap.exceptions import ( + RecapConflictError, + RecapInternalError, + RecapPermissionDeniedError, + RecapServiceUnavailableError, + RecapValidationError, +) +from recap.server.errors import AuthorizationDenied + + +def _assert_request_id(response): + header_id = response.headers["X-Request-ID"] + parsed = UUID(header_id) + assert parsed.version == 4 + assert response.json()["error"]["request_id"] == header_id + + +def test_success_has_server_generated_uuid4_that_cannot_be_spoofed(tmp_path): + from recap.server.app import create_app + + client = TestClient(create_app(tmp_path / "test.db", api_key="secret")) + response = client.get( + "/api/v1/namespaces/children", + headers={"Authorization": "Apikey secret", "X-Request-ID": "caller-id"}, + ) + + assert response.status_code == 200 + request_id = response.headers["X-Request-ID"] + assert UUID(request_id).version == 4 + assert request_id != "caller-id" + + +def test_authentication_failure_uses_safe_stable_envelope(tmp_path): + from recap.server.app import create_app + + secret = "correct-horse-battery-staple" + client = TestClient(create_app(tmp_path / "test.db", api_key=secret)) + response = client.get( + "/api/v1/namespaces/children", + headers={"Authorization": f"Apikey wrong-{secret}", "X-Request-ID": "spoof"}, + ) + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Apikey" + assert response.json()["error"]["code"] == "authentication_required" + assert secret not in response.text + _assert_request_id(response) + + +@pytest.mark.parametrize( + ("error", "status_code", "code"), + [ + (AuthorizationDenied(), 403, "permission_denied"), + (AuthorizationDenied(conceal=True), 404, "not_found"), + (SnapshotUnavailable("grant details: top-secret"), 503, "service_unavailable"), + ], +) +def test_security_failures_map_to_safe_envelopes(tmp_path, error, status_code, code): + from recap.server.app import create_app + + app = create_app(tmp_path / "test.db") + + @app.get("/failure") + def failure(): + raise error + + response = TestClient(app).get("/failure") + + assert response.status_code == status_code + assert response.json()["error"]["code"] == code + assert "top-secret" not in response.text + assert "grant" not in response.text.lower() + _assert_request_id(response) + + +def test_security_failures_use_public_error_categories(): + assert isinstance(AuthorizationDenied(), RecapPermissionDeniedError) + assert isinstance(SnapshotUnavailable("unavailable"), RecapServiceUnavailableError) + + +@pytest.mark.parametrize( + ("error", "status_code", "code", "message"), + [ + ( + RecapValidationError("private validation details"), + 422, + "validation_error", + "Request validation failed", + ), + (RecapConflictError("private conflict details"), 409, "conflict", "Conflict"), + ( + RecapInternalError("credential=server-secret"), + 500, + "internal_error", + "Internal server error", + ), + ], +) +def test_public_request_errors_use_safe_status_code_and_message( + tmp_path, error, status_code, code, message +): + from recap.server.app import create_app + + app = create_app(tmp_path / "test.db") + + @app.get("/failure") + def failure(): + raise error + + response = TestClient(app).get("/failure") + + assert response.status_code == status_code + assert response.json()["error"]["code"] == code + assert response.json()["error"]["message"] == message + assert str(error) not in response.text + _assert_request_id(response) + + +def test_command_validation_error_keeps_safe_public_message(tmp_path): + from recap.server.app import create_app + + app = create_app(tmp_path / "test.db") + + @app.get("/failure") + def failure(): + raise CommandValidationError( + "private validation details", public_message="Invalid command" + ) + + response = TestClient(app).get("/failure") + + assert response.status_code == 422 + assert response.json()["error"]["code"] == "validation_error" + assert response.json()["error"]["message"] == "Invalid command" + assert "private validation details" not in response.text + + +def test_validation_failure_is_sanitized_422(tmp_path): + from recap.server.app import create_app + + app = create_app(tmp_path / "test.db") + + @app.get("/validated") + def validated(secret_parameter: int): + return {"value": secret_parameter} + + response = TestClient(app).get( + "/validated", params={"secret_parameter": "sensitive-value"} + ) + + assert response.status_code == 422 + assert response.json()["error"]["code"] == "validation_error" + assert "secret_parameter" not in response.text + assert "sensitive-value" not in response.text + _assert_request_id(response) + + +def test_http_and_internal_failures_never_expose_details(tmp_path): + from recap.server.app import create_app + + app = create_app(tmp_path / "test.db") + + @app.get("/missing") + def missing(): + raise HTTPException(status_code=404, detail="private object identifier") + + @app.get("/internal") + def internal(): + raise RuntimeError("credential=server-secret") + + with TestClient(app, raise_server_exceptions=False) as client: + missing_response = client.get("/missing") + internal_response = client.get("/internal") + + assert missing_response.status_code == 404 + assert missing_response.json()["error"]["code"] == "not_found" + assert "private object identifier" not in missing_response.text + _assert_request_id(missing_response) + + assert internal_response.status_code == 500 + assert internal_response.json()["error"]["code"] == "internal_error" + assert "server-secret" not in internal_response.text + _assert_request_id(internal_response) diff --git a/recap/tests/test_resolvers.py b/recap/tests/test_resolvers.py deleted file mode 100644 index 1474a3a..0000000 --- a/recap/tests/test_resolvers.py +++ /dev/null @@ -1,99 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - - -def make_mock_info(backend): - info = MagicMock() - info.context = {"backend": backend} - return info - - -def make_backend(tmp_path): - from recap.client import RecapClient - - client = RecapClient.from_sqlite(str(tmp_path / "test.db")) - return client.backend - - -def test_resolve_resources_returns_list(tmp_path): - from recap.server.resolvers import resolve_resources - - backend = make_backend(tmp_path) - info = make_mock_info(backend) - result = resolve_resources(info, campaign_id=None, limit=10, offset=0) - assert isinstance(result, list) - - -def test_resolve_campaigns_returns_list(tmp_path): - from recap.server.resolvers import resolve_campaigns - - backend = make_backend(tmp_path) - info = make_mock_info(backend) - result = resolve_campaigns(info, limit=10, offset=0) - assert isinstance(result, list) - - -def test_resolve_resources_count(tmp_path): - from recap.server.resolvers import resolve_resources_count - - backend = make_backend(tmp_path) - info = make_mock_info(backend) - count = resolve_resources_count(info, campaign_id=None) - assert count == 0 - - -def test_resolve_resources_enforces_max_limit(tmp_path): - import strawberry - - from recap.server.resolvers import resolve_resources - - backend = make_backend(tmp_path) - info = make_mock_info(backend) - with pytest.raises(strawberry.exceptions.StrawberryGraphQLError): - resolve_resources(info, campaign_id=None, limit=99999, offset=0) - - -def test_resolve_process_runs_returns_list(tmp_path): - from recap.server.resolvers import resolve_process_runs - - backend = make_backend(tmp_path) - info = make_mock_info(backend) - result = resolve_process_runs(info, campaign_id=None, limit=10, offset=0) - assert isinstance(result, list) - - -def test_resolve_process_runs_count(tmp_path): - from recap.server.resolvers import resolve_process_runs_count - - backend = make_backend(tmp_path) - info = make_mock_info(backend) - count = resolve_process_runs_count(info, campaign_id=None) - assert count == 0 - - -def test_resolve_process_templates_returns_list(tmp_path): - from recap.server.resolvers import resolve_process_templates - - backend = make_backend(tmp_path) - info = make_mock_info(backend) - result = resolve_process_templates(info, limit=10, offset=0) - assert isinstance(result, list) - - -def test_resolve_resource_templates_returns_list(tmp_path): - from recap.server.resolvers import resolve_resource_templates - - backend = make_backend(tmp_path) - info = make_mock_info(backend) - result = resolve_resource_templates(info, limit=10, offset=0) - assert isinstance(result, list) - - -def test_resolve_campaigns_count(tmp_path): - from recap.server.resolvers import resolve_campaigns_count - - backend = make_backend(tmp_path) - info = make_mock_info(backend) - count = resolve_campaigns_count(info) - assert count == 0 diff --git a/recap/tests/test_resource_commands.py b/recap/tests/test_resource_commands.py new file mode 100644 index 0000000..d40218a --- /dev/null +++ b/recap/tests/test_resource_commands.py @@ -0,0 +1,168 @@ +from dataclasses import replace +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +from recap.adapter.local import LocalBackend +from recap.authentication.models import ActorKind, ProviderIdentity, RequestActor +from recap.authorization.policy import UnrestrictedNamespacePolicy +from recap.authorization.scopes import Scope +from recap.commands.errors import CommandConflictError +from recap.commands.models import CommandContext, CreateResource +from recap.commands.service import CommandService +from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate +from recap.db.base import Base +from recap.db.namespace import Namespace +from recap.db.resource import Resource, ResourceTemplate +from recap.lifecycle import LifecycleStatus +from recap.server.audit import AuditOutcome + + +class AuditCollector: + def __init__(self): + self.records = [] + + def emit(self, record): + self.records.append(record) + + +@pytest.fixture +def command_setup(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'resources.db'}") + Base.metadata.create_all(engine) + factory = sessionmaker(engine, expire_on_commit=False) + actor = RequestActor( + actor_id="actor-1", + kind=ActorKind.USER, + identities=(ProviderIdentity(provider="test", subject="actor-1"),), + credential_scopes=frozenset(Scope), + namespace_restrictions=None, + credential_fingerprint="fingerprint", + ) + audit = AuditCollector() + context = CommandContext( + actor=actor, + request_id=str(uuid4()), + policy=UnrestrictedNamespacePolicy(), + audit_sink=audit, + authorization_generation=None, + idempotency_key="resource-command-1", + ) + return CommandService(factory), factory, context, audit + + +def _template(factory, path="beamline"): + with factory.begin() as session: + namespace = Namespace(path=path) + template = ResourceTemplate(name="plate", version="1", namespace=namespace) + session.add(template) + session.flush() + return namespace.path, template.id + + +def _property_template(factory, path="beamline/properties"): + with factory.begin() as session: + namespace = Namespace(path=path) + template = ResourceTemplate( + name="property-plate", version="1", namespace=namespace + ) + group = AttributeGroupTemplate(name="measurements", resource_template=template) + AttributeTemplate( + name="dose", + value_type="int", + unit="mg", + default_value=0, + attribute_group_template=group, + ) + session.add(template) + session.flush() + return namespace.path, template.id + + +def test_local_backend_execute_delegates_resource_command(command_setup): + _, factory, context, audit = command_setup + namespace_path, template_id = _template(factory) + backend = LocalBackend(factory) + + result = backend.execute( + CreateResource( + namespace_path=namespace_path, + name="sample-1", + template_id=template_id, + ), + context, + ) + + assert result.name == "sample-1" + assert result.namespace_id is not None + assert len(audit.records) == 1 + with factory() as session: + assert session.get(Resource, result.id) is not None + + +def test_resource_create_update_revision_and_frozen_patch(command_setup): + service, factory, context, audit = command_setup + path, template_id = _template(factory) + + created = service.create_resource( + context, namespace_path=path, name="plate-1", template_id=template_id + ) + updated = service.update_resource( + replace(context, idempotency_key="resource-command-2"), + resource_id=created.id, + expected_revision=1, + name="plate-2", + ) + assert updated.name == "plate-2" + assert updated.revision == 2 + + with factory.begin() as session: + session.get(Resource, created.id).activate() + + with pytest.raises(CommandConflictError, match="active resource"): + service.update_resource( + replace(context, idempotency_key="resource-command-3"), + resource_id=created.id, + expected_revision=2, + name="plate-3", + ) + with factory() as session: + assert ( + session.scalar(select(Resource).where(Resource.id == created.id)).status + is LifecycleStatus.ACTIVE + ) + assert audit.records[-1].outcome is AuditOutcome.ERROR + + +def test_resource_create_and_update_apply_complete_property_payload(command_setup): + service, _, context, _ = command_setup + path, template_id = _property_template(command_setup[1]) + created = service.create_resource( + context, + namespace_path=path, + name="property-plate-1", + template_id=template_id, + properties={ + "measurements": { + "dose": { + "value": 12, + "unit": "mg", + "metadata_json": {"source": "builder"}, + } + } + }, + ) + assert created.properties["measurements"].values["dose"].value == 12 + assert created.properties["measurements"].values["dose"].metadata_json == { + "source": "builder" + } + + updated = service.update_resource( + replace(context, idempotency_key="resource-property-update"), + resource_id=created.id, + expected_revision=1, + properties={"measurements": {"dose": {"value": 24}}}, + ) + assert updated.properties["measurements"].values["dose"].value == 24 diff --git a/recap/tests/test_resource_copy.py b/recap/tests/test_resource_copy.py new file mode 100644 index 0000000..ba65383 --- /dev/null +++ b/recap/tests/test_resource_copy.py @@ -0,0 +1,324 @@ +from copy import deepcopy +from uuid import uuid4 + +import pytest +from sqlalchemy import func, select + +from recap.commands.errors import CommandNotFoundError +from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate +from recap.db.namespace import Namespace +from recap.db.resource import Resource, ResourceTemplate +from recap.lifecycle import LifecycleStatus +from recap.schemas.resource import ResourceCopyChanges, ResourceCopyOptions + + +def _create_source(client): + suffix = uuid4().hex + source_path = f"copy-{suffix}" + destination_path = f"{source_path}/destination" + sibling_path = f"{source_path}-sibling" + prefix_path = f"{source_path}x/destination" + namespace_prefix = client.namespace_path + source_full_path = "/".join(filter(None, (namespace_prefix, source_path))) + destination_full_path = "/".join(filter(None, (namespace_prefix, destination_path))) + sibling_full_path = "/".join(filter(None, (namespace_prefix, sibling_path))) + prefix_full_path = "/".join(filter(None, (namespace_prefix, prefix_path))) + with client.connection_state.sessionmaker.begin() as session: + source_namespace = Namespace( + path=source_full_path, status=LifecycleStatus.ACTIVE + ) + destination_namespace = Namespace( + path=destination_full_path, + parent=source_namespace, + status=LifecycleStatus.ACTIVE, + ) + sibling_namespace = Namespace( + path=sibling_full_path, status=LifecycleStatus.ACTIVE + ) + prefix_namespace = Namespace( + path=prefix_full_path, status=LifecycleStatus.ACTIVE + ) + value_template = AttributeTemplate( + name="value", value_type="array", default_value="[]" + ) + group = AttributeGroupTemplate( + name="details", attribute_templates=[value_template] + ) + template = ResourceTemplate( + name=f"resource-{suffix}", + version="1", + namespace=source_namespace, + attribute_group_templates=[group], + ) + root = Resource(name="root", template=template, namespace=source_namespace) + child = Resource( + name="child", + template=template, + namespace=source_namespace, + parent=root, + ) + grandchild = Resource( + name="grandchild", + template=template, + namespace=source_namespace, + parent=child, + ) + root.properties["details"]._values["value"].value = [1, {"nested": [2]}] + root.properties["details"]._values["value"].unit = "uL" + root.properties["details"]._values["value"].metadata_json = {"tags": ["source"]} + child.properties["details"]._values["value"].value = [3] + grandchild.properties["details"]._values["value"].value = [4] + session.add_all( + [ + source_namespace, + destination_namespace, + sibling_namespace, + prefix_namespace, + template, + root, + ] + ) + session.flush() + result = { + "source_id": root.id, + "child_id": child.id, + "source_namespace_id": source_namespace.id, + "source_namespace_path": source_full_path, + "destination_namespace_id": destination_namespace.id, + "sibling_namespace_id": sibling_namespace.id, + "prefix_namespace_id": prefix_namespace.id, + "destination_path": destination_path, + "sibling_path": sibling_path, + "prefix_path": prefix_path, + } + return result + + +def _load_tree(client, root_id): + with client.connection_state.sessionmaker.begin() as session: + resources = client.connection_state.backend.writer._load_resource_subtrees( + session, [root_id] + ) + by_id = {resource.id: resource for resource in resources} + root = by_id[root_id] + # Snapshot while ORM relationships remain attached to active session. + snapshot = { + "root": root, + "resources": list(resources), + "values": { + resource.name: deepcopy( + resource.properties["details"]._values["value"].value + ) + for resource in resources + }, + "metadata": deepcopy( + root.properties["details"]._values["value"].metadata_json + ), + } + return snapshot + + +def test_copy_resource_deep_copies_full_graph_with_new_ids(client): + setup = _create_source(client) + + copied = client.namespace(setup["destination_path"]).copy_resource( + setup["source_id"] + ) + + source = _load_tree(client, setup["source_id"]) + clone = _load_tree(client, copied.id) + source_by_name = {resource.name: resource for resource in source["resources"]} + clone_by_name = {resource.name: resource for resource in clone["resources"]} + assert set(clone_by_name) == {"root", "child", "grandchild"} + for name in clone_by_name: + assert clone_by_name[name].id != source_by_name[name].id + assert ( + clone_by_name[name].properties["details"].id + != source_by_name[name].properties["details"].id + ) + clone_value = clone_by_name[name].properties["details"]._values["value"] + source_value = source_by_name[name].properties["details"]._values["value"] + assert clone_value.id != source_value.id + assert clone_value.value == source_value.value + assert clone["root"].copied_from_id == setup["source_id"] + assert all( + resource.copied_from_id is None + for resource in clone["resources"] + if resource.id != copied.id + ) + + +def test_copy_resource_attaches_full_clone_as_parent_child(client): + setup = _create_source(client) + with client.connection_state.sessionmaker.begin() as session: + namespace = session.get(Namespace, setup["source_namespace_id"]) + template = session.get(Resource, setup["source_id"]).template + group = Resource(name="group", template=template, namespace=namespace) + session.add(group) + session.flush() + group_id = group.id + + copied = client.namespace(setup["source_namespace_path"]).copy_resource( + setup["source_id"], ResourceCopyOptions(parent_id=group_id) + ) + + clone = _load_tree(client, copied.id)["root"] + assert clone.parent_id == group_id + assert clone.copied_from_id == setup["source_id"] + assert {item.name for item in _load_tree(client, copied.id)["resources"]} == { + "root", + "child", + "grandchild", + } + + +def test_copy_resource_rejects_missing_parent(client): + setup = _create_source(client) + + with pytest.raises(CommandNotFoundError, match="Parent resource not found"): + client.namespace(setup["source_namespace_path"]).copy_resource( + setup["source_id"], ResourceCopyOptions(parent_id=uuid4()) + ) + + +def test_copy_resource_rejects_parent_from_another_namespace(client): + setup = _create_source(client) + with client.connection_state.sessionmaker.begin() as session: + foreign_namespace = session.get(Namespace, setup["sibling_namespace_id"]) + template = session.get(Resource, setup["source_id"]).template + foreign_parent = Resource( + name="foreign-group", template=template, namespace=foreign_namespace + ) + session.add(foreign_parent) + session.flush() + foreign_parent_id = foreign_parent.id + + with pytest.raises(ValueError, match="Parent resource belongs to another namespace"): + client.namespace(setup["source_namespace_path"]).copy_resource( + setup["source_id"], ResourceCopyOptions(parent_id=foreign_parent_id) + ) + + +def test_copy_resource_rejects_duplicate_child_name(client): + setup = _create_source(client) + with client.connection_state.sessionmaker.begin() as session: + namespace = session.get(Namespace, setup["source_namespace_id"]) + template = session.get(Resource, setup["source_id"]).template + group = Resource(name="group", template=template, namespace=namespace) + Resource(name="root", template=template, namespace=namespace, parent=group) + session.add(group) + session.flush() + group_id = group.id + + with pytest.raises(ValueError, match="already has a child named"): + client.namespace(setup["source_namespace_path"]).copy_resource( + setup["source_id"], ResourceCopyOptions(parent_id=group_id) + ) + + +def test_copy_resource_isolates_mutable_values_and_applies_root_overrides(client): + setup = _create_source(client) + + copied = client.namespace(setup["destination_path"]).copy_resource( + setup["source_id"], + ResourceCopyOptions( + name="copy", + changes=ResourceCopyChanges(properties={"details": {"value": [9]}}), + ), + ) + + with client.connection_state.sessionmaker.begin() as session: + clone = session.get(Resource, copied.id) + copied_value = clone.properties["details"]._values["value"] + copied_value.value = [*copied_value.value, 10] + copied_value.metadata_json["tags"] = [ + *copied_value.metadata_json["tags"], + "copy", + ] + + source = _load_tree(client, setup["source_id"]) + clone = _load_tree(client, copied.id) + assert clone["root"].name == "copy" + assert clone["values"]["copy"] == [9, 10] + assert source["values"]["root"] == [1, {"nested": [2]}] + assert clone["metadata"] == {"tags": ["source", "copy"]} + assert source["metadata"] == {"tags": ["source"]} + + +def test_builder_copy_on_write_preserves_value_metadata(client): + setup = _create_source(client) + with client.connection_state.sessionmaker.begin() as session: + session.get(Resource, setup["source_id"]).activate() + client = client.namespace(setup["source_namespace_path"]) + + with client.build_resource(resource_id=setup["source_id"]) as builder: + value = builder.resource.properties["details"].values["value"] + value.unit = "mL" + value.metadata_json = {"tags": ["builder"]} + copied_id = builder.resource.id + + source = _load_tree(client, setup["source_id"])["root"] + copied = _load_tree(client, copied_id)["root"] + copied_value = copied.properties["details"]._values["value"] + assert copied_value.unit == "mL" + assert copied_value.metadata_json == {"tags": ["builder"]} + assert source.status is LifecycleStatus.ACTIVE + + +def test_copy_resource_activates_source_and_owns_clone_in_destination(client): + setup = _create_source(client) + + copied = client.namespace(setup["destination_path"]).copy_resource( + setup["source_id"] + ) + + source = _load_tree(client, setup["source_id"])["root"] + clone = _load_tree(client, copied.id) + assert source.status is LifecycleStatus.ACTIVE + assert all( + resource.namespace_id == setup["destination_namespace_id"] + for resource in clone["resources"] + ) + assert all( + resource.status is LifecycleStatus.MUTABLE and resource.revision == 1 + for resource in clone["resources"] + ) + + +@pytest.mark.parametrize("destination_key", ["sibling_path", "prefix_path"]) +def test_copy_resource_rejects_destination_outside_source_ancestry( + client, destination_key +): + setup = _create_source(client) + + with pytest.raises(ValueError, match="descendant"): + client.namespace(setup[destination_key]).copy_resource(setup["source_id"]) + + +def test_copy_resource_requires_source_graph_root(client): + setup = _create_source(client) + + with pytest.raises(ValueError, match="root"): + client.namespace(setup["destination_path"]).copy_resource(setup["child_id"]) + + +def test_invalid_copy_changes_roll_back_source_activation_and_clone(client): + setup = _create_source(client) + + with pytest.raises(ValueError, match="missing"): + client.namespace(setup["destination_path"]).copy_resource( + setup["source_id"], + ResourceCopyOptions( + changes=ResourceCopyChanges(properties={"missing": {"value": 9}}) + ), + ) + + with client.connection_state.sessionmaker.begin() as session: + source = session.get(Resource, setup["source_id"]) + clone_count = session.scalar( + select(func.count()) + .select_from(Resource) + .where(Resource.namespace_id == setup["destination_namespace_id"]) + ) + assert source.status is LifecycleStatus.MUTABLE + assert clone_count == 0 diff --git a/recap/tests/test_resource_freeze.py b/recap/tests/test_resource_freeze.py new file mode 100644 index 0000000..1465ed3 --- /dev/null +++ b/recap/tests/test_resource_freeze.py @@ -0,0 +1,62 @@ +import pytest + +from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate +from recap.db.namespace import Namespace +from recap.db.resource import Resource, ResourceTemplate +from recap.lifecycle import LifecycleStatus + + +def _resource_tree(db_session): + namespace = Namespace(path="/resource-freeze", status=LifecycleStatus.ACTIVE) + value_template = AttributeTemplate( + name="value", value_type="int", default_value="1" + ) + group = AttributeGroupTemplate( + name="properties", attribute_templates=[value_template] + ) + template = ResourceTemplate( + name="resource", + version="1", + namespace=namespace, + attribute_group_templates=[group], + ) + root = Resource(name="root", template=template, namespace=namespace) + child = Resource(name="child", template=template, namespace=namespace, parent=root) + db_session.add(root) + db_session.flush() + return root, child + + +def test_mutable_resource_allows_nested_value_update(db_session): + root, child = _resource_tree(db_session) + child.properties["properties"].values["value"] = 2 + db_session.flush() + assert child.properties["properties"].values["value"] == 2 + assert root.status is LifecycleStatus.MUTABLE + + +@pytest.mark.parametrize("target", ["root", "child", "value"]) +def test_active_resource_rejects_aggregate_mutation(db_session, target): + root, child = _resource_tree(db_session) + root.activate() + db_session.flush() + + if target == "root": + root.name = "changed-root" + elif target == "child": + child.name = "changed-child" + else: + child.properties["properties"].values["value"] = 2 + + with pytest.raises(ValueError, match="active resource"): + db_session.flush() + + +def test_active_resource_rejects_child_deletion(db_session): + root, child = _resource_tree(db_session) + root.activate() + db_session.flush() + db_session.delete(child) + + with pytest.raises(ValueError, match="active resource"): + db_session.flush() diff --git a/recap/tests/test_resource_template_commands.py b/recap/tests/test_resource_template_commands.py new file mode 100644 index 0000000..f80b556 --- /dev/null +++ b/recap/tests/test_resource_template_commands.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +from dataclasses import replace +from uuid import uuid4 + +import pytest +from pydantic import ValidationError +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +from recap.authentication.models import ActorKind, ProviderIdentity, RequestActor +from recap.authorization.policy import UnrestrictedNamespacePolicy +from recap.authorization.scopes import Scope +from recap.client.backend import ClientBackend +from recap.commands.errors import CommandConflictError, CommandNotFoundError +from recap.commands.models import ( + CommandContext, + CreateResourceTemplate, +) +from recap.commands.service import CommandService +from recap.db.audit import MutationAudit +from recap.db.base import Base +from recap.db.namespace import Namespace +from recap.db.resource import ResourceTemplate +from recap.dsl.drafts import AttributeDraft, AttributeGroupDraft, ResourceTemplateDraft +from recap.schemas.namespace import NamespaceContext + + +class AuditCollector: + def __init__(self): + self.records = [] + + def emit(self, record): + self.records.append(record) + + +@pytest.fixture +def command_setup(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'resource-template-commands.db'}") + Base.metadata.create_all(engine) + factory = sessionmaker(engine, expire_on_commit=False) + actor = RequestActor( + actor_id="actor-1", + kind=ActorKind.USER, + identities=(ProviderIdentity(provider="test", subject="actor-1"),), + credential_scopes=frozenset(Scope), + namespace_restrictions=None, + credential_fingerprint="fingerprint", + ) + with factory.begin() as session: + session.add(Namespace(path="", metadata_json={})) + audit = AuditCollector() + context = CommandContext( + actor=actor, + request_id=str(uuid4()), + policy=UnrestrictedNamespacePolicy(), + audit_sink=audit, + authorization_generation=None, + idempotency_key="template-1", + ) + service = CommandService(factory) + service.create_namespace(context, path="beamline", metadata={}) + namespace = service.create_namespace( + replace(context, idempotency_key="namespace-child-1"), + path="beamline/amx", + metadata={}, + ) + return ( + service, + factory, + replace(context, idempotency_key="resource-template-1"), + namespace, + audit, + ) + + +def template_draft(name="plate", version="1.0"): + return ResourceTemplateDraft( + name=name, + version=version, + type_names=["container", "plate"], + property_groups=[ + AttributeGroupDraft( + name="dimensions", + attributes=[AttributeDraft(name="rows", type="int", default=8)], + ) + ], + children=[ + ResourceTemplateDraft( + name="well", + version="1.0", + type_names=["container", "well"], + ) + ], + ) + + +def test_resource_template_draft_rejects_duplicate_nested_names(): + with pytest.raises(ValidationError, match="Duplicate child template name"): + ResourceTemplateDraft( + name="plate", + version="1.0", + children=[ + ResourceTemplateDraft(name="well", version="1"), + ResourceTemplateDraft(name="well", version="2"), + ], + ) + + +def test_create_persists_nested_graph_replays_and_audits(command_setup): + service, factory, context, namespace, audit = command_setup + created = service.create_resource_template( + context, namespace_path=namespace.path, draft=template_draft() + ) + replay = service.create_resource_template( + context, namespace_path=namespace.path, draft=template_draft() + ) + + assert replay.model_dump(mode="json") == created.model_dump(mode="json") + assert created.namespace_id == namespace.id + assert created.children["well"].types[1].name == "well" + assert ( + created.attribute_group_templates[0].attribute_templates[0].default_value == 8 + ) + assert len(audit.records) == 3 + with factory() as session: + assert len(session.scalars(select(ResourceTemplate)).all()) == 3 + assert ( + session.scalars(select(MutationAudit)).all()[-1].resource_type + == "resource_template" + ) + + +def test_duplicate_identity_and_frozen_update_conflict(command_setup): + service, factory, context, namespace, audit = command_setup + created = service.create_resource_template( + context, namespace_path=namespace.path, draft=template_draft() + ) + with pytest.raises(CommandConflictError, match="already exists"): + service.create_resource_template( + replace(context, idempotency_key="resource-template-2"), + namespace_path=namespace.path, + draft=template_draft(), + ) + + updated = service.update_resource_template( + replace(context, idempotency_key="resource-template-3"), + template_id=created.id, + expected_revision=1, + draft=template_draft(name="plate-updated"), + ) + assert updated.revision == 2 + with factory.begin() as session: + stored = session.get(ResourceTemplate, created.id) + stored.activate() + + with pytest.raises(CommandConflictError, match="active"): + service.update_resource_template( + replace(context, idempotency_key="resource-template-4"), + template_id=created.id, + expected_revision=2, + draft=template_draft(name="too-late"), + ) + assert audit.records[-1].outcome.value == "error" + + +def test_failed_nested_materialization_rolls_back(command_setup): + service, factory, context, namespace, _ = command_setup + with pytest.raises(CommandNotFoundError): + service.create_resource_template( + replace(context, idempotency_key="resource-template-bad"), + namespace_path="beamline/missing", + draft=template_draft(), + ) + with factory() as session: + assert session.scalars(select(ResourceTemplate)).all() == [] + + +def test_failed_update_preserves_graph_and_revision(command_setup, monkeypatch): + service, factory, context, namespace, _ = command_setup + created = service.create_resource_template( + context, namespace_path=namespace.path, draft=template_draft() + ) + + observed_revisions = [] + + def fail_materialization(session, template, draft): + observed_revisions.append( + session.scalar( + select(ResourceTemplate.revision).where( + ResourceTemplate.id == template.id + ) + ) + ) + raise RuntimeError("materialization failed") + + monkeypatch.setattr(service, "_materialize_resource_contents", fail_materialization) + with pytest.raises(RuntimeError, match="materialization failed"): + service.update_resource_template( + replace(context, idempotency_key="resource-template-failed-update"), + template_id=created.id, + expected_revision=1, + draft=template_draft(name="replacement"), + ) + assert observed_revisions == [1] + + with factory() as session: + stored = session.get(ResourceTemplate, created.id) + assert stored.revision == 1 + assert stored.name == "plate" + assert stored.children["well"].name == "well" + assert stored.attribute_group_templates[0].name == "dimensions" + + +class RecordingBackend: + def __init__(self, existing=None): + self.commands = [] + self.existing = existing + + def get_resource_template(self, *args, **kwargs): + return self.existing + + def query(self, schema, spec, *, namespace_path): + return [self.existing] if "id" in spec.filters and self.existing else [] + + def execute(self, command, context): + self.commands.append((command, context)) + return None + + def count(self, schema, spec, *, namespace_path): + return 0 + + def list_child_namespaces(self, parent_path): + return [] + + def get_namespace_context(self, path): + return NamespaceContext(id=uuid4(), path=path) + + def create_namespace(self, path, metadata, context): + return None + + def update_namespace( + self, namespace_id, expected_revision, metadata, status, context, *, etag=None + ): + return None + + +def test_builder_submits_one_complete_command(): + from recap.dsl.resource_builder import ResourceTemplateBuilder + + backend = RecordingBackend() + client_backend = ClientBackend( + reader=backend, + writer=backend, + namespaces=backend, + namespace_writer=backend, + context_resolver=backend, + ) + context = object() + with ResourceTemplateBuilder( + backend=client_backend, + namespace_context=NamespaceContext(id=uuid4(), path="beamline/amx"), + name="plate", + type_names=["plate"], + version="1.0", + command_context=context, + ) as builder: + builder.add_properties( + {"dimensions": [{"name": "rows", "type": "int", "default": 8}]} + ) + child = builder.add_child("well", ["container", "well"]) + + assert len(backend.commands) == 1 + command, submitted_context = backend.commands[0] + assert isinstance(command, CreateResourceTemplate) + assert submitted_context is context + assert command.draft.children[0].name == "well" + assert child.backend is client_backend + + +def test_builder_submits_nothing_when_body_raises(): + from recap.dsl.resource_builder import ResourceTemplateBuilder + + backend = RecordingBackend() + client_backend = ClientBackend( + reader=backend, + writer=backend, + namespaces=backend, + namespace_writer=backend, + context_resolver=backend, + ) + with ( + pytest.raises(RuntimeError, match="stop"), + ResourceTemplateBuilder( + backend=client_backend, + namespace_context=NamespaceContext(id=uuid4(), path="beamline/amx"), + name="plate", + type_names=["plate"], + version="1.0", + command_context=object(), + ), + ): + raise RuntimeError("stop") + assert backend.commands == [] diff --git a/recap/tests/test_resource_tree_perf.py b/recap/tests/test_resource_tree_perf.py index 6b8e832..7a5e954 100644 --- a/recap/tests/test_resource_tree_perf.py +++ b/recap/tests/test_resource_tree_perf.py @@ -13,16 +13,14 @@ issue the **same** number of statements. """ -from recap.dsl.resource_builder import ResourceTemplateBuilder +import pytest from .conftest import count_statements def _make_template(client, name="TreePerfT"): """A minimal single-type template with one property group.""" - with ResourceTemplateBuilder( - name=name, type_names=["container"], backend=client.backend - ) as rtb: + with client.build_resource_template(name=name, type_names=["container"]) as rtb: rtb.prop_group("details").add_attribute( "serial", "str", "", "abc" ).close_group() @@ -42,6 +40,8 @@ def _make_chain(client, depth, *, prefix): parent=parent, on_existing="create", ) + with client.build_resource(resource_id=root.id) as builder: + builder.finalize() return root @@ -55,6 +55,7 @@ def _walk_depth(resource): return n +@pytest.mark.performance def test_load_eager_resource_tree_is_depth_independent(client): """A ``load="eager"`` resource query must issue a bounded, depth-independent number of SQL statements regardless of tree depth.""" @@ -62,7 +63,7 @@ def test_load_eager_resource_tree_is_depth_independent(client): _make_chain(client, depth=3, prefix="three") _make_chain(client, depth=4, prefix="four") - qm = client.query_maker(unscoped=True) + qm = client.query_maker() with count_statements(client) as counter_3: tree_3 = qm.resources(load="eager").filter(name="three-0").first() @@ -83,6 +84,7 @@ def test_load_eager_resource_tree_is_depth_independent(client): ) +@pytest.mark.performance def test_load_eager_resource_tree_bounded_count(client): """The absolute statement count for a deep tree must be a small constant.""" _make_template(client, name="TreePerfBounded") @@ -95,8 +97,10 @@ def test_load_eager_resource_tree_bounded_count(client): parent=parent, on_existing="create", ) + with client.build_resource(resource_id=root.id) as builder: + builder.finalize() - qm = client.query_maker(unscoped=True) + qm = client.query_maker() with count_statements(client) as counter: tree = qm.resources(load="eager").filter(name="bounded-0").first() @@ -105,3 +109,33 @@ def test_load_eager_resource_tree_bounded_count(client): # template/attribute-group, not per resource). The pre-fix path issued one # lazy load per node; this asserts a depth-independent constant instead. assert counter["n"] <= 18, f"expected bounded count, got {counter['n']}" + + +@pytest.mark.performance +def test_load_eager_resource_tree_is_multi_root_bounded(client): + """Several roots must share one recursive subtree load.""" + _make_template(client, name="TreePerfRoots") + roots = [] + for index in range(4): + root = client.create_resource( + f"root-{index}", "TreePerfRoots", on_existing="create" + ) + parent = root + for level in range(1, 3): + parent = client.create_resource( + f"root-{index}-{level}", + "TreePerfRoots", + parent=parent, + on_existing="create", + ) + with client.build_resource(resource_id=root.id) as builder: + builder.finalize() + roots.append(root) + + with count_statements(client) as counter: + trees = client.query_maker().resources(load="eager").all() + + by_name = {tree.name: tree for tree in trees} + assert {tree.name for tree in trees} == {root.name for root in roots} + assert all(_walk_depth(by_name[root.name]) == 3 for root in roots) + assert counter["n"] <= 19, f"multi-root hydration exceeded batch bound: {counter['n']}" diff --git a/recap/tests/test_rest_adapter.py b/recap/tests/test_rest_adapter.py new file mode 100644 index 0000000..1414a94 --- /dev/null +++ b/recap/tests/test_rest_adapter.py @@ -0,0 +1,492 @@ +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import httpx2 +import pytest + +from recap.adapter.http_transport import HTTPResult, HTTPTransport +from recap.adapter.transport import serialize_model +from recap.client.permissions import ActorPermissions +from recap.dsl.query import QuerySpec +from recap.exceptions import ( + RecapAuthenticationError, + RecapConflictError, + RecapConnectionError, + RecapInternalError, + RecapNotFoundError, + RecapPermissionDeniedError, + RecapProtocolError, + RecapRequestError, + RecapServiceUnavailableError, + RecapValidationError, + error_from_code, +) + + +def _transport(body=None, *, etag=None, request_id=None): + transport = MagicMock(spec=HTTPTransport) + transport.request.return_value = HTTPResult(body, etag, request_id) + return transport + + +def _namespace_body(namespace_id=None): + stamp = datetime(2026, 1, 1, tzinfo=UTC).isoformat() + return { + "id": str(namespace_id or uuid4()), + "path": "beamline/amx", + "revision": 1, + "status": "ACTIVE", + "metadata": {}, + "create_date": stamp, + "modified_date": stamp, + } + + +def test_create_namespace_uses_registered_route_and_hydrates_response(): + from recap.adapter.rest import RESTAdapter + from recap.schemas.namespace import NamespaceContext + + resource = NamespaceContext(id=uuid4(), path="beamline/amx", revision=7, metadata={"beamline": "amx"}) + transport = _transport( + {**_namespace_body(resource.id), "revision": 7, "metadata": {"beamline": "amx"}}, + etag='"7"', + request_id="req-1", + ) + adapter = RESTAdapter("https://recap.test/", _transport=transport) + + result = adapter.create_namespace( + "beamline/amx", {"beamline": "amx"}, SimpleNamespace(idempotency_key="idem-1") + ) + + transport.request.assert_called_once_with( + "PUT", + "https://recap.test/api/v1/namespaces/beamline/amx", + json={"metadata": {"beamline": "amx"}}, + headers={"Idempotency-Key": "idem-1"}, + ) + assert isinstance(result, NamespaceContext) + assert result.id == resource.id + assert result.etag == '"7"' + assert result.revision == resource.revision + + +def test_api_key_constructs_default_transport(): + from recap.adapter.rest import RESTAdapter + + with patch("recap.adapter.rest.HTTPTransport") as transport_type: + adapter = RESTAdapter("https://recap.test", api_key="secret", timeout=12.5) + + transport_type.assert_called_once_with("secret", timeout=12.5) + assert adapter._transport is transport_type.return_value + + +def test_missing_api_key_is_rejected_without_transport(): + from recap.adapter.rest import RESTAdapter + + with pytest.raises(TypeError, match="api_key is required"): + RESTAdapter("https://recap.test") + + +def test_request_preserves_transport_request_id(): + from recap.adapter.rest import RESTAdapter + + adapter = RESTAdapter( + "https://recap.test", _transport=_transport({"ok": True}, request_id="request-4") + ) + + result = adapter._request("GET", "/api/v1/status") + + assert result.request_id == "request-4" + + +def test_update_and_copy_preserve_if_match_routes_and_generated_idempotency_keys(): + from recap.adapter.rest import RESTAdapter + + source = uuid4() + namespace_id = uuid4() + transport = _transport(_namespace_body()) + adapter = RESTAdapter("https://recap.test", _transport=transport) + + adapter.update_namespace( + namespace_id, 1, {"owner": "amx"}, None, SimpleNamespace(idempotency_key=None), etag='"1"' + ) + adapter.copy_resource(source, "beamline/amx", changes={"name": "copy"}) + + calls = transport.request.call_args_list + assert calls[0].args[:2] == ( + "PATCH", + f"https://recap.test/api/v1/namespaces/{namespace_id}", + ) + assert calls[0].kwargs["headers"]["If-Match"] == '"1"' + assert calls[0].kwargs["json"] == {"metadata": {"owner": "amx"}} + assert calls[0].kwargs["headers"]["Idempotency-Key"] + assert calls[1].args[:2] == ( + "POST", + f"https://recap.test/api/v1/resources/{source}/copies", + ) + assert calls[1].kwargs["json"] == { + "destination_namespace": "beamline/amx", + "name": "copy", + } + assert calls[1].kwargs["headers"]["Idempotency-Key"] + + +def test_update_namespace_preserves_explicit_empty_etag(): + from recap.adapter.rest import RESTAdapter + + transport = _transport(_namespace_body()) + RESTAdapter("https://recap.test", _transport=transport).update_namespace( + uuid4(), 1, None, None, SimpleNamespace(idempotency_key=None), etag="" + ) + + assert transport.request.call_args.kwargs["headers"]["If-Match"] == "" + + +def test_execute_hydrates_copy_resource(): + from recap.adapter.rest import RESTAdapter + from recap.commands.models import CopyResource + from recap.schemas.resource import ResourceSchema + + source = uuid4() + result = object() + transport = _transport({"resource": "payload"}) + adapter = RESTAdapter("https://recap.test", _transport=transport) + original = ResourceSchema.model_validate + ResourceSchema.model_validate = classmethod(lambda cls, value: result) + try: + returned = adapter.execute( + CopyResource(source_resource_id=source, destination_namespace_path="beamline/amx"), + SimpleNamespace(idempotency_key="idem-copy"), + ) + finally: + ResourceSchema.model_validate = original + + assert returned is result + transport.request.assert_called_once_with( + "POST", + f"https://recap.test/api/v1/resources/{source}/copies", + json={ + "destination_namespace": "beamline/amx", + "name": None, + "parent_id": None, + "changes": {"properties": {}}, + }, + headers={"Idempotency-Key": "idem-copy"}, + ) + + +def test_execute_lifecycle_uses_status_route_and_if_match_header(): + from recap.adapter.rest import RESTAdapter + from recap.commands.models import SetLifecycleStatus + from recap.schemas.resource import ResourceTemplateSchema + + command = SetLifecycleStatus( + object_type="resource_template", object_id=uuid4(), expected_revision=3, status="ACTIVE" + ) + transport = _transport({"id": str(command.object_id), "revision": 4}) + original = ResourceTemplateSchema.model_validate + ResourceTemplateSchema.model_validate = classmethod(lambda cls, value: object()) + try: + RESTAdapter("https://recap.test", _transport=transport).execute( + command, SimpleNamespace(idempotency_key="lifecycle-1") + ) + finally: + ResourceTemplateSchema.model_validate = original + + transport.request.assert_called_once_with( + "POST", + f"https://recap.test/api/v1/lifecycle/resource_template/{command.object_id}", + json={"status": "ACTIVE"}, + headers={"If-Match": '"3"', "Idempotency-Key": "lifecycle-1"}, + ) + + +def test_execute_update_resource_hydrates_dynamic_properties(): + from recap.adapter.rest import RESTAdapter + from recap.commands.models import UpdateResource + from recap.tests.transport_factories import full_resource + + resource = full_resource() + command = UpdateResource(resource_id=resource.id, expected_revision=1, name=resource.name) + transport = _transport(serialize_model(resource)) + + returned = RESTAdapter("https://recap.test", _transport=transport).execute( + command, SimpleNamespace(idempotency_key="resource-update") + ) + + assert returned.properties.measurements.values.captured_at.value == resource.properties.measurements.values.captured_at.value + assert transport.request.call_args.kwargs["headers"] == { + "If-Match": '"1"', + "Idempotency-Key": "resource-update", + } + + +def test_execute_update_resource_keeps_revision_out_of_request_body(): + from recap.adapter.rest import RESTAdapter + from recap.commands.models import UpdateResource + from recap.tests.transport_factories import minimal_resource + + resource = minimal_resource() + command = UpdateResource(resource_id=resource.id, expected_revision=1, name="renamed") + transport = _transport(serialize_model(resource)) + + RESTAdapter("https://recap.test", _transport=transport).execute( + command, SimpleNamespace(idempotency_key="resource-update") + ) + + request = transport.request.call_args + assert request.args[:2] == ( + "PATCH", + f"https://recap.test/api/v1/resources/{resource.id}", + ) + assert request.kwargs["json"] == {"name": "renamed", "properties": None} + assert "expected_revision" not in request.kwargs["json"] + + +def test_execute_update_process_run_uses_route_body_and_if_match(): + from recap.adapter.rest import RESTAdapter + from recap.commands.models import UpdateProcessRun + from recap.schemas.process import ProcessRunSchema + + command = UpdateProcessRun( + process_run_id=uuid4(), + expected_revision=1, + description="finished", + status="ACTIVE", + ) + transport = _transport({"id": str(command.process_run_id), "revision": 2}) + original = ProcessRunSchema.model_validate + ProcessRunSchema.model_validate = classmethod(lambda cls, value: object()) + try: + RESTAdapter("https://recap.test", _transport=transport).execute( + command, SimpleNamespace(idempotency_key="process-run-update") + ) + finally: + ProcessRunSchema.model_validate = original + + transport.request.assert_called_once_with( + "PATCH", + f"https://recap.test/api/v1/process-runs/{command.process_run_id}", + json={ + "description": "finished", + "status": "ACTIVE", + "assignments": None, + "steps": None, + }, + headers={"If-Match": '"1"', "Idempotency-Key": "process-run-update"}, + ) + + +def test_list_child_namespaces_uses_get_without_write_headers(): + from recap.adapter.rest import RESTAdapter + + transport = _transport(["amx", "fmx"]) + result = RESTAdapter("https://recap.test", _transport=transport).list_child_namespaces("beamline") + + transport.request.assert_called_once_with( + "GET", + "https://recap.test/api/v1/namespaces/children/beamline", + json=None, + headers={}, + ) + assert result == ["amx", "fmx"] + + +def test_read_operations_send_wire_payloads_and_no_idempotency_headers(): + from recap.adapter.rest import RESTAdapter + from recap.schemas.resource import ResourceSchema + + permissions = { + "identities": [], + "snapshot_generation": None, + "effective_scopes": ["namespace:read"], + "matched_namespace_paths": ["beamline/amx"], + "groups": [], + "roles": [], + } + transport = _transport( + {"entity": "resource", "projection": "full", "items": []} + ) + transport.request.side_effect = [ + HTTPResult({"entity": "resource", "projection": "full", "items": []}, None, None), + HTTPResult(4, None, None), + HTTPResult(permissions, None, None), + HTTPResult(_namespace_body(), '"1"', None), + ] + adapter = RESTAdapter("https://recap.test", _transport=transport) + + assert adapter.query( + ResourceSchema, QuerySpec(filters={"name": "sample"}), namespace_path="beamline/amx" + ) == [] + assert adapter.count(ResourceSchema, QuerySpec(), namespace_path="beamline/amx") == 4 + assert isinstance(adapter.permissions("beamline/amx"), ActorPermissions) + adapter.get_namespace_context("beamline/amx station") + + query_call, count_call, permissions_call, context_call = transport.request.call_args_list + assert query_call.kwargs["json"]["namespace_path"] == "beamline/amx" + assert query_call.kwargs["json"]["entity"] == "resource" + assert query_call.kwargs["json"]["projection"] == "full" + assert query_call.kwargs["json"]["spec"]["filters"] == {"name": "sample"} + assert count_call.kwargs["json"]["namespace_path"] == "beamline/amx" + assert count_call.kwargs["json"]["entity"] == "resource" + assert count_call.kwargs["json"]["spec"]["filters"] == {} + assert permissions_call.kwargs["params"] == {"namespace_path": "beamline/amx"} + assert context_call.args[:2] == ( + "GET", + "https://recap.test/api/v1/namespaces/context/beamline/amx%20station", + ) + assert context_call.kwargs["json"] is None + for call in transport.request.call_args_list: + assert "Idempotency-Key" not in call.kwargs["headers"] + + +@pytest.mark.parametrize( + ("operation", "body", "message"), + [ + ("query", None, "Malformed REST query response"), + ("count", True, "Malformed REST count response"), + ("permissions", {}, "Malformed REST permissions response"), + ("context", {}, "Malformed REST namespace context response"), + ], +) +def test_read_operations_reject_malformed_success_bodies(operation, body, message): + from recap.adapter.rest import RESTAdapter + from recap.schemas.resource import ResourceSchema + + adapter = RESTAdapter("https://recap.test", _transport=_transport(body)) + with pytest.raises(RecapProtocolError, match=message): + if operation == "query": + adapter.query(ResourceSchema, QuerySpec(), namespace_path="beamline/amx") + elif operation == "count": + adapter.count(ResourceSchema, QuerySpec(), namespace_path="beamline/amx") + elif operation == "permissions": + adapter.permissions("beamline/amx") + else: + adapter.get_namespace_context("beamline/amx") + + +def test_query_rejects_schema_mismatch_in_success_body(): + from recap.adapter.rest import RESTAdapter + from recap.schemas.resource import ResourceSchema + + transport = _transport({"entity": "process_run", "projection": "full", "items": []}) + adapter = RESTAdapter("https://recap.test", _transport=transport) + + with pytest.raises(RecapProtocolError, match="schema does not match"): + adapter.query(ResourceSchema, QuerySpec(), namespace_path="beamline/amx") + + +def test_close_delegates_to_transport(): + from recap.adapter.rest import RESTAdapter + + transport = _transport() + RESTAdapter("https://recap.test", _transport=transport).close() + + transport.close.assert_called_once_with() + + +@pytest.mark.parametrize( + ("code", "error_type"), + [ + ("authentication_required", RecapAuthenticationError), + ("permission_denied", RecapPermissionDeniedError), + ("not_found", RecapNotFoundError), + ("validation_error", RecapValidationError), + ("conflict", RecapConflictError), + ("service_unavailable", RecapServiceUnavailableError), + ("internal_error", RecapInternalError), + ("request_error", RecapRequestError), + ], +) +def test_rest_propagates_normalized_error_metadata(code, error_type): + from recap.adapter.rest import RESTAdapter + + transport = _transport() + transport.request.side_effect = error_from_code( + code, + "Safe message", + url="https://recap.test/api/v1/resources", + status_code=409, + request_id="request-7", + ) + + with pytest.raises(error_type) as caught: + RESTAdapter("https://recap.test", _transport=transport).list_child_namespaces("beamline") + + error = caught.value + assert error.code == code + assert error.message == "Safe message" + assert error.url == "https://recap.test/api/v1/resources" + assert error.status_code == 409 + assert error.request_id == "request-7" + assert "secret" not in str(error) + assert "raw internal" not in str(error) + + +@pytest.mark.parametrize( + ("status_code", "response_code", "expected_code", "error_type"), + [ + (401, "authentication_required", "authentication_required", RecapAuthenticationError), + (403, "permission_denied", "permission_denied", RecapPermissionDeniedError), + (404, "not_found", "not_found", RecapNotFoundError), + (422, "validation_error", "validation_error", RecapValidationError), + (409, "conflict", "conflict", RecapConflictError), + (503, "service_unavailable", "service_unavailable", RecapServiceUnavailableError), + (500, "internal_error", "internal_error", RecapInternalError), + (418, "future_error", "request_error", RecapRequestError), + ], +) +def test_rest_classifies_real_http_error_responses( + status_code, response_code, expected_code, error_type +): + from recap.adapter.rest import RESTAdapter + + transport = HTTPTransport("secret") + response = httpx2.Response( + status_code, + json={ + "error": { + "code": response_code, + "message": "Safe message", + "request_id": "request-8", + } + }, + headers={"X-Request-ID": "request-8"}, + request=httpx2.Request( + "GET", "https://recap.test/api/v1/namespaces/children/beamline" + ), + ) + with patch.object(transport._client, "request", return_value=response), pytest.raises( + error_type + ) as caught: + RESTAdapter("https://recap.test", _transport=transport).list_child_namespaces( + "beamline" + ) + + assert caught.value.code == expected_code + assert caught.value.message == "Safe message" + assert caught.value.status_code == status_code + assert caught.value.request_id == "request-8" + assert caught.value.url.endswith("/api/v1/namespaces/children/beamline") + assert "secret" not in str(caught.value) + assert "raw internal" not in str(caught.value) + + +def test_rest_classifies_real_transport_failure(): + from recap.adapter.rest import RESTAdapter + + transport = HTTPTransport("secret") + with patch.object( + transport._client, + "request", + side_effect=httpx2.ConnectError("secret connection failure"), + ), pytest.raises(RecapConnectionError) as caught: + RESTAdapter("https://recap.test", _transport=transport).list_child_namespaces( + "beamline" + ) + + assert caught.value.code == "connection_error" + assert caught.value.url.endswith("/api/v1/namespaces/children/beamline") + assert "secret" not in str(caught.value) diff --git a/recap/tests/test_rest_lifecycle.py b/recap/tests/test_rest_lifecycle.py new file mode 100644 index 0000000..97e3069 --- /dev/null +++ b/recap/tests/test_rest_lifecycle.py @@ -0,0 +1,89 @@ +import pytest + + +def _seed_lifecycle_entity(api_client, idempotency_headers, create_namespace, object_type): + create_namespace() + if object_type == "resource_template": + response = api_client.post( + "/api/v1/resource-templates/beamline/amx", + headers=idempotency_headers("template"), + json={"name": "plate", "version": "1", "type_names": ["plate"]}, + ) + elif object_type == "resource": + template = api_client.post( + "/api/v1/resource-templates/beamline/amx", + headers=idempotency_headers("template"), + json={"name": "plate", "version": "1", "type_names": ["plate"]}, + ) + response = api_client.post( + "/api/v1/resources/beamline/amx", + headers=idempotency_headers("resource"), + json={"name": "sample", "template_id": template.json()["id"]}, + ) + elif object_type == "process_template": + response = api_client.post( + "/api/v1/process-templates/beamline/amx", + headers=idempotency_headers("process-template"), + json={"name": "scan", "version": "1", "resource_slots": [], "steps": []}, + ) + else: + template = api_client.post( + "/api/v1/process-templates/beamline/amx", + headers=idempotency_headers("process-template"), + json={"name": "scan", "version": "1", "resource_slots": [], "steps": []}, + ) + response = api_client.post( + "/api/v1/process-runs/beamline/amx", + headers=idempotency_headers("process-run"), + json={ + "name": "run", + "description": "queued", + "template_id": template.json()["id"], + "steps": {}, + }, + ) + assert response.status_code == 201, response.text + return response + + +@pytest.mark.parametrize( + "object_type", ["resource", "resource_template", "process_template", "process_run"] +) +def test_lifecycle_route_dispatches_supported_object_types( + api_client, idempotency_headers, create_namespace, object_type +): + entity = _seed_lifecycle_entity( + api_client, idempotency_headers, create_namespace, object_type + ) + + updated = api_client.post( + f"/api/v1/lifecycle/{object_type}/{entity.json()['id']}", + headers=idempotency_headers("lifecycle", **{"If-Match": '"1"'}), + json={"status": "ACTIVE"}, + ) + + assert updated.status_code == 200, updated.text + assert updated.json()["status"] == "ACTIVE" + assert updated.headers["ETag"] == '"2"' + + replayed = api_client.post( + f"/api/v1/lifecycle/{object_type}/{entity.json()['id']}", + headers=idempotency_headers("lifecycle", **{"If-Match": '"1"'}), + json={"status": "ACTIVE"}, + ) + assert replayed.status_code == 200, replayed.text + for field in ("id", "name", "description", "version", "status", "revision"): + if field in updated.json(): + assert replayed.json()[field] == updated.json()[field] + assert replayed.json()["revision"] == updated.json()["revision"] == 2 + assert replayed.headers["ETag"] == updated.headers["ETag"] == '"2"' + + archived = api_client.post( + f"/api/v1/lifecycle/{object_type}/{entity.json()['id']}", + headers=idempotency_headers("lifecycle-archive", **{"If-Match": '"2"'}), + json={"status": "ARCHIVED"}, + ) + assert archived.status_code == 200, archived.text + assert archived.json()["status"] == "ARCHIVED" + assert archived.json()["revision"] == 3 + assert archived.headers["ETag"] == '"3"' diff --git a/recap/tests/test_rest_namespaces.py b/recap/tests/test_rest_namespaces.py new file mode 100644 index 0000000..d01b092 --- /dev/null +++ b/recap/tests/test_rest_namespaces.py @@ -0,0 +1,233 @@ +from uuid import UUID + +from recap.authentication.models import ProviderIdentity +from recap.authorization.policy import SnapshotNamespacePolicy +from recap.authorization.scopes import Scope +from recap.authorization.snapshot import ( + AuthorizationSnapshot, + GrantProvenance, + SnapshotMetadata, +) + + +def test_create_namespace_route(api_client, idempotency_headers): + parent = api_client.put( + "/api/v1/namespaces/beamline", + headers=idempotency_headers("parent-1"), + json={"metadata": {}}, + ) + assert parent.status_code == 201 + response = api_client.put( + "/api/v1/namespaces/beamline/amx", + headers=idempotency_headers("ns-1"), + json={"metadata": {"beamline": "amx"}}, + ) + + assert response.status_code == 201 + assert response.json()["path"] == "beamline/amx" + assert response.json()["metadata"] == {"beamline": "amx"} + assert response.headers["ETag"] == '"1"' + UUID(response.json()["id"]) + + +def test_patch_namespace_route_uses_if_match_and_returns_new_etag( + api_client, idempotency_headers +): + parent = api_client.put( + "/api/v1/namespaces/beamline", + headers=idempotency_headers("parent-1"), + json={"metadata": {}}, + ) + assert parent.status_code == 201 + created = api_client.put( + "/api/v1/namespaces/beamline/amx", + headers=idempotency_headers("ns-1"), + json={"metadata": {}}, + ) + + response = api_client.patch( + f"/api/v1/namespaces/{created.json()['id']}", + headers=idempotency_headers("ns-2", **{"If-Match": created.headers["ETag"]}), + json={"metadata": {"owner": "amx"}, "status": "ACTIVE"}, + ) + + assert response.status_code == 200 + assert response.json()["revision"] == 2 + assert response.json()["status"] == "ACTIVE" + assert response.headers["ETag"] == '"2"' + + +def test_replayed_put_returns_same_representation(api_client, idempotency_headers): + parent = api_client.put( + "/api/v1/namespaces/beamline", + headers=idempotency_headers("parent-1"), + json={"metadata": {}}, + ) + assert parent.status_code == 201 + request = { + "headers": idempotency_headers("ns-1"), + "json": {"metadata": {"beamline": "amx"}}, + } + first = api_client.put("/api/v1/namespaces/beamline/amx", **request) + replay = api_client.put("/api/v1/namespaces/beamline/amx", **request) + + assert replay.status_code == 201 + assert replay.json() == first.json() + assert replay.headers["ETag"] == first.headers["ETag"] + + +def test_stale_if_match_maps_to_safe_conflict(api_client, idempotency_headers): + parent = api_client.put( + "/api/v1/namespaces/beamline", + headers=idempotency_headers("parent-1"), + json={"metadata": {}}, + ) + assert parent.status_code == 201 + created = api_client.put( + "/api/v1/namespaces/beamline/amx", + headers=idempotency_headers("ns-1"), + json={"metadata": {}}, + ) + + response = api_client.patch( + f"/api/v1/namespaces/{created.json()['id']}", + headers=idempotency_headers("ns-2", **{"If-Match": '"2"'}), + json={"metadata": {"wrong": True}}, + ) + + assert response.status_code == 409 + assert response.json()["error"]["code"] == "conflict" + assert "revision" not in response.text.lower() + + +def test_missing_idempotency_key_and_if_match_are_validation_errors( + api_client, auth_header, idempotency_headers +): + put = api_client.put( + "/api/v1/namespaces/beamline/amx", + headers=auth_header, + json={"metadata": {}}, + ) + patch = api_client.patch( + "/api/v1/namespaces/00000000-0000-0000-0000-000000000000", + headers=idempotency_headers("ns-2"), + json={"metadata": {}}, + ) + + assert put.status_code == 422 + assert patch.status_code == 422 + + +def test_namespace_routes_require_authentication(api_client): + response = api_client.put( + "/api/v1/namespaces/beamline/amx", + headers={"Idempotency-Key": "ns-1"}, + json={"metadata": {}}, + ) + + assert response.status_code == 401 + + +def test_nested_namespace_requires_explicit_parent(api_client, idempotency_headers): + response = api_client.put( + "/api/v1/namespaces/beamline/amx", + headers=idempotency_headers("nested-1"), + json={"metadata": {}}, + ) + + assert response.status_code == 422 + assert response.json()["error"]["code"] == "validation_error" + message = response.json()["error"]["message"] + assert message == ( + "Parent namespace must be explicitly created before " + "creating a nested namespace." + ) + assert "beamline" not in message + + +def test_list_namespace_children_route_returns_relative_direct_children( + api_client, auth_header, idempotency_headers +): + for path, key in (("beamlines", "ns-1"), ("beamlines/amx", "ns-2"), + ("beamlines/amx/proposals", "ns-3")): + response = api_client.put( + f"/api/v1/namespaces/{path}", + headers=idempotency_headers(key), + json={"metadata": {}}, + ) + assert response.status_code == 201 + + response = api_client.get("/api/v1/namespaces/children/beamlines", headers=auth_header) + + assert response.status_code == 200 + assert response.json() == ["amx"] + + +def test_list_namespace_children_root_route_returns_top_level_namespaces( + api_client, auth_header, idempotency_headers +): + for path, key in (("beamlines", "ns-1"), ("staff", "ns-2")): + response = api_client.put( + f"/api/v1/namespaces/{path}", + headers=idempotency_headers(key), + json={"metadata": {}}, + ) + assert response.status_code == 201 + + response = api_client.get("/api/v1/namespaces/children", headers=auth_header) + + assert response.status_code == 200 + assert sorted(response.json()) == ["beamlines", "staff"] + + +def test_list_namespace_children_route_requires_authentication(api_client): + response = api_client.get("/api/v1/namespaces/children") + + assert response.status_code == 401 + + +def test_list_namespace_children_only_returns_paths_to_authorized_descendant( + api_client, auth_header, idempotency_headers +): + app = api_client.app + for path, key in ( + ("beamlines", "ns-1"), + ("beamlines/amx", "ns-2"), + ("beamlines/amx/proposals", "ns-3"), + ("beamlines/fmx", "ns-4"), + ("staff", "ns-5"), + ): + response = api_client.put( + f"/api/v1/namespaces/{path}", + headers=idempotency_headers(key), + json={"metadata": {}}, + ) + assert response.status_code == 201 + + identity = ProviderIdentity(provider="api-key", subject="single-user") + app.state.namespace_policy = SnapshotNamespacePolicy( + AuthorizationSnapshot( + metadata=SnapshotMetadata(format_version=1, source_revision="test"), + grants=frozenset( + { + GrantProvenance( + identity=identity, + namespace_path="beamlines/amx/proposals", + scope=Scope.NAMESPACE_READ, + group="scientists", + role="member", + ) + } + ), + ) + ) + + root = api_client.get("/api/v1/namespaces/children", headers=auth_header) + beamlines = api_client.get( + "/api/v1/namespaces/children/beamlines", headers=auth_header + ) + + assert root.status_code == 200 + assert root.json() == ["beamlines"] + assert beamlines.status_code == 200 + assert beamlines.json() == ["amx"] diff --git a/recap/tests/test_rest_process_runs.py b/recap/tests/test_rest_process_runs.py new file mode 100644 index 0000000..3fe767c --- /dev/null +++ b/recap/tests/test_rest_process_runs.py @@ -0,0 +1,162 @@ +from uuid import UUID + +from fastapi.testclient import TestClient + +from recap.server.app import create_app + + +def test_process_run_create_update_finalize_and_replay(tmp_path): + with TestClient(create_app(tmp_path / "runs.db", api_key="secret")) as client: + auth = {"Authorization": "Apikey secret"} + parent = client.put( + "/api/v1/namespaces/beamline", + headers={**auth, "Idempotency-Key": "parent"}, + json={}, + ) + assert parent.status_code == 201 + namespace = client.put( + "/api/v1/namespaces/beamline/amx", + headers={**auth, "Idempotency-Key": "ns"}, + json={}, + ) + assert namespace.status_code == 201 + template = client.post( + "/api/v1/process-templates/beamline/amx", + headers={**auth, "Idempotency-Key": "pt"}, + json={ + "name": "screen", + "version": "1", + "resource_slots": [], + "steps": [ + { + "name": "collect", + "parameter_groups": [ + { + "name": "settings", + "attributes": [ + { + "name": "exposure", + "type": "float", + "default": 0.1, + } + ], + } + ], + } + ], + }, + ) + assert template.status_code == 201 + + body = { + "name": "run-1", + "description": "first", + "template_id": template.json()["id"], + "steps": {"collect": {"parameters": {"settings": {"exposure": 2.5}}}}, + } + created = client.post( + "/api/v1/process-runs/beamline/amx", + headers={**auth, "Idempotency-Key": "run"}, + json=body, + ) + assert created.status_code == 201, created.text + assert UUID(created.json()["id"]) + assert "collect" in created.json()["steps"] + assert created.headers["ETag"] == '"1"' + assert ( + client.post( + "/api/v1/process-runs/beamline/amx", + headers={**auth, "Idempotency-Key": "run"}, + json=body, + ).json() + == created.json() + ) + + updated = client.patch( + f"/api/v1/process-runs/{created.json()['id']}", + headers={**auth, "Idempotency-Key": "run-update", "If-Match": '"1"'}, + json={"description": "finished", "status": "ACTIVE"}, + ) + assert updated.status_code == 200 + assert updated.request.content == b'{"description":"finished","status":"ACTIVE"}' + assert "expected_revision" not in updated.request.content.decode() + assert updated.json()["description"] == "finished" + assert updated.json()["status"] == "ACTIVE" + assert updated.headers["ETag"] == '"2"' + + empty = client.patch( + f"/api/v1/process-runs/{created.json()['id']}", + headers={**auth, "Idempotency-Key": "empty-update", "If-Match": '"2"'}, + json={}, + ) + assert empty.status_code == 422 + + +def test_process_run_update_rejects_revision_in_json(tmp_path): + with TestClient(create_app(tmp_path / "revision-body.db", api_key="secret")) as client: + auth = {"Authorization": "Apikey secret"} + client.put( + "/api/v1/namespaces/n", + headers={**auth, "Idempotency-Key": "namespace"}, + json={}, + ) + template = client.post( + "/api/v1/process-templates/n", + headers={**auth, "Idempotency-Key": "template"}, + json={"name": "pt", "version": "1", "resource_slots": [], "steps": []}, + ) + created = client.post( + "/api/v1/process-runs/n", + headers={**auth, "Idempotency-Key": "run"}, + json={ + "name": "run", + "description": "initial", + "template_id": template.json()["id"], + "steps": {}, + }, + ) + + response = client.patch( + f"/api/v1/process-runs/{created.json()['id']}", + headers={**auth, "Idempotency-Key": "update", "If-Match": '"1"'}, + json={"description": "updated", "expected_revision": 1}, + ) + + assert response.status_code == 422 + + +def test_process_run_create_rolls_back_on_bad_parameter(tmp_path): + with TestClient(create_app(tmp_path / "rollback.db", api_key="secret")) as client: + auth = {"Authorization": "Apikey secret"} + client.put( + "/api/v1/namespaces/n", headers={**auth, "Idempotency-Key": "ns"}, json={} + ) + template = client.post( + "/api/v1/process-templates/n", + headers={**auth, "Idempotency-Key": "pt"}, + json={"name": "pt", "version": "1", "resource_slots": [], "steps": []}, + ) + response = client.post( + "/api/v1/process-runs/n", + headers={**auth, "Idempotency-Key": "run"}, + json={ + "name": "bad", + "description": "bad", + "template_id": template.json()["id"], + "steps": {"missing": {"parameters": {}}}, + }, + ) + assert response.status_code == 422 + assert ( + client.post( + "/api/v1/process-runs/n", + headers={**auth, "Idempotency-Key": "run-2"}, + json={ + "name": "bad", + "description": "bad", + "template_id": template.json()["id"], + "steps": {}, + }, + ).status_code + == 201 + ) diff --git a/recap/tests/test_rest_process_templates.py b/recap/tests/test_rest_process_templates.py new file mode 100644 index 0000000..d2d3289 --- /dev/null +++ b/recap/tests/test_rest_process_templates.py @@ -0,0 +1,85 @@ +from uuid import UUID + + +def draft(labels=None): + return { + "name": "screening", + "version": "1.0", + "labels": labels or ["mx"], + "resource_slots": [ + { + "name": "sample", + "resource_type": "container", + "direction": "input", + "create_resource_type": True, + } + ], + "steps": [ + { + "name": "collect", + "role_bindings": {"source": "sample"}, + "parameter_groups": [ + { + "name": "exposure", + "attributes": [ + { + "name": "duration", + "type": "float", + "unit": "s", + "default": 0.1, + } + ], + } + ], + } + ], + } + +def test_post_and_patch_process_template_routes( + api_client, idempotency_headers, create_namespace +): + create_namespace() + created = api_client.post( + "/api/v1/process-templates/beamline/amx", + headers=idempotency_headers("template-1"), + json=draft(), + ) + + assert created.status_code == 201 + UUID(created.json()["id"]) + assert ( + created.json()["step_templates"]["collect"]["resource_slots"]["source"]["name"] + == "sample" + ) + assert created.headers["ETag"] == '"1"' + + updated = api_client.patch( + f"/api/v1/process-templates/{created.json()['id']}", + headers=idempotency_headers("template-2", **{"If-Match": created.headers["ETag"]}), + json=draft(["updated"]), + ) + assert updated.status_code == 200 + assert updated.json()["labels"] == ["updated"] + assert updated.headers["ETag"] == '"2"' + + +def test_process_template_route_replay_and_validation( + api_client, idempotency_headers, create_namespace +): + create_namespace() + request = { + "headers": idempotency_headers("template-1"), + "json": draft(), + } + first = api_client.post("/api/v1/process-templates/beamline/amx", **request) + replay = api_client.post("/api/v1/process-templates/beamline/amx", **request) + invalid = draft() + invalid["steps"][0]["role_bindings"] = {"source": "missing"} + rejected = api_client.post( + "/api/v1/process-templates/beamline/amx", + headers=idempotency_headers("template-2"), + json=invalid, + ) + + assert replay.json() == first.json() + assert rejected.status_code == 422 diff --git a/recap/tests/test_rest_query.py b/recap/tests/test_rest_query.py new file mode 100644 index 0000000..8df9985 --- /dev/null +++ b/recap/tests/test_rest_query.py @@ -0,0 +1,490 @@ +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from recap.adapter.schema_registry import QUERY_SCHEMA_KEYS, schema_for +from recap.adapter.transport import QueryRequest, QueryResult +from recap.authentication.models import ProviderIdentity +from recap.authorization.policy import SnapshotNamespacePolicy +from recap.authorization.scopes import Scope +from recap.authorization.snapshot import ( + AuthorizationSnapshot, + GrantProvenance, + SnapshotMetadata, +) +from recap.dsl.query import QuerySpec +from recap.exceptions import RecapProtocolError, RecapValidationError +from recap.schemas.namespace import NamespaceRef +from recap.schemas.resource import ResourceRef, ResourceSchema +from recap.server.query_service import QueryService + + +def test_rest_query_returns_transport_result( + integration_database_path, query_resource_tree_path, auth_header +): + from fastapi.testclient import TestClient + + from recap.server.app import create_app + + with TestClient(create_app(integration_database_path, api_key="secret")) as client: + response = client.post( + "/api/v1/query", + headers=auth_header, + json={ + "entity": "resource", + "projection": "full", + "namespace_path": query_resource_tree_path, + "spec": {"filters": {}, "limit": 1}, + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["entity"] == "resource" + assert len(body["items"]) == 1 + + +def test_rest_namespace_ref_query_hydrates_namespace_refs( + integration_database_path, query_namespace_path, auth_header +): + from fastapi.testclient import TestClient + + from recap.server.app import create_app + + with TestClient(create_app(integration_database_path, api_key="secret")) as client: + response = client.post( + "/api/v1/query", + headers=auth_header, + json={ + "entity": "namespace", + "projection": "ref", + "namespace_path": query_namespace_path, + "spec": {"filters": {}, "include_mutable": True}, + }, + ) + + assert response.status_code == 200 + item = response.json()["items"][0] + assert set(item) == {"id", "path"} + assert NamespaceRef.model_validate(item).path == query_namespace_path + + +def test_rest_count_permissions_and_namespace_context( + integration_database_path, query_namespace_path, auth_header +): + from fastapi.testclient import TestClient + + from recap.server.app import create_app + + with TestClient(create_app(integration_database_path, api_key="secret")) as client: + count = client.post( + "/api/v1/query/count", + headers=auth_header, + json={ + "entity": "namespace", + "projection": "full", + "namespace_path": query_namespace_path, + "spec": {"filters": {}, "include_mutable": True}, + }, + ) + permissions = client.get( + "/api/v1/permissions", + headers=auth_header, + params={"namespace_path": query_namespace_path}, + ) + context = client.get( + f"/api/v1/namespaces/context/{query_namespace_path}", + headers=auth_header, + ) + + assert count.status_code == 200 + assert count.json() == 1 + assert permissions.status_code == 200 + assert permissions.json()["snapshot_generation"] is None + assert permissions.json()["effective_scopes"] + assert context.status_code == 200 + assert context.json()["id"] + assert context.json()["revision"] == 1 + assert context.headers["ETag"] == '"1"' + + +@pytest.mark.parametrize( + "request_call", + [ + lambda client, headers: client.post( + "/api/v1/query", + headers=headers, + json={ + "entity": "namespace", + "projection": "full", + "namespace_path": "beamline//amx", + "spec": {}, + }, + ), + lambda client, headers: client.post( + "/api/v1/query/count", + headers=headers, + json={ + "entity": "namespace", + "projection": "full", + "namespace_path": "beamline//amx", + "spec": {}, + }, + ), + lambda client, headers: client.get( + "/api/v1/permissions", + headers=headers, + params={"namespace_path": "beamline//amx"}, + ), + lambda client, headers: client.get( + "/api/v1/namespaces/context/beamline//amx", + headers=headers, + ), + ], + ids=("query", "count", "permissions", "context"), +) +def test_rest_namespace_routes_reject_noncanonical_paths(api_client, auth_header, request_call): + response = request_call(api_client, auth_header) + + assert response.status_code == 422 + assert response.json()["error"]["code"] == "validation_error" + assert response.json()["error"]["message"] == "Request validation failed" + + +def test_rest_namespace_context_conceals_undiscoverable_namespace( + api_client, auth_header, idempotency_headers +): + created = api_client.put( + "/api/v1/namespaces/private", + headers=idempotency_headers("private-1"), + json={"metadata": {}}, + ) + assert created.status_code == 201 + + identity = ProviderIdentity(provider="api-key", subject="single-user") + api_client.app.state.namespace_policy = SnapshotNamespacePolicy( + AuthorizationSnapshot( + metadata=SnapshotMetadata(format_version=1, source_revision="test"), + grants=frozenset( + { + GrantProvenance( + identity=identity, + namespace_path="public", + scope=Scope.NAMESPACE_READ, + group="scientists", + role="member", + ) + } + ), + ) + ) + + response = api_client.get( + "/api/v1/namespaces/context/private", + headers={**auth_header, "X-Request-ID": "caller-id"}, + ) + + assert response.status_code == 404 + assert response.json()["error"]["code"] == "not_found" + assert response.headers["X-Request-ID"] != "caller-id" + assert response.headers["X-Request-ID"] == response.json()["error"]["request_id"] + assert "private" not in response.text + + +def test_rest_query_rejects_unknown_entity_and_invalid_spec(api_client, auth_header): + for payload in ( + { + "entity": "unknown", + "projection": "full", + "namespace_path": "test", + "spec": {}, + }, + { + "entity": "namespace", + "projection": "full", + "namespace_path": "test", + "spec": {"unknown": True}, + }, + ): + response = api_client.post("/api/v1/query", headers=auth_header, json=payload) + assert response.status_code == 422 + assert response.json()["error"]["code"] == "validation_error" + + +@pytest.mark.parametrize( + "spec", + [ + {"filters": {"missing": "value"}}, + {"filters": {"template__missing": "value"}}, + {"predicates": [{"field": "missing", "op": "eq", "value": "value"}]}, + {"predicates": [{"field": "template.missing", "op": "eq", "value": "value"}]}, + {"orderings": [{"field": "missing", "direction": "asc"}]}, + {"orderings": [{"field": "template.missing", "direction": "desc"}]}, + ], +) +def test_rest_query_rejects_malformed_query_paths( + api_client, auth_header, create_namespace, spec +): + create_namespace("test") + response = api_client.post( + "/api/v1/query", + headers=auth_header, + json={ + "entity": "resource", + "projection": "full", + "namespace_path": "test", + "spec": spec, + }, + ) + + assert response.status_code == 422 + assert response.json()["error"]["code"] == "validation_error" + + +def test_rest_query_requires_authentication(api_client): + response = api_client.post( + "/api/v1/query", + json={ + "entity": "namespace", + "projection": "full", + "namespace_path": "test", + "spec": {}, + }, + ) + + assert response.status_code == 401 + assert response.json()["error"]["code"] == "authentication_required" + assert response.headers["X-Request-ID"] == response.json()["error"]["request_id"] + + +@pytest.mark.parametrize( + "request_call", + [ + lambda client: client.post( + "/api/v1/query/count", + json={ + "entity": "namespace", + "projection": "full", + "namespace_path": "test", + "spec": {}, + }, + ), + lambda client: client.get("/api/v1/permissions", params={"namespace_path": "test"}), + lambda client: client.get("/api/v1/namespaces/context/test"), + ], + ids=("count", "permissions", "namespace-context"), +) +def test_rest_security_routes_require_authentication_and_request_id(request_call, api_client): + response = request_call(api_client) + + assert response.status_code == 401 + assert response.json()["error"]["code"] == "authentication_required" + assert response.headers["X-Request-ID"] == response.json()["error"]["request_id"] + + +@pytest.mark.parametrize( + "request_call", + [ + lambda client, headers: client.post( + "/api/v1/query/count", + headers=headers, + json={ + "entity": "namespace", + "projection": "full", + "namespace_path": "missing", + "spec": {}, + }, + ), + lambda client, headers: client.get( + "/api/v1/permissions", + headers=headers, + params={"namespace_path": "missing"}, + ), + lambda client, headers: client.get( + "/api/v1/namespaces/context/missing", headers=headers + ), + ], + ids=("count", "permissions", "namespace-context"), +) +def test_rest_security_routes_replace_caller_request_id(request_call, api_client, auth_header): + response = request_call(api_client, {**auth_header, "X-Request-ID": "caller-id"}) + + assert response.headers["X-Request-ID"] != "caller-id" + if response.status_code != 200: + assert response.headers["X-Request-ID"] == response.json()["error"]["request_id"] + + +def test_rest_query_request_id_is_server_generated(api_client, auth_header): + response = api_client.post( + "/api/v1/query", + headers={**auth_header, "X-Request-ID": "caller-id"}, + json={ + "entity": "namespace", + "projection": "full", + "namespace_path": "test", + "spec": {}, + }, + ) + + assert response.status_code == 404 + assert response.headers["X-Request-ID"] != "caller-id" + assert response.headers["X-Request-ID"] == response.json()["error"]["request_id"] + + +def test_query_request_uses_stable_entity_key(): + request = QueryRequest.from_query( + ResourceSchema, + QuerySpec(filters={"name": "sample"}), + namespace_path="beamline/amx", + ) + + assert request.entity == "resource" + assert request.projection == "full" + assert request.namespace_path == "beamline/amx" + assert request.spec["filters"] == {"name": "sample"} + + +def test_canonical_alias_query_uses_stable_entity_and_projection(): + request = QueryRequest.from_query( + ResourceRef, QuerySpec(), namespace_path="beamline/amx" + ) + + assert request.model_dump(mode="json") == { + "entity": "resource", + "projection": "full", + "namespace_path": "beamline/amx", + "spec": request.spec, + } + assert "schema_name" not in request.model_dump() + + +@pytest.mark.parametrize( + "payload", + [ + {"entity": "unknown", "projection": "full", "namespace_path": "x", "spec": {}}, + {"entity": "resource", "projection": "other", "namespace_path": "x", "spec": {}}, + ], +) +def test_query_request_rejects_unknown_wire_keys(payload): + with pytest.raises(ValidationError): + QueryRequest.model_validate(payload) + + +@pytest.mark.parametrize( + "payload", + [ + {"filters": {}, "unknown": True}, + {"predicates": [{"field": "name", "op": "eq", "value": "x", "unknown": True}]}, + {"orderings": [{"field": "name", "direction": "asc", "unknown": True}]}, + ], +) +def test_query_spec_rejects_unknown_wire_keys(payload): + with pytest.raises(ValidationError): + QuerySpec.model_validate(payload) + + +def test_query_result_rejects_malformed_items_and_schema_mismatch(): + from recap.adapter.transport import hydrate_result + + with pytest.raises(ValidationError): + QueryResult.model_validate( + {"entity": "resource", "projection": "full", "items": ["not an object"]} + ) + + result = QueryResult(entity="process_run", projection="full", items=[]) + with pytest.raises(RecapProtocolError): + hydrate_result(ResourceSchema, result) + + +def test_stable_query_result_requires_projection(): + with pytest.raises(ValidationError): + QueryResult.model_validate({"entity": "resource", "items": []}) + + +def test_query_result_rejects_malformed_entity_with_legacy_schema_name(): + with pytest.raises(ValidationError): + QueryResult.model_validate( + { + "entity": "unknown", + "projection": "full", + "schema_name": "ResourceSchema", + "items": [], + } + ) + + +def test_legacy_query_result_schema_name_defaults_projection(): + result = QueryResult.model_validate({"schema_name": "ResourceRef", "items": []}) + + assert result.entity == "resource" + assert result.projection == "ref" + assert result.projection == "ref" + + +def test_schema_registry_keeps_full_and_ref_projections_distinct(): + assert schema_for("resource", "full") is ResourceSchema + assert schema_for("resource", "ref") is ResourceRef + assert QUERY_SCHEMA_KEYS[("resource", "full")] is ResourceSchema + assert QUERY_SCHEMA_KEYS[("resource", "ref")] is ResourceRef + + +def test_query_service_validates_and_executes_authorized_query(): + from unittest.mock import Mock + + backend = Mock() + backend.query_authorized.return_value = [] + policy = Mock() + actor = object() + request = QueryRequest( + entity="resource", + projection="full", + namespace_path="beamline/amx", + spec={"filters": {"id": str(uuid4())}, "include_mutable": True}, + ) + + QueryService(backend).query(request, actor=actor, policy=policy) + + backend.query_authorized.assert_called_once() + assert backend.query_authorized.call_args.args[0] is ResourceSchema + + +def test_query_service_rejects_invalid_spec(): + from unittest.mock import Mock + + request = QueryRequest( + entity="resource", projection="full", namespace_path="beamline", spec={"unknown": True} + ) + + with pytest.raises(RecapValidationError): + QueryService(Mock()).query(request, actor=object(), policy=Mock()) + + +def test_query_service_rejects_backend_query_validation_before_execution(): + from unittest.mock import Mock + + backend = Mock() + backend.validate_query.side_effect = ValueError("invalid field path") + request = QueryRequest( + entity="resource", + projection="full", + namespace_path="beamline", + spec={}, + ) + + with pytest.raises(RecapValidationError): + QueryService(backend).query(request, actor=object(), policy=Mock()) + + backend.query.assert_not_called() + backend.query_authorized.assert_not_called() + + +def test_query_service_rejects_empty_body_and_non_integer_count(): + from recap.server.query_service import QueryService + + service = QueryService(SimpleNamespace()) + with pytest.raises(RecapProtocolError): + service.parse_result(None) + with pytest.raises(RecapProtocolError): + service.parse_count(True) diff --git a/recap/tests/test_rest_resource_templates.py b/recap/tests/test_rest_resource_templates.py new file mode 100644 index 0000000..6454101 --- /dev/null +++ b/recap/tests/test_rest_resource_templates.py @@ -0,0 +1,53 @@ +def draft(name="plate", version="1.0"): + return { + "name": name, + "version": version, + "type_names": ["container", "plate"], + "property_groups": [ + { + "name": "dimensions", + "attributes": [{"name": "rows", "type": "int", "default": 8}], + } + ], + "children": [{"name": "well", "version": "1.0", "type_names": ["well"]}], + } + +def test_post_and_patch_resource_template_aggregate( + api_client, idempotency_headers, create_namespace +): + create_namespace() + created = api_client.post( + "/api/v1/resource-templates/beamline/amx", + headers=idempotency_headers("template-1"), + json=draft(), + ) + assert created.status_code == 201 + assert created.json()["children"]["well"]["types"][0]["name"] == "well" + assert created.headers["ETag"] == '"1"' + + updated = api_client.patch( + f"/api/v1/resource-templates/{created.json()['id']}", + headers=idempotency_headers("template-2", **{"If-Match": created.headers["ETag"]}), + json=draft("plate-updated"), + ) + assert updated.status_code == 200 + assert updated.json()["name"] == "plate-updated" + assert updated.headers["ETag"] == '"2"' + + +def test_resource_template_route_replays_and_has_no_granular_child_route( + api_client, idempotency_headers, create_namespace +): + create_namespace() + request = {"headers": idempotency_headers("template-1"), "json": draft()} + first = api_client.post("/api/v1/resource-templates/beamline/amx", **request) + replay = api_client.post("/api/v1/resource-templates/beamline/amx", **request) + assert replay.json() == first.json() + assert ( + api_client.post( + f"/api/v1/resource-templates/{first.json()['id']}/children", + headers=idempotency_headers("child-1"), + json={}, + ).status_code + == 422 + ) diff --git a/recap/tests/test_rest_resources.py b/recap/tests/test_rest_resources.py new file mode 100644 index 0000000..9fe0aa7 --- /dev/null +++ b/recap/tests/test_rest_resources.py @@ -0,0 +1,210 @@ +from dataclasses import replace + +from recap.commands.registry import CommandRegistration +from recap.server.rest import command_registration, router + + +def test_resource_routes_are_canonical(api_client): + paths = {route.path for route in router.routes} + assert "/api/v1/resources/{namespace_path:path}" in paths + assert "/api/v1/resources/{resource_id}" in paths + assert "/api/v1/resources/{source_resource_id}/copies" in paths + + +def test_registry_dependency_resolves_named_command(): + resolved = command_registration("update_resource")() + + assert isinstance(resolved, CommandRegistration) + assert resolved.name == "update_resource" + + +def test_create_and_patch_resource(api_client, idempotency_headers, monkeypatch): + namespace = api_client.put( + "/api/v1/namespaces/beamline", + headers=idempotency_headers("namespace-1"), + json={"metadata": {}}, + ) + assert namespace.status_code == 201 + template = api_client.post( + "/api/v1/resource-templates/beamline", + headers=idempotency_headers("template-1"), + json={ + "name": "plate", + "version": "1", + "type_names": [], + "property_groups": [], + "children": [], + }, + ) + assert template.status_code == 201 + resource = api_client.post( + "/api/v1/resources/beamline", + headers=idempotency_headers("resource-1"), + json={"name": "plate-1", "template_id": template.json()["id"]}, + ) + assert resource.status_code == 201 + assert resource.json()["name"] == "plate-1" + from recap.commands.registry import CommandRegistry + + decoded = [] + original_by_name = CommandRegistry.by_name + + def by_name(registry, name): + registration = original_by_name(registry, name) + if name == "update_resource": + decoder = registration.decode_command + + def decode(path_params, headers, body): + command = decoder(path_params, headers, body) + decoded.append(command) + return command + + return replace(registration, decode_command=decode) + return registration + + monkeypatch.setattr(CommandRegistry, "by_name", by_name) + updated = api_client.patch( + f"/api/v1/resources/{resource.json()['id']}", + headers=idempotency_headers("resource-2", **{"If-Match": resource.headers["ETag"]}), + json={"name": "plate-2"}, + ) + assert updated.status_code == 200 + assert updated.json()["name"] == "plate-2" + assert updated.request.content == b'{"name":"plate-2"}' + assert "expected_revision" not in updated.request.content.decode() + assert updated.headers["ETag"] == '"2"' + assert str(decoded[0].resource_id) == resource.json()["id"] + assert decoded[0].expected_revision == 1 + + +def test_create_resource_replays_sequentially_with_same_idempotency_key( + api_client, idempotency_headers +): + namespace = api_client.put( + "/api/v1/namespaces/beamline", + headers=idempotency_headers("namespace-replay"), + json={"metadata": {}}, + ) + assert namespace.status_code == 201 + template = api_client.post( + "/api/v1/resource-templates/beamline", + headers=idempotency_headers("template-replay"), + json={"name": "plate", "version": "1", "type_names": []}, + ) + assert template.status_code == 201 + + headers = idempotency_headers("resource-replay") + payload = {"name": "plate-1", "template_id": template.json()["id"]} + first = api_client.post("/api/v1/resources/beamline", headers=headers, json=payload) + replay = api_client.post("/api/v1/resources/beamline", headers=headers, json=payload) + + assert first.status_code == replay.status_code == 201 + first_body = first.json() + replay_body = replay.json() + assert first_body["id"] == replay_body["id"] + assert first_body["name"] == replay_body["name"] == "plate-1" + assert first_body["revision"] == replay_body["revision"] == 1 + assert first_body["template"]["id"] == replay_body["template"]["id"] + + +def test_create_resource_response_preserves_dynamic_properties( + api_client, idempotency_headers, create_namespace +): + create_namespace("beamline") + template = api_client.post( + "/api/v1/resource-templates/beamline", + headers=idempotency_headers("template-properties"), + json={ + "name": "sample", + "version": "1", + "type_names": [], + "property_groups": [ + { + "name": "measurements", + "attributes": [{"name": "mass", "type": "float", "default": 0.0}], + } + ], + }, + ) + assert template.status_code == 201 + + request = { + "headers": idempotency_headers("resource-properties"), + "json": { + "name": "sample-1", + "template_id": template.json()["id"], + "properties": {"measurements": {"mass": 2.5}}, + }, + } + first = api_client.post("/api/v1/resources/beamline", **request) + assert first.status_code == 201 + assert first.json()["properties"]["measurements"]["values"]["mass"]["value"] == 2.5 + + +def test_copy_resource_uses_destination_namespace_in_body(api_client, idempotency_headers): + namespace = api_client.put( + "/api/v1/namespaces/beamline", + headers=idempotency_headers("namespace-copy"), + json={"metadata": {}}, + ) + assert namespace.status_code == 201 + template = api_client.post( + "/api/v1/resource-templates/beamline", + headers=idempotency_headers("template-copy"), + json={"name": "plate", "version": "1", "type_names": []}, + ) + resource = api_client.post( + "/api/v1/resources/beamline", + headers=idempotency_headers("resource-copy"), + json={"name": "plate-1", "template_id": template.json()["id"]}, + ) + + copied = api_client.post( + f"/api/v1/resources/{resource.json()['id']}/copies", + headers=idempotency_headers("copy-1"), + json={"destination_namespace": "beamline", "name": "plate-copy"}, + ) + + assert copied.status_code == 201 + assert copied.json()["name"] == "plate-copy" + + +def test_copy_resource_accepts_parent_id(api_client, idempotency_headers): + api_client.put( + "/api/v1/namespaces/beamline", + headers=idempotency_headers("copy-child-namespace"), + json={"metadata": {}}, + ) + template = api_client.post( + "/api/v1/resource-templates/beamline", + headers=idempotency_headers("copy-child-template"), + json={"name": "plate", "version": "1", "type_names": []}, + ) + parent = api_client.post( + "/api/v1/resources/beamline", + headers=idempotency_headers("copy-child-parent"), + json={"name": "group", "template_id": template.json()["id"]}, + ) + source = api_client.post( + "/api/v1/resources/beamline", + headers=idempotency_headers("copy-child-source"), + json={"name": "source", "template_id": template.json()["id"]}, + ) + copied = api_client.post( + f"/api/v1/resources/{source.json()['id']}/copies", + headers=idempotency_headers("copy-as-child"), + json={"destination_namespace": "beamline", "parent_id": parent.json()["id"]}, + ) + + assert copied.status_code == 201 + assert copied.json()["copied_from_id"] == source.json()["id"] + + +def test_copy_resource_requires_destination_namespace(api_client, idempotency_headers): + response = api_client.post( + "/api/v1/resources/00000000-0000-0000-0000-000000000000/copies", + headers=idempotency_headers("copy-missing-destination"), + json={}, + ) + + assert response.status_code == 422 diff --git a/recap/tests/test_revisions.py b/recap/tests/test_revisions.py new file mode 100644 index 0000000..630e18c --- /dev/null +++ b/recap/tests/test_revisions.py @@ -0,0 +1,92 @@ +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from recap.commands.errors import CommandConflictError +from recap.db.base import Base, compare_and_swap_revision +from recap.db.namespace import Namespace + + +def test_create_starts_at_revision_one_and_patch_increments_once(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + session_factory = sessionmaker(engine, expire_on_commit=False) + + with session_factory.begin() as session: + namespace = Namespace(path=f"test/{uuid4()}", metadata_json={}) + session.add(namespace) + session.flush() + namespace_id = namespace.id + assert namespace.revision == 1 + + revision = compare_and_swap_revision( + session, + Namespace, + namespace_id, + expected_revision=1, + values={"metadata_json": {"owner": "beamline"}}, + ) + + assert revision == 2 + with session_factory() as session: + namespace = session.get(Namespace, namespace_id) + assert namespace.revision == 2 + assert namespace.metadata_json == {"owner": "beamline"} + + +def test_stale_expected_revision_fails_without_mutation(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + session_factory = sessionmaker(engine, expire_on_commit=False) + + with session_factory.begin() as session: + namespace = Namespace(path=f"test/{uuid4()}", metadata_json={}) + session.add(namespace) + session.flush() + namespace_id = namespace.id + + with pytest.raises(CommandConflictError, match="revision"): + compare_and_swap_revision( + session, + Namespace, + namespace_id, + expected_revision=2, + values={"metadata_json": {"owner": "wrong"}}, + ) + + with session_factory() as session: + namespace = session.get(Namespace, namespace_id) + assert namespace.revision == 1 + assert namespace.metadata_json == {} + + +def test_rollback_leaves_revision_and_values_unchanged(): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + session_factory = sessionmaker(engine, expire_on_commit=False) + + with session_factory.begin() as session: + namespace = Namespace(path=f"test/{uuid4()}", metadata_json={}) + session.add(namespace) + session.flush() + namespace_id = namespace.id + + with ( + pytest.raises(RuntimeError, match="later failure"), + session_factory.begin() as session, + ): + compare_and_swap_revision( + session, + Namespace, + namespace_id, + expected_revision=1, + values={"metadata_json": {"owner": "rolled-back"}}, + ) + raise RuntimeError("later failure") + + with session_factory() as session: + namespace = session.get(Namespace, namespace_id) + assert namespace.revision == 1 + assert namespace.metadata_json == {} diff --git a/recap/tests/test_schema_registry.py b/recap/tests/test_schema_registry.py new file mode 100644 index 0000000..c8dc068 --- /dev/null +++ b/recap/tests/test_schema_registry.py @@ -0,0 +1,74 @@ +import pytest +from pydantic import BaseModel + +from recap.adapter.schema_registry import ( + SCHEMA_REGISTRY, + SchemaRegistration, + SchemaRegistry, +) +from recap.db.namespace import Namespace +from recap.db.process import ProcessRun, ProcessTemplate +from recap.db.resource import Resource, ResourceTemplate +from recap.schemas.namespace import NamespaceSchema +from recap.schemas.process import ProcessRunSchema, ProcessTemplateSchema +from recap.schemas.resource import ResourceSchema, ResourceTemplateSchema + + +def test_public_query_registrations_are_complete(): + expected = { + "namespace": (NamespaceSchema, Namespace), + "resource_template": (ResourceTemplateSchema, ResourceTemplate), + "resource": (ResourceSchema, Resource), + "process_template": (ProcessTemplateSchema, ProcessTemplate), + "process_run": (ProcessRunSchema, ProcessRun), + } + + SCHEMA_REGISTRY.validate_complete() + + assert set(SCHEMA_REGISTRY.keys()) == set(expected) + for key, (model, orm_model) in expected.items(): + registration = SCHEMA_REGISTRY.by_key(key) + assert registration.model is model + assert registration.orm_model is orm_model + assert registration.hydrator is not None + if key != "namespace": + assert registration.loader_capabilities + assert SCHEMA_REGISTRY.by_model(model) is registration + assert {"full", "ref"} <= set(registration.projections) + + +def test_registry_rejects_duplicate_keys_and_models(): + class First(BaseModel): + value: str + + class Second(BaseModel): + value: str + + registration = SchemaRegistration( + key="first", + model=First, + orm_model=Namespace, + hydrator=First.model_validate, + loader_capabilities=(), + ) + with pytest.raises(ValueError, match="duplicate schema key"): + SchemaRegistry((registration, registration)) + + duplicate_model = SchemaRegistration( + key="second", + model=First, + orm_model=Namespace, + hydrator=First.model_validate, + loader_capabilities=(), + ) + with pytest.raises(ValueError, match="duplicate schema model"): + SchemaRegistry((registration, duplicate_model)) + + assert Second is not First + + +def test_registry_rejects_unknown_keys_and_models(): + with pytest.raises(KeyError, match="unknown query schema key"): + SCHEMA_REGISTRY.by_key("missing") + with pytest.raises(KeyError, match="unknown query schema model"): + SCHEMA_REGISTRY.by_model(BaseModel) diff --git a/recap/tests/test_server_config.py b/recap/tests/test_server_config.py index 7b55220..f78299a 100644 --- a/recap/tests/test_server_config.py +++ b/recap/tests/test_server_config.py @@ -2,16 +2,22 @@ import pytest import yaml +from pydantic import SecretStr, ValidationError def test_server_config_defaults(): from recap.server.config import ServerConfig - cfg = ServerConfig(db_path="/tmp/test.db") + cfg = ServerConfig(db_path="/tmp/test.db", api_key="test-secret") assert cfg.host == "127.0.0.1" assert cfg.port == 8000 assert cfg.log_level == "info" assert cfg.db_path == Path("/tmp/test.db") + assert cfg.authentication_mode == "single-user" + assert cfg.api_key == SecretStr("test-secret") + assert cfg.entitlement_snapshot_path is None + assert cfg.entitlement_snapshot_max_age_seconds == 300 + assert cfg.audit_log_path == Path("recap-audit.jsonl") def test_server_config_from_yaml(tmp_path): @@ -19,7 +25,15 @@ def test_server_config_from_yaml(tmp_path): config_file = tmp_path / "recap-server.yaml" config_file.write_text( - yaml.dump({"server": {"db_path": str(tmp_path / "recap.db"), "port": 9000}}) + yaml.dump( + { + "server": { + "db_path": str(tmp_path / "recap.db"), + "port": 9000, + "api_key": "yaml-secret", + } + } + ) ) cfg = ServerConfig.from_yaml(config_file) assert cfg.port == 9000 @@ -42,6 +56,52 @@ def test_server_config_env_override(tmp_path, monkeypatch): monkeypatch.setenv("RECAP_PORT", "7777") monkeypatch.setenv("RECAP_DB_PATH", str(tmp_path / "env.db")) + monkeypatch.setenv("RECAP_API_KEY", "env-secret") cfg = ServerConfig() assert cfg.port == 7777 assert cfg.db_path == tmp_path / "env.db" + + +def test_single_user_mode_requires_api_key(): + from recap.server.config import ServerConfig + + with pytest.raises(ValidationError, match="API key is required"): + ServerConfig(db_path="/tmp/test.db") + + +def test_multi_user_mode_requires_entitlement_snapshot(): + from recap.server.config import ServerConfig + + with pytest.raises(ValidationError, match="snapshot path is required"): + ServerConfig(db_path="/tmp/test.db", authentication_mode="multi-user") + + +def test_multi_user_mode_accepts_snapshot_without_api_key(tmp_path): + from recap.server.config import ServerConfig + + cfg = ServerConfig( + db_path=tmp_path / "test.db", + authentication_mode="multi-user", + entitlement_snapshot_path=tmp_path / "entitlements.json", + ) + + assert cfg.api_key is None + + +def test_server_config_repr_redacts_api_key(): + from recap.server.config import ServerConfig + + cfg = ServerConfig(db_path="/tmp/test.db", api_key="never-print-this") + + assert "never-print-this" not in repr(cfg) + + +def test_entitlement_snapshot_max_age_must_be_positive(): + from recap.server.config import ServerConfig + + with pytest.raises(ValidationError, match="maximum age must be positive"): + ServerConfig( + db_path="/tmp/test.db", + api_key="test-secret", + entitlement_snapshot_max_age_seconds=0, + ) diff --git a/recap/tests/test_set_campaign_perf.py b/recap/tests/test_set_campaign_perf.py deleted file mode 100644 index 0f8ea97..0000000 --- a/recap/tests/test_set_campaign_perf.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Performance regression tests for ``RecapClient.set_campaign``. - -See ``RECAP_QUERY_OPTIMIZATION.md`` Step C: ``set_campaign`` must short-circuit -when the requested campaign is already active, avoiding a redundant DB -round-trip (transaction open + ``SELECT Campaign`` + commit). -""" - -from recap.client.base_client import RecapClient - -from .conftest import count_statements - - -def test_set_campaign_same_id_issues_no_sql(apply_migrations, db_url): - """Re-activating the already-active campaign must not touch the DB.""" - with RecapClient(url=db_url) as client: - client.create_campaign("perf-camp", "perf-prop") - active_id = client._campaign.id - - with count_statements(client) as counter: - client.set_campaign(active_id) - - assert counter["n"] == 0 - assert client._campaign.id == active_id - - -def test_set_campaign_different_id_still_queries(apply_migrations, db_url): - """Switching to a different campaign must still load it from the DB.""" - with RecapClient(url=db_url) as client: - client.create_campaign("camp-a", "prop-a") - id_a = client._campaign.id - client.create_campaign("camp-b", "prop-b") # B is now active - - # Active campaign is B; switching to A must run SQL. - with count_statements(client) as counter: - client.set_campaign(id_a) - - assert counter["n"] > 0 - assert client._campaign.id == id_a - - -def test_set_campaign_via_schema_short_circuits(apply_migrations, db_url): - """Passing the active campaign as a schema also short-circuits.""" - with RecapClient(url=db_url) as client: - client.create_campaign("schema-camp", "schema-prop") - active = client._campaign - - with count_statements(client) as counter: - client.set_campaign(campaign=active) - - assert counter["n"] == 0 - assert client._campaign.id == active.id - - -def test_set_campaign_force_reloads_issues_sql(apply_migrations, db_url): - """``force=True`` must re-query the active campaign (escape hatch for - out-of-band edits), even though the short-circuit would otherwise skip - the round-trip.""" - with RecapClient(url=db_url) as client: - client.create_campaign("force-camp", "force-prop") - active_id = client._campaign.id - - with count_statements(client) as counter: - client.set_campaign(active_id, force=True) - - assert counter["n"] > 0 - assert client._campaign.id == active_id diff --git a/recap/tests/test_strawberry_types.py b/recap/tests/test_strawberry_types.py deleted file mode 100644 index 0585236..0000000 --- a/recap/tests/test_strawberry_types.py +++ /dev/null @@ -1,12 +0,0 @@ -def test_strawberry_types_importable(): - from recap.server.strawberry_types import ( - CampaignType, - ) - - assert CampaignType is not None - - -def test_build_schema_importable(): - from recap.server.strawberry_schema import build_schema - - assert callable(build_schema) diff --git a/recap/tests/test_template_guards.py b/recap/tests/test_template_guards.py index 999aecd..8ef5866 100644 --- a/recap/tests/test_template_guards.py +++ b/recap/tests/test_template_guards.py @@ -1,11 +1,15 @@ import pytest +from recap.db.attribute import AttributeGroupTemplate, AttributeTemplate from recap.db.process import ProcessTemplate -from recap.db.resource import ResourceTemplate +from recap.db.resource import Resource, ResourceTemplate +from recap.db.step import StepTemplate +from recap.lifecycle import LifecycleStatus from recap.utils.general import Direction def test_resource_template_guard_prevents_updates_when_resources_exist(client): + client.create_namespace("resource-guard") with client.build_resource_template( name="GuardedTemplate", type_names=["sample"], version="1.0" ): @@ -14,45 +18,33 @@ def test_resource_template_guard_prevents_updates_when_resources_exist(client): with client.build_resource("Res1", "GuardedTemplate") as rb: resource = rb.resource - tmpl_schema = ( - client.query_maker() - .resource_templates() - .filter(name="GuardedTemplate", version="1.0") - .first() - ) - - uow = client.backend.begin() - try: - tmpl_model = client.backend.session.get(ResourceTemplate, tmpl_schema.id) + with client.connection_state.sessionmaker.begin() as session: + tmpl_model = ( + session.query(ResourceTemplate) + .filter_by(name="GuardedTemplate", version="1.0") + .one() + ) tmpl_model.name = "ShouldFail" - with pytest.raises( - ValueError, match="resource template that already has resources" - ): - client.backend.session.flush() - finally: - uow.rollback() + with pytest.raises(ValueError, match="active resource template"): + session.flush() + session.rollback() # Ensure the original resource is still intact - fetched = ( - client.query_maker() - .resources() - .filter(name=resource.name) - .include_template() - .first() - ) + with client.connection_state.sessionmaker.begin() as session: + fetched = session.query(Resource).filter_by(name=resource.name).one() + template_name = fetched.template.name assert fetched is not None - assert fetched.template.name == "GuardedTemplate" + assert template_name == "GuardedTemplate" def test_process_template_guard_prevents_updates_when_runs_exist(client): + client.create_namespace("camp") with client.build_resource_template( name="ProcRes", type_names=["container"], version="1.0" ): pass resource = client.create_resource("ProcRes1", "ProcRes") - client.create_campaign("Camp", "P1", "SAF1") - with client.build_process_template(name="PT Guard", version="1.0") as pt_builder: pt_builder.add_resource_slot( "slot1", @@ -65,31 +57,81 @@ def test_process_template_guard_prevents_updates_when_runs_exist(client): with client.build_process_run("RunGuard", "desc", "PT Guard", "1.0") as prb: prb.assign_resource("slot1", resource) + prb.finalize() - pt_schema = ( - client.query_maker() - .process_runs() - .filter(name="RunGuard") - .include_steps() - .first() - .template - ) - - uow = client.backend.begin() - try: - pt_model = client.backend.session.get(ProcessTemplate, pt_schema.id) + with client.connection_state.sessionmaker.begin() as session: + pt_model = ( + session.query(ProcessTemplate) + .filter_by(name="PT Guard", version="1.0") + .one() + ) pt_model.name = "ShouldFail" - with pytest.raises(ValueError, match="process template with existing runs"): - client.backend.session.flush() - finally: - uow.rollback() - - unchanged = ( - client.query_maker() - .process_runs() - .filter(name="RunGuard") - .include_steps() - .first() - .template - ) + with pytest.raises(ValueError, match="active process template"): + session.flush() + session.rollback() + + with client.connection_state.sessionmaker.begin() as session: + unchanged = ( + session.query(ProcessTemplate) + .filter_by(name="PT Guard", version="1.0") + .one() + ) assert unchanged.name == "PT Guard" + + +def test_active_process_template_rejects_nested_attribute_mutation(client): + client.create_namespace("nested-guard") + with client.build_process_template(name="Nested PT", version="1.0") as builder: + builder.add_step("step").param_group("params").add_attribute( + "temperature", "float", "C", 20 + ).close_group().close_step() + + with client.connection_state.sessionmaker.begin() as session: + template = session.query(ProcessTemplate).filter_by(name="Nested PT").one() + template.status = LifecycleStatus.ACTIVE + session.flush() + attribute = ( + session.query(AttributeTemplate) + .join(AttributeTemplate.attribute_group_template) + .join(AttributeGroupTemplate.step_template) + .filter(AttributeTemplate.name == "temperature") + .filter(StepTemplate.process_template_id == template.id) + .one() + ) + attribute.unit = "K" + with pytest.raises(ValueError, match="active process template"): + session.flush() + session.rollback() + + +def test_active_resource_template_rejects_nested_child_mutation(client): + client.create_namespace("nested-resource-guard") + with client.build_resource_template( + name="Nested RT", type_names=["plate"], version="1.0" + ) as builder: + builder.add_child("well", ["sample"]).close_child() + + with client.connection_state.sessionmaker.begin() as session: + root = session.query(ResourceTemplate).filter_by(name="Nested RT").one() + child = session.query(ResourceTemplate).filter_by(name="well").one() + root.status = LifecycleStatus.ACTIVE + session.flush() + child.name = "changed" + with pytest.raises(ValueError, match="active resource template"): + session.flush() + session.rollback() + + +def test_first_resource_instance_activates_template(client): + client.create_namespace("instance-guard") + with client.build_resource_template( + name="Instance RT", type_names=["sample"], version="1.0" + ): + pass + client.create_resource("instance", "Instance RT") + + with client.connection_state.sessionmaker.begin() as session: + template = session.query(ResourceTemplate).filter_by(name="Instance RT").one() + resource = session.query(Resource).filter_by(name="instance").one() + assert template.status is LifecycleStatus.ACTIVE + assert resource.status is LifecycleStatus.MUTABLE diff --git a/recap/tests/test_transport.py b/recap/tests/test_transport.py index 26b3865..e263078 100644 --- a/recap/tests/test_transport.py +++ b/recap/tests/test_transport.py @@ -19,12 +19,18 @@ PropertyFilter, QuerySpec, ) +from recap.exceptions import RecapProtocolError from recap.schemas.attribute import ( AttributeGroupTemplateSchema, + AttributeTemplateSchema, ) from recap.schemas.common import StepStatus from recap.schemas.process import ProcessRunSchema -from recap.schemas.resource import ResourceSchema +from recap.schemas.resource import ( + PropertySchema, + ResourceSchema, + ResourceTemplateSchema, +) from recap.tests.transport_factories import ( STAMP, attribute_group, @@ -42,7 +48,6 @@ def test_query_spec_normalizes_legacy_full_load_mode(): def test_query_request_serializes_complete_supported_query_spec(): - campaign_id = uuid4() parent_id = uuid4() spec = QuerySpec( filters={"name": "sample"}, @@ -51,14 +56,17 @@ def test_query_request_serializes_complete_supported_query_spec(): offset=5, property_filters=[PropertyFilter(name="temperature", value=20)], parent_resource_id=parent_id, - campaign_id=campaign_id, + include_archived=True, load_mode="eager", on_unloaded="raise", ) - request = QueryRequest.from_query(ResourceSchema, spec) + request = QueryRequest.from_query( + ResourceSchema, spec, namespace_path="beamline/amx" + ) assert request.schema_name == "ResourceSchema" + assert request.namespace_path == "beamline/amx" assert request.spec == { "filters": {"name": "sample"}, "predicates": [], @@ -78,29 +86,55 @@ def test_query_request_serializes_complete_supported_query_spec(): ], "parent_resource_id": str(parent_id), "parameter_filters": [], - "campaign_id": str(campaign_id), + "include_archived": True, + "local_metadata_filters": {}, + "effective_metadata_filters": {}, "load_mode": "eager", "on_unloaded": "raise", } assert request.spec["load_mode"] == "eager" assert "full" not in request.spec.values() + assert "include_mutable" not in request.spec + + +def test_hydrate_result_rejects_contradictory_legacy_schema_metadata(): + result = QueryResult( + entity="process_run", + projection="full", + schema_name="ResourceSchema", + items=[], + ) + + with pytest.raises(RecapProtocolError, match="does not match"): + hydrate_result(ResourceSchema, result) + + +def test_query_request_serializes_include_mutable_when_true(): + request = QueryRequest.from_query( + ResourceSchema, + QuerySpec(include_mutable=True), + namespace_path="beamline/amx", + ) + + assert request.spec["include_mutable"] is True def test_query_request_serializes_structured_predicates_and_orderings(): - campaign_id = uuid4() + namespace_id = uuid4() request = QueryRequest.from_query( ProcessRunSchema, QuerySpec( predicates=[ - Field("campaign_id") == campaign_id, + Field("namespace_id") == namespace_id, Field("create_date") >= STAMP, ], orderings=[Field("create_date").desc()], ), + namespace_path="beamline/amx", ) assert request.spec["predicates"] == [ - {"field": "campaign_id", "op": "eq", "value": str(campaign_id)}, + {"field": "namespace_id", "op": "eq", "value": str(namespace_id)}, { "field": "create_date", "op": "gte", @@ -128,7 +162,11 @@ def test_query_request_serializes_structured_predicates_and_orderings(): ) def test_query_request_rejects_every_legacy_query_feature(field, value): with pytest.raises(TypeError, match="Field"): - QueryRequest.from_query(ResourceSchema, QuerySpec(**{field: value})) + QueryRequest.from_query( + ResourceSchema, + QuerySpec(**{field: value}), + namespace_path="beamline/amx", + ) def test_datetime_default_value_round_trips_as_datetime(): @@ -200,6 +238,118 @@ def test_resource_round_trip_preserves_full_graph_and_transport_state(): assert hydrated.children["child"]._on_unloaded == "silent" +def test_serialize_model_truncates_repeated_cyclic_template_edges(): + parent = ResourceTemplateSchema.model_construct( + id=uuid4(), + namespace_id=uuid4(), + create_date=STAMP, + modified_date=STAMP, + status="ACTIVE", + revision=1, + name="Parent", + slug="parent", + version="1.0", + labels=[], + types=[], + children={}, + attribute_group_templates=[], + ) + child = ResourceTemplateSchema.model_construct( + id=uuid4(), + namespace_id=parent.namespace_id, + create_date=STAMP, + modified_date=STAMP, + status="ACTIVE", + revision=1, + name="Child", + slug="child", + version="1.0", + labels=[], + types=[], + children={}, + attribute_group_templates=[], + ) + parent.children = {"child": child} + child.parent = parent + + payload = serialize_model(parent) + + assert payload["children"]["child"]["parent"]["id"] == str(parent.id) + assert "children" not in payload["children"]["child"]["parent"] + + +def test_resource_round_trip_preserves_hydrated_array_override_and_metadata(): + group = AttributeGroupTemplateSchema( + **{ + **{ + "id": uuid4(), + "create_date": STAMP, + "modified_date": STAMP, + "namespace_id": uuid4(), + "status": "ACTIVE", + "revision": 1, + "labels": [], + }, + "name": "Measurements", + "slug": "measurements", + "attribute_templates": [ + AttributeTemplateSchema( + **{ + "id": uuid4(), + "create_date": STAMP, + "modified_date": STAMP, + "namespace_id": uuid4(), + "status": "ACTIVE", + "revision": 1, + "labels": [], + "name": "Samples", + "slug": "samples", + "value_type": "array", + "unit": "uL", + "default_value": [1], + "metadata_json": {"source": "detector"}, + } + ) + ], + } + ) + resource = minimal_resource() + resource.template.attribute_group_templates = [group] + resource.properties = {"Measurements": PropertySchema( + id=uuid4(), + create_date=STAMP, + modified_date=STAMP, + template=group, + values={ + "Samples": { + "value": [1, 1], + "unit": "mL", + "metadata_json": {"sample": "override"}, + } + }, + )} + resource.set_loaded_relations({"children": True, "properties": True}) + + payload = serialize_model(resource) + [hydrated] = hydrate_result( + ResourceSchema, + QueryResult(schema_name="ResourceSchema", items=[payload]), + ) + + value = hydrated.properties.measurements.values.samples + assert value.value == [1, 1] + assert value.unit == "mL" + assert value.metadata_json == {"sample": "override"} + + [rehydrated] = hydrate_result( + ResourceSchema, + QueryResult( + schema_name="ResourceSchema", items=[serialize_model(hydrated)] + ), + ) + assert rehydrated.properties.measurements.values.samples.value == [1, 1] + + def test_minimal_resource_round_trip_does_not_trigger_unloaded_guard(): resource = minimal_resource() resource.set_loaded_relations( @@ -241,7 +391,7 @@ def test_process_run_round_trip_restores_enums_dynamic_parameters_and_nested_sta ) assert hydrated.id == run.id - assert hydrated.campaign_id == run.campaign_id + assert hydrated.namespace_id == run.namespace_id assert hydrated.create_date == STAMP assert hydrated.steps["Acquire"].state is StepStatus.COMPLETE assert isinstance(hydrated.steps["Acquire"].parameters, BaseModel) diff --git a/recap/tests/transport_factories.py b/recap/tests/transport_factories.py index 93c62f1..be0ba45 100644 --- a/recap/tests/transport_factories.py +++ b/recap/tests/transport_factories.py @@ -1,6 +1,7 @@ from datetime import UTC, datetime from uuid import uuid4 +from recap.lifecycle import LifecycleStatus from recap.schemas.attribute import ( AttributeGroupTemplateSchema, AttributeTemplateSchema, @@ -28,6 +29,10 @@ def fields(**values): "id": uuid4(), "create_date": STAMP, "modified_date": STAMP, + "namespace_id": uuid4(), + "status": LifecycleStatus.ACTIVE, + "revision": 1, + "labels": [], **values, } @@ -151,7 +156,6 @@ def full_process_run() -> ProcessRunSchema: id=run_id, name="run-1", description="transport round trip", - campaign_id=uuid4(), template=process_template, steps={"Acquire": step}, assigned_resources={"input": assignment}, diff --git a/recap/utils/general.py b/recap/utils/general.py index 5ab0241..8cf059d 100644 --- a/recap/utils/general.py +++ b/recap/utils/general.py @@ -1,4 +1,3 @@ -import enum import json from datetime import datetime from typing import Any @@ -6,10 +5,7 @@ from slugify import slugify from sqlalchemy.ext.mutable import MutableList - -class Direction(str, enum.Enum): - input = "input" - output = "output" +from recap import Direction # noqa: F401 def _parse_array_like(value: Any) -> list[Any]: @@ -79,7 +75,8 @@ def _to_datetime(value): def _to_array(v): items = _parse_array_like(v) # your existing helper - return MutableList(items) + # Avoid handing ORM mutable wrappers across conversion boundaries. + return list(items) CONVERTERS = { diff --git a/recap/utils/migrations.py b/recap/utils/migrations.py index 87619b5..84e8fd0 100644 --- a/recap/utils/migrations.py +++ b/recap/utils/migrations.py @@ -1,5 +1,7 @@ +from collections.abc import Mapping from importlib import resources from pathlib import Path +from uuid import UUID from alembic import command from alembic.config import Config @@ -19,9 +21,25 @@ def build_alembic_config(db_url: str, script_location: Path | None = None) -> Co return config -def apply_migrations(db_url: str, revision: str = "head") -> None: - """Apply all migrations up to the requested revision for the given database.""" +def apply_migrations( + db_url: str, + revision: str = "head", + *, + campaign_namespace_paths: Mapping[UUID | str, str] | None = None, + base_namespace_path: str = "", +) -> None: + """Apply migrations, using the canonical root and campaign path defaults. + + An empty ``base_namespace_path`` represents the canonical root namespace. + Campaign paths default to ``campaign/`` when no explicit mapping is + provided. + """ config = build_alembic_config(db_url) + config.attributes["campaign_namespace_paths"] = { + str(campaign_id): path + for campaign_id, path in (campaign_namespace_paths or {}).items() + } + config.attributes["base_namespace_path"] = base_namespace_path command.upgrade(config, revision) diff --git a/recap/utils/namespace.py b/recap/utils/namespace.py new file mode 100644 index 0000000..52b8c19 --- /dev/null +++ b/recap/utils/namespace.py @@ -0,0 +1,38 @@ +import re + +_NAMESPACE_SEGMENT = re.compile(r"[a-z0-9._~-]+") + + +def canonicalize_namespace_path(path: str) -> str: + if path == "": + return path + + segments = path.split("/") + if any( + segment in {"", ".", ".."} or _NAMESPACE_SEGMENT.fullmatch(segment) is None + for segment in segments + ): + raise ValueError(f"Namespace path is not canonical: {path!r}") + return path + + +def parent_namespace_path(path: str) -> str: + path = canonicalize_namespace_path(path) + if path == "": + return path + return path.rpartition("/")[0] + + +def namespace_ancestors(path: str) -> tuple[str, ...]: + path = canonicalize_namespace_path(path) + if path == "": + return (path,) + + segments = path.split("/") + return ("", *("/".join(segments[:index]) for index in range(1, len(segments) + 1))) + + +def is_namespace_ancestor(ancestor: str, path: str) -> bool: + ancestor = canonicalize_namespace_path(ancestor) + path = canonicalize_namespace_path(path) + return ancestor == "" or path == ancestor or path.startswith(f"{ancestor}/") diff --git a/scripts/benchmark_hydration.py b/scripts/benchmark_hydration.py index 85b54cc..b4c917a 100644 --- a/scripts/benchmark_hydration.py +++ b/scripts/benchmark_hydration.py @@ -7,10 +7,15 @@ import time from collections.abc import Callable from typing import Any +from uuid import uuid4 from pydantic import BaseModel +from recap.adapter.schema_registry import SCHEMA_ENTITY_KEYS +from recap.adapter.transport import QueryResult, hydrate_result from recap.client import RecapClient +from recap.client.identity import IdentityMap +from recap.lifecycle import LifecycleStatus def timed(call: Callable[[], Any], repeats: int) -> tuple[Any, float]: @@ -30,23 +35,85 @@ def benchmark( repeats: int, ) -> None: models, local_seconds = timed(lambda: query_factory().all(), repeats) + _, schema_seconds = timed( + lambda: [schema.model_validate(model.model_dump(mode="python")) for model in models], + repeats, + ) payload, dump_seconds = timed( lambda: [model.model_dump(mode="json") for model in models], repeats ) - _, validation_seconds = timed( - lambda: [schema.model_validate(item) for item in payload], repeats + result = QueryResult( + entity=SCHEMA_ENTITY_KEYS[schema], projection="full", items=payload + ) + transport_models, validation_seconds = timed( + lambda: hydrate_result(schema, result), repeats + ) + _, materialization_seconds = timed( + lambda: [model.build_property_model() for model in transport_models] + if hasattr(schema, "model_fields") and "properties" in schema.model_fields + else list(transport_models), + repeats, + ) + _, identity_seconds = timed( + lambda: _merge_canonical(transport_models), repeats ) count = len(models) print( f"{label}: count={count} " f"local={local_seconds * 1000:.1f}ms " + f"database_query={local_seconds * 1000:.1f}ms " + f"schema_construction={schema_seconds * 1000:.1f}ms " f"model_dump={dump_seconds * 1000:.1f}ms " + f"json_transport_hydration={validation_seconds * 1000:.1f}ms " + f"dynamic_model_materialization={materialization_seconds * 1000:.1f}ms " + f"canonical_identity_merge={identity_seconds * 1000:.1f}ms " f"model_validate={validation_seconds * 1000:.1f}ms " f"transport_overhead={(dump_seconds + validation_seconds) * 1000:.1f}ms " f"model_validate/local={validation_seconds / local_seconds:.2f}x" ) +def _merge_canonical(models: list[BaseModel]) -> list[BaseModel]: + identity = IdentityMap() + return [identity.intern(model) for model in models] + + +def _benchmark_client(client: RecapClient) -> RecapClient: + """Create one small graph when benchmark database has no queryable namespace.""" + namespaces = client.list_namespaces() + for path in namespaces: + scoped = client.namespace(path) + context = scoped._resolve_namespace_context() + scoped._namespace_context = context + if ( + context.status is LifecycleStatus.ACTIVE + and scoped.query_maker().resources().count() > 0 + and scoped.query_maker().process_runs().count() > 0 + ): + return scoped + + benchmark_path = f"benchmark/{uuid4().hex}" + client.create_namespace(benchmark_path) + scoped = client.namespace(benchmark_path) + scoped._namespace_context = scoped._resolve_namespace_context() + if scoped.query_maker().resources().count() == 0: + with scoped.build_resource_template( + name="BenchmarkResource", type_names=["sample"] + ): + pass + resource = scoped.create_resource("sample-0", "BenchmarkResource") + scoped.build_resource(resource_id=resource.id).activate() + process_name = f"BenchmarkProcess-{uuid4().hex}" + with scoped.build_process_template(process_name, "1.0") as template: + template.add_step("Collect") + template.activate() + with scoped.build_process_run( + "run-0", "benchmark", process_name, "1.0" + ) as run: + run.finalize() + return scoped + + def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--db", required=True) @@ -58,7 +125,7 @@ def main() -> None: from recap.schemas.resource import ResourceSchema with RecapClient.from_sqlite(args.db) as client: - query = client.query_maker(unscoped=True) + query = _benchmark_client(client).query_maker() benchmark( "resources load=none", lambda: query.resources(load="none").limit(args.limit),