diff --git a/.cursor/skills/examining-ado-operations/SKILL.md b/.cursor/skills/examining-ado-operations/SKILL.md index 2ba4028b7..a980f9c84 100644 --- a/.cursor/skills/examining-ado-operations/SKILL.md +++ b/.cursor/skills/examining-ado-operations/SKILL.md @@ -16,11 +16,9 @@ Structured workflow for understanding what an operation did, which space it ran on, and whether measurements and results look healthy. - Run all commands from the **repository root** with `uv run`. -- Write the report to `reports//` (create the directory if - needed) - - where `ado_context_name` is the **active ado metastore context** - (`uv run ado context`) -- Write the report as `__report.md` +- The report produced by this skill is stored as the `content` of a + `document` resource in the active ado metastore context (see + [Producing a report](#producing-a-report)). **Related skills**: @@ -28,6 +26,8 @@ on, and whether measurements and results look healthy. [using-ado-cli](../using-ado-cli/SKILL.md). - For metastore filtering, schemas see [query-ado-data](../query-ado-data/SKILL.md). +- For creating document resources that store reports, see + [resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document). - For a project/context wide view (all spaces and operations), see [examining-ado-project](../examining-ado-project/SKILL.md). @@ -125,11 +125,20 @@ failure. In this case: If the operation is finished, -- Check if there is an existing report for this operation in - `reports//` -- If yes, check if that report indicated the operation was finished - - If yes, ask the user if they want to replace it with a new report - - If no, continue with creating new report +- Query the metastore for an existing document linked to this operation: + + ```bash + uv run ado get document -q 'config.relatedResources=OPERATION_ID' + ``` + + If a document is found, retrieve its metadata (name, created timestamp) and + fetch its `content` (`uv run ado get document DOCUMENT_ID -o yaml`) to check + if that report indicated the operation was finished. + - If yes, ask the user whether to replace it with a new report. If they + agree, delete the existing document (`uv run ado delete document + DOCUMENT_ID`) once the new report has been created. See + [resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document). + - If no, continue with creating a new report. ### Step 3: Review the operator @@ -321,6 +330,10 @@ Structure the report as: distributions 5. **Next Steps**: A plan for the next research steps to take using ado. +Store the report as a document resource (see +[resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document)). +Set `relatedResources` to the operation id and the input space ids from step 1. + ## Troubleshooting ### Number points sampled is greater than the number of entities measured by operation diff --git a/.cursor/skills/examining-ado-project/SKILL.md b/.cursor/skills/examining-ado-project/SKILL.md index 8c3dbcfbf..4116b0f85 100644 --- a/.cursor/skills/examining-ado-project/SKILL.md +++ b/.cursor/skills/examining-ado-project/SKILL.md @@ -15,6 +15,9 @@ related metadata in the ado project associated to the active context. - Run all commands from the **repository root** with `uv run` (see [using-ado-cli](../using-ado-cli/SKILL.md)). +- The report produced by this skill is stored as the `content` of a + `document` resource in the active ado metastore context (see + [Producing a report](#producing-a-report)). - See [projects and contexts](../../../docs/resources/metastore.md#contexts-and-projects) for details on what projects and contexts are @@ -24,6 +27,8 @@ related metadata in the ado project associated to the active context. - Prefer metastore listing and YAML dumps before heavy `uv run ado show` data pulls; see [query-ado-data](../query-ado-data/SKILL.md). +- For creating document resources that store reports, see + [resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document). - For one space in depth: [examining-discovery-spaces](../examining-discovery-spaces/SKILL.md). - For one operation in depth: @@ -209,26 +214,57 @@ Optionally complement with one-hop links: uv run ado show related space SPACE_ID ``` -## 4. Report template - -Write a concise markdown report - -- Write the report to `reports//` (create the directory if - needed), where `ado_context_name` is the **active ado metastore context** - (`uv run ado context`). -- Write the report as `project__report.md`. -- If a report already exists, check whether there has been meaningful activity - since it was written before replacing it: - 1. Run `uv run ado get spaces --details` and - `uv run ado get operations --details` and note the age of the most recent - resource. - 2. If the most recent resource is **younger** than the date of the existing - report, there has been new activity — proceed to write a new report. - 3. If not, ask the user whether they want to replace it. - 4. If finer-grained confirmation is needed, fetch the YAML of the most recent - space or operation - (`uv run ado get space SPACE_ID -o yaml --output-file SPACE_ID.yaml`, or - the same pattern for `operation`) and read its `creationTimestamp` field. +## 4. Check for existing report + +The project report is stored as a `document` resource whose `metadata.name` is + `project_report`. There **may** be zero, one, or +many documents with that name — each has a unique identifier. If several match, +the **current** report is the most recently created one. + +Query the metastore: + +```bash +uv run ado get document -q 'config.metadata.name=project_report' --details +``` + +If matches exist: + +1. Identify the **current** document (newest by creation timestamp from + `--details` or from document YAML `created` / status timestamps). Fetch its + `content` (`uv run ado get document DOCUMENT_ID -o yaml`). +2. Check whether there has been meaningful activity since the current report + was written: + 1. Run `uv run ado get spaces --details` and + `uv run ado get operations --details` and note the age of the most recent + resource. + 2. If the most recent resource is **younger** than the current document's + creation timestamp, there has been new activity — proceed toward writing + a new report. + 3. If not, ask the user whether they want to write a new report anyway. If + they decline, stop and use/refer to the current document id. + 4. If finer-grained confirmation is needed, fetch the YAML of the most recent + space or operation (`uv run ado get space SPACE_ID -o yaml --output-file + SPACE_ID.yaml`, or the same pattern for `operation`) and read its + `creationTimestamp` field. +3. Before creating a new report when a current one exists, **ask** whether to: + - **Keep history** — leave existing `project_report` document(s); create + another with the same name. + - **Replace** — delete the current document + (`uv run ado delete document DOCUMENT_ID`), then create the new one. + See + [resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document). + Do not auto-delete; do not assume history must be kept. + +## Producing a report + +Write a concise markdown report. Store it as the `content` field of a +`document` resource (see +[resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document)). + +- Set `metadata.name` to `project_report`. +- Set `metadata.description` to include today's date, e.g. + `Project overview as of YYYY-MM-DD`. +- Omit `relatedResources` (or leave empty). ### Project summary diff --git a/.cursor/skills/examining-discovery-spaces/SKILL.md b/.cursor/skills/examining-discovery-spaces/SKILL.md index 2680d5cf9..aa32f54ab 100644 --- a/.cursor/skills/examining-discovery-spaces/SKILL.md +++ b/.cursor/skills/examining-discovery-spaces/SKILL.md @@ -15,11 +15,9 @@ Structured workflow for understanding what a discoveryspace contains, how covered its entity space is, and what data has been collected. - Run all commands from the **repository root** with `uv run`. -- Write the report to `reports//` (create the directory if - needed) - - where `ado_context_name` is the **active ado metastore context** - (`uv run ado context`) -- Write the report as `__report.md` +- The report produced by this skill is stored as the `content` of a + `document` resource in the active ado metastore context (see + [Producing a report](#producing-a-report)). **Related skills**: @@ -27,6 +25,8 @@ covered its entity space is, and what data has been collected. [using-ado-cli](../using-ado-cli/SKILL.md). - For metastore filtering and schemas, see [query-ado-data](../query-ado-data/SKILL.md). +- For creating document resources that store reports, see + [resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document). - For examining operations run on a space, see [examining-ado-operations](../examining-ado-operations/SKILL.md). - For a project/context wide view (all spaces and operations), see @@ -150,13 +150,24 @@ uv run ado show related space SPACE_ID ### Step 3: Check for existing report -- Check if there is an existing report for this space in - `reports//` -- If yes, check if either of the following are true: - - New operations have been run on space since report +- Query the metastore for an existing document linked to this space: + + ```bash + uv run ado get document -q 'config.relatedResources=SPACE_ID' + ``` + + If a document is found, retrieve its metadata (name, created timestamp) and + fetch its `content` (`uv run ado get document DOCUMENT_ID -o yaml`) to + compare against current state. +- If a document is found, check if either of the following are true: + - New operations have been run on the space since the document was created - The number of measured entities has increased -- If neither of above are true, ask the user if they want to write a new report - or use existing +- If yes to either, ask the user whether to replace it with a new report. If + they agree, delete the existing document (`uv run ado delete document + DOCUMENT_ID`) once the new report has been created. See + [resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document). +- If neither of the above are true, ask the user if they want to write a new + report or use the existing one - As nothing has changed, the only purpose of creating a new report is if a different agent is being used @@ -201,7 +212,7 @@ understand what each operation did and what it produced. Note: Do not analyze the data in the operations, or do detailed diagnoses. Just enough for summary. -## Producing a Report +## Producing a report Structure the report as: @@ -217,3 +228,8 @@ Structure the report as: 4. **Data summary** – distributions of measured properties, notable performers, outliers, correlations 5. **Related operations** – which operations ran on this space and their status + +Store the report as a document resource (see +[resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document)). +Set `relatedResources` to the space id and the related operation ids from +step 2. diff --git a/.cursor/skills/resource-yaml-creation/SKILL.md b/.cursor/skills/resource-yaml-creation/SKILL.md index 91489e2c7..5538dadd1 100644 --- a/.cursor/skills/resource-yaml-creation/SKILL.md +++ b/.cursor/skills/resource-yaml-creation/SKILL.md @@ -2,10 +2,10 @@ name: resource-yaml-creation description: | Guidance for creating ado resource YAML files (discoveryspace, operation, - actuatorconfiguration, samplestore). Covers metadata conventions, dynamic - reference resolution with --use-latest/--with/--set, space design principles, - avoiding duplicate resources, and validation. Use when creating or editing - any ado resource YAML file. + actuatorconfiguration, samplestore, document). Covers metadata conventions, + dynamic reference resolution with --use-latest/--with/--set, space design + principles, avoiding duplicate resources, document reports, and validation. + Use when creating or editing any ado resource YAML file. --- # Creating ado Resource YAML Files @@ -33,7 +33,7 @@ metadata: - `name` and `description` are shown by `ado get --details` - `labels` support filtering: `uv run ado get spaces --label project=my_project` - `--filter` supports path-based filtering across any field: - `uv run ado get spaces --filter "metadata.name=my_space"` + `uv run ado get spaces --filter 'config.metadata.name=my_space'` ## Dynamic Reference Resolution @@ -194,6 +194,52 @@ shared measurement history. uv run ado create samplestore -f samplestore.yaml ``` +### Document + +Use `document` resources to persist markdown or HTML reports in the metastore. +Set `contentType` to `markdown` (default) or `html`. See the +[document resource documentation](../../../docs/resources/document.md). +Examining skills store their reports this way: put the body in `content`, +optionally set `contentType`, and list related resource identifiers in +`relatedResources`. + +```yaml +# _document.yaml (temp file, not committed) +metadata: + name: "" + description: "" +contentType: markdown # or html +content: | + +relatedResources: + - +``` + +```bash +uv run ado create document -f _document.yaml +uv run ado delete document DOCUMENT_ID +``` + +Validate with `uv run ado create document -f FILE --dry-run` before creating. + +**Querying existing documents** (paths are under `config.`; string candidates +must be single-quoted — see [query-ado-data](../query-ado-data/SKILL.md)): + +```bash +# By related resource id (array containment — no [*] subscript) +uv run ado get document -q 'config.relatedResources=RESOURCE_ID' + +# By metadata name +uv run ado get document -q 'config.metadata.name=NAME' +``` + +Fetch a document's body with +`uv run ado get document DOCUMENT_ID -o yaml`. + +When replacing a report, delete the existing document only after the user +agrees and the new report has been created (or immediately before creating the +replacement, if the skill's replace policy says so). + ## Related Resources - [using-ado-cli](../using-ado-cli/SKILL.md) — CLI command syntax and shortcuts diff --git a/AGENTS.md b/AGENTS.md index fb65d90e9..e9a0e2c48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ These guidelines apply to all code development in the ado codebase. - samplestore - datacontainer - actuatorconfiguration + - document - **modules/actuators**: defines actuators, custom experiments, and their associated management code (plugins, registry) - **modules/operators**: defines operators and their associated management diff --git a/ado/cli/commands/create.py b/ado/cli/commands/create.py index 2309b05b4..1a0bd0b5c 100644 --- a/ado/cli/commands/create.py +++ b/ado/cli/commands/create.py @@ -22,6 +22,7 @@ ) from ado.cli.resources.context.create import create_context from ado.cli.resources.discovery_space.create import create_discovery_space +from ado.cli.resources.document.create import create_document from ado.cli.resources.operation.create import create_operation from ado.cli.resources.sample_store.create import create_sample_store from ado.cli.utils.input.parsers import ( @@ -239,6 +240,7 @@ def create_resource( AdoCreateSupportedResourceTypes.ACTUATOR_CONFIGURATION: create_actuator_configuration, AdoCreateSupportedResourceTypes.CONTEXT: create_context, AdoCreateSupportedResourceTypes.DISCOVERY_SPACE: create_discovery_space, + AdoCreateSupportedResourceTypes.DOCUMENT: create_document, AdoCreateSupportedResourceTypes.SAMPLE_STORE: create_sample_store, AdoCreateSupportedResourceTypes.OPERATION: create_operation, } diff --git a/ado/cli/commands/delete.py b/ado/cli/commands/delete.py index 50a78d87b..752a74727 100644 --- a/ado/cli/commands/delete.py +++ b/ado/cli/commands/delete.py @@ -14,6 +14,7 @@ from ado.cli.resources.context.delete import delete_context from ado.cli.resources.data_container.delete import delete_data_container from ado.cli.resources.discovery_space.delete import delete_discovery_space +from ado.cli.resources.document.delete import delete_document from ado.cli.resources.operation.delete import delete_operation from ado.cli.resources.sample_store.delete import delete_sample_store from ado.cli.utils.input.parsers import enum_choice_with_plural_parser @@ -220,6 +221,7 @@ def delete_resource( AdoDeleteSupportedResourceTypes.CONTEXT: delete_context, AdoDeleteSupportedResourceTypes.DATA_CONTAINER: delete_data_container, AdoDeleteSupportedResourceTypes.DISCOVERY_SPACE: delete_discovery_space, + AdoDeleteSupportedResourceTypes.DOCUMENT: delete_document, AdoDeleteSupportedResourceTypes.SAMPLE_STORE: delete_sample_store, AdoDeleteSupportedResourceTypes.OPERATION: delete_operation, } diff --git a/ado/cli/commands/describe.py b/ado/cli/commands/describe.py index 9c917fd0f..f109ca917 100644 --- a/ado/cli/commands/describe.py +++ b/ado/cli/commands/describe.py @@ -16,6 +16,7 @@ from ado.cli.models.types import AdoDescribeSupportedResourceTypes from ado.cli.resources.data_container.describe import describe_data_container from ado.cli.resources.discovery_space.describe import describe_discovery_space +from ado.cli.resources.document.describe import describe_document from ado.cli.resources.experiment.describe import describe_experiment from ado.cli.utils.generic.common import get_effective_resource_id from ado.cli.utils.input.parsers import enum_choice_with_plural_parser @@ -96,6 +97,9 @@ def describe_resource( # Describe an experiment, optionally with actuator prefix ado describe experiment . + + # Describe a document (markdown in-terminal; HTML opens in browser) + ado describe document """ ado_configuration: AdoConfiguration = ctx.obj @@ -141,6 +145,7 @@ def describe_resource( method_mapping = { AdoDescribeSupportedResourceTypes.DATA_CONTAINER: describe_data_container, AdoDescribeSupportedResourceTypes.DISCOVERY_SPACE: describe_discovery_space, + AdoDescribeSupportedResourceTypes.DOCUMENT: describe_document, AdoDescribeSupportedResourceTypes.EXPERIMENT: describe_experiment, } diff --git a/ado/cli/commands/edit.py b/ado/cli/commands/edit.py index c04665f4c..02f719619 100644 --- a/ado/cli/commands/edit.py +++ b/ado/cli/commands/edit.py @@ -21,6 +21,7 @@ ) from ado.cli.resources.data_container.edit import edit_data_container from ado.cli.resources.discovery_space.edit import edit_discovery_space +from ado.cli.resources.document.edit import edit_document from ado.cli.resources.operation.edit import edit_operation from ado.cli.resources.sample_store.edit import edit_sample_store from ado.cli.utils.input.parsers import enum_choice_with_plural_parser @@ -132,6 +133,7 @@ def edit_resource( AdoEditSupportedResourceTypes.ACTUATOR_CONFIGURATION: edit_actuator_configuration, AdoEditSupportedResourceTypes.DATA_CONTAINER: edit_data_container, AdoEditSupportedResourceTypes.DISCOVERY_SPACE: edit_discovery_space, + AdoEditSupportedResourceTypes.DOCUMENT: edit_document, AdoEditSupportedResourceTypes.SAMPLE_STORE: edit_sample_store, AdoEditSupportedResourceTypes.OPERATION: edit_operation, } diff --git a/ado/cli/commands/get.py b/ado/cli/commands/get.py index a3f4af338..b8515f761 100644 --- a/ado/cli/commands/get.py +++ b/ado/cli/commands/get.py @@ -23,6 +23,7 @@ from ado.cli.resources.context.get import get_context from ado.cli.resources.data_container.get import get_data_container from ado.cli.resources.discovery_space.get import get_discovery_space +from ado.cli.resources.document.get import get_document from ado.cli.resources.experiment.get import get_experiment from ado.cli.resources.operation.get import get_operation from ado.cli.resources.operator.get import get_operator @@ -383,6 +384,7 @@ def get_resource( AdoGetSupportedResourceTypes.CONTEXT: get_context, AdoGetSupportedResourceTypes.DATA_CONTAINER: get_data_container, AdoGetSupportedResourceTypes.DISCOVERY_SPACE: get_discovery_space, + AdoGetSupportedResourceTypes.DOCUMENT: get_document, AdoGetSupportedResourceTypes.EXPERIMENT: get_experiment, AdoGetSupportedResourceTypes.SAMPLE_STORE: get_sample_store, AdoGetSupportedResourceTypes.OPERATION: get_operation, diff --git a/ado/cli/commands/template.py b/ado/cli/commands/template.py index e75c00bd4..9a930aa20 100644 --- a/ado/cli/commands/template.py +++ b/ado/cli/commands/template.py @@ -16,6 +16,7 @@ ) from ado.cli.resources.context.template import template_context from ado.cli.resources.discovery_space.template import template_discovery_space +from ado.cli.resources.document.template import template_document from ado.cli.resources.operation.template import template_operation from ado.cli.resources.sample_store.template import template_sample_store from ado.cli.utils.input.parsers import ( @@ -169,6 +170,7 @@ def template_resource( AdoTemplateSupportedResourceTypes.ACTUATOR_CONFIGURATION: template_actuator_configuration, AdoTemplateSupportedResourceTypes.CONTEXT: template_context, AdoTemplateSupportedResourceTypes.DISCOVERY_SPACE: template_discovery_space, + AdoTemplateSupportedResourceTypes.DOCUMENT: template_document, AdoTemplateSupportedResourceTypes.SAMPLE_STORE: template_sample_store, AdoTemplateSupportedResourceTypes.OPERATION: template_operation, } diff --git a/ado/cli/models/types.py b/ado/cli/models/types.py index fd752c062..667078a2c 100644 --- a/ado/cli/models/types.py +++ b/ado/cli/models/types.py @@ -11,6 +11,7 @@ _ACTUATOR_SINGULAR = "actuator" _CONTEXT_SINGULAR = "context" _DATA_CONTAINER_SINGULAR = "datacontainer" +_DOCUMENT_SINGULAR = "document" _DISCOVERY_SPACE_SINGULAR = "discoveryspace" _EXPERIMENT_SINGULAR = "experiment" _OPERATION_SINGULAR = "operation" @@ -48,6 +49,7 @@ class AdoCreateSupportedResourceTypes(Enum): ACTUATOR_CONFIGURATION = _ACTUATOR_CONFIGURATION_SINGULAR CONTEXT = _CONTEXT_SINGULAR DISCOVERY_SPACE = _DISCOVERY_SPACE_SINGULAR + DOCUMENT = _DOCUMENT_SINGULAR OPERATION = _OPERATION_SINGULAR SAMPLE_STORE = _SAMPLE_STORE_SINGULAR @@ -64,6 +66,7 @@ class AdoDeleteSupportedResourceTypes(Enum): CONTEXT = _CONTEXT_SINGULAR DATA_CONTAINER = _DATA_CONTAINER_SINGULAR DISCOVERY_SPACE = _DISCOVERY_SPACE_SINGULAR + DOCUMENT = _DOCUMENT_SINGULAR OPERATION = _OPERATION_SINGULAR SAMPLE_STORE = _SAMPLE_STORE_SINGULAR @@ -72,6 +75,7 @@ class AdoDeleteSupportedResourceTypes(Enum): class AdoDescribeSupportedResourceTypes(Enum): DATA_CONTAINER = _DATA_CONTAINER_SINGULAR DISCOVERY_SPACE = _DISCOVERY_SPACE_SINGULAR + DOCUMENT = _DOCUMENT_SINGULAR EXPERIMENT = _EXPERIMENT_SINGULAR @@ -86,6 +90,7 @@ class AdoEditSupportedResourceTypes(Enum): ACTUATOR_CONFIGURATION = _ACTUATOR_CONFIGURATION_SINGULAR DATA_CONTAINER = _DATA_CONTAINER_SINGULAR DISCOVERY_SPACE = _DISCOVERY_SPACE_SINGULAR + DOCUMENT = _DOCUMENT_SINGULAR OPERATION = _OPERATION_SINGULAR SAMPLE_STORE = _SAMPLE_STORE_SINGULAR @@ -107,6 +112,7 @@ class AdoGetSupportedResourceTypes(Enum): CONTEXT = _CONTEXT_SINGULAR DATA_CONTAINER = _DATA_CONTAINER_SINGULAR DISCOVERY_SPACE = _DISCOVERY_SPACE_SINGULAR + DOCUMENT = _DOCUMENT_SINGULAR EXPERIMENT = _EXPERIMENT_SINGULAR OPERATION = _OPERATION_SINGULAR OPERATOR = _OPERATOR_SINGULAR @@ -181,6 +187,7 @@ class AdoTemplateSupportedResourceTypes(Enum): ACTUATOR_CONFIGURATION = _ACTUATOR_CONFIGURATION_SINGULAR CONTEXT = _CONTEXT_SINGULAR DISCOVERY_SPACE = _DISCOVERY_SPACE_SINGULAR + DOCUMENT = _DOCUMENT_SINGULAR OPERATION = _OPERATION_SINGULAR SAMPLE_STORE = _SAMPLE_STORE_SINGULAR diff --git a/ado/cli/resources/document/__init__.py b/ado/cli/resources/document/__init__.py new file mode 100644 index 000000000..2a4c79e8b --- /dev/null +++ b/ado/cli/resources/document/__init__.py @@ -0,0 +1,2 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT diff --git a/ado/cli/resources/document/create.py b/ado/cli/resources/document/create.py new file mode 100644 index 000000000..e32a19adf --- /dev/null +++ b/ado/cli/resources/document/create.py @@ -0,0 +1,60 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import pydantic +import typer +import yaml +from rich.status import Status + +from ado.cli.models.parameters import AdoCreateCommandParameters +from ado.cli.utils.generic.wrappers import get_sql_store +from ado.cli.utils.output.prints import ( + ADO_CREATE_DRY_RUN_CONFIG_VALID, + ADO_SPINNER_SAVING_TO_DB, + ERROR, + SUCCESS, + console_print, + magenta, +) +from ado.cli.utils.pydantic.updaters import override_values_in_pydantic_model +from ado.core.document.config import DocumentConfiguration +from ado.core.document.resource import DocumentResource + + +def create_document(parameters: AdoCreateCommandParameters) -> str | None: + """Create a document resource from a YAML configuration file.""" + try: + document_configuration = DocumentConfiguration.model_validate( + yaml.safe_load(parameters.resource_configuration_file.read_text()) + ) + except pydantic.ValidationError as error: + console_print( + f"{ERROR}The document provided was not valid:", + stderr=True, + ) + console_print(error, stderr=True, use_markup=False) + raise typer.Exit(1) from error + + if parameters.override_values: + document_configuration = override_values_in_pydantic_model( + model=document_configuration, + override_values=parameters.override_values, + ) + + if parameters.dry_run: + console_print(ADO_CREATE_DRY_RUN_CONFIG_VALID, stderr=True) + return None + + resource_to_be_created = DocumentResource(config=document_configuration) + + sql = get_sql_store(project_context=parameters.ado_configuration.project_context) + with Status(ADO_SPINNER_SAVING_TO_DB): + sql.addResource(resource_to_be_created) + + console_print( + f"{SUCCESS}Created document with identifier " + f"{magenta(resource_to_be_created.identifier)}", + stderr=True, + ) + + return resource_to_be_created.identifier diff --git a/ado/cli/resources/document/delete.py b/ado/cli/resources/document/delete.py new file mode 100644 index 000000000..1b3ed4c79 --- /dev/null +++ b/ado/cli/resources/document/delete.py @@ -0,0 +1,52 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +from rich.status import Status + +from ado.cli.models.parameters import AdoDeleteCommandParameters +from ado.cli.utils.generic.wrappers import get_sql_store +from ado.cli.utils.output.prints import ( + ADO_SPINNER_DELETING_FROM_DB, + ADO_SPINNER_QUERYING_DB, +) +from ado.core import CoreResourceKinds +from ado.metastore.base import ( + ResourceDoesNotExistError, + ResourceHasChildrenError, +) + + +def delete_document(parameters: AdoDeleteCommandParameters) -> None: + """Delete a document resource.""" + resource_id = parameters.resource_ids[0] + + sql = get_sql_store(project_context=parameters.ado_configuration.project_context) + with Status(ADO_SPINNER_QUERYING_DB) as spinner: + if not sql.containsResourceWithIdentifier( + identifier=resource_id, + kind=CoreResourceKinds.DOCUMENT, + ): + spinner.stop() + raise ResourceDoesNotExistError( + resource_id=resource_id, + kind=CoreResourceKinds.DOCUMENT, + ) + + children_resources = sql.getRelatedObjectResourceIdentifiers( + identifier=resource_id + ) + + if not children_resources.empty: + spinner.stop() + raise ResourceHasChildrenError( + resource_id=resource_id, + kind=CoreResourceKinds.DOCUMENT, + children_resources=children_resources, + ) + + spinner.update(ADO_SPINNER_DELETING_FROM_DB) + try: + sql.delete_document(resource_id) + except Exception: + spinner.stop() + raise diff --git a/ado/cli/resources/document/describe.py b/ado/cli/resources/document/describe.py new file mode 100644 index 000000000..74ad2f4f9 --- /dev/null +++ b/ado/cli/resources/document/describe.py @@ -0,0 +1,101 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import pathlib +import sys +import tempfile +import webbrowser + +from rich.console import Group +from rich.markdown import Markdown +from rich.status import Status +from rich.text import Text + +from ado.cli.models.parameters import AdoDescribeCommandParameters +from ado.cli.utils.generic.wrappers import get_sql_store +from ado.cli.utils.output.prints import ( + ADO_SPINNER_QUERYING_DB, + INFO, + console_print, +) +from ado.core.resources import CoreResourceKinds +from ado.metastore.base import ResourceDoesNotExistError + + +def describe_document(parameters: AdoDescribeCommandParameters) -> None: + """Print a human-friendly description of a document resource. + + When stdout is a terminal, markdown is rendered with rich and HTML is opened + in the default browser. When stdout is redirected (pipe or file), the raw + ``content`` body is written with no styling. + """ + sql = get_sql_store(project_context=parameters.ado_configuration.project_context) + to_terminal = sys.stdout.isatty() + + if to_terminal: + with Status(ADO_SPINNER_QUERYING_DB) as status: + document_resource = sql.getResource( + identifier=parameters.resource_id, kind=CoreResourceKinds.DOCUMENT + ) + if not document_resource: + status.stop() + raise ResourceDoesNotExistError( + resource_id=parameters.resource_id, kind=CoreResourceKinds.DOCUMENT + ) + else: + document_resource = sql.getResource( + identifier=parameters.resource_id, kind=CoreResourceKinds.DOCUMENT + ) + if not document_resource: + raise ResourceDoesNotExistError( + resource_id=parameters.resource_id, kind=CoreResourceKinds.DOCUMENT + ) + + config = document_resource.config + + if not to_terminal: + sys.stdout.write(config.content) + if not config.content.endswith("\n"): + sys.stdout.write("\n") + return + + header_parts: list[Text | str] = [ + Text.assemble( + ("Identifier: ", "bold"), (document_resource.identifier, "bold green") + ), + "", + ] + if config.metadata.name: + header_parts.append(Text.assemble(("Name: ", "bold"), (config.metadata.name,))) + if config.metadata.description: + header_parts.append( + Text.assemble(("Description: ", "bold"), (config.metadata.description,)) + ) + if config.relatedResources: + header_parts.append( + Text.assemble( + ("Related resources: ", "bold"), + (", ".join(config.relatedResources),), + ) + ) + header_parts.append("") + + if config.contentType == "html": + console_print(Group(*header_parts)) + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".html", + prefix=f"{document_resource.identifier}-", + delete=False, + encoding="utf-8", + ) as html_file: + html_file.write(config.content) + html_path = pathlib.Path(html_file.name) + + webbrowser.open(html_path.as_uri()) + console_print( + f"{INFO}Opened HTML document content in the default browser ({html_path})." + ) + return + + console_print(Group(*header_parts, Markdown(config.content))) diff --git a/ado/cli/resources/document/edit.py b/ado/cli/resources/document/edit.py new file mode 100644 index 000000000..b93133496 --- /dev/null +++ b/ado/cli/resources/document/edit.py @@ -0,0 +1,18 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +from ado.cli.models.parameters import AdoEditCommandParameters +from ado.cli.utils.resources.handlers import handle_edit_resource_metadata +from ado.core.resources import CoreResourceKinds + + +def edit_document(parameters: AdoEditCommandParameters) -> None: + """Edit metadata on a document resource.""" + handle_edit_resource_metadata( + resource_id=parameters.resource_id, + resource_type=CoreResourceKinds.DOCUMENT, + project_context=parameters.ado_configuration.project_context, + editor=parameters.editor, + metadata_path=parameters.metadata_path, + metadata_patch=parameters.metadata_patch, + ) diff --git a/ado/cli/resources/document/get.py b/ado/cli/resources/document/get.py new file mode 100644 index 000000000..ce44f228d --- /dev/null +++ b/ado/cli/resources/document/get.py @@ -0,0 +1,11 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +from ado.cli.models.parameters import AdoGetCommandParameters +from ado.cli.utils.resources.handlers import handle_ado_get +from ado.core.resources import CoreResourceKinds + + +def get_document(parameters: AdoGetCommandParameters) -> None: + """Get one or more document resources.""" + handle_ado_get(parameters=parameters, resource_type=CoreResourceKinds.DOCUMENT) diff --git a/ado/cli/resources/document/template.py b/ado/cli/resources/document/template.py new file mode 100644 index 000000000..09ab9b96e --- /dev/null +++ b/ado/cli/resources/document/template.py @@ -0,0 +1,34 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import pathlib + +from ado.cli.models.parameters import AdoTemplateCommandParameters +from ado.core.document.config import DocumentConfiguration + + +def template_document(parameters: AdoTemplateCommandParameters) -> None: + """Emit a starter document configuration YAML file.""" + from ado.cli.utils.pydantic.serializers import ( + serialise_pydantic_model, + serialise_pydantic_model_json_schema, + ) + + model_instance = DocumentConfiguration( + content="# Report title\n\nReport body.", + contentType="markdown", + relatedResources=[], + ) + serialise_pydantic_model( + model=model_instance, + output_path=parameters.output_file, + ) + + if parameters.include_schema: + if parameters.output_file is None: + serialise_pydantic_model_json_schema(model_instance, None) + else: + schema_output_path = pathlib.Path( + parameters.output_file.stem + "_schema.yaml" + ) + serialise_pydantic_model_json_schema(model_instance, schema_output_path) diff --git a/ado/cli/utils/resources/mappings.py b/ado/cli/utils/resources/mappings.py index 2079d13f2..42acb6ca3 100644 --- a/ado/cli/utils/resources/mappings.py +++ b/ado/cli/utils/resources/mappings.py @@ -8,6 +8,7 @@ "ac": CoreResourceKinds.ACTUATORCONFIGURATION.value, "ctx": "context", "dcr": CoreResourceKinds.DATACONTAINER.value, + "doc": CoreResourceKinds.DOCUMENT.value, "exp": "experiment", "op": CoreResourceKinds.OPERATION.value, "space": CoreResourceKinds.DISCOVERYSPACE.value, @@ -18,6 +19,7 @@ CoreResourceKinds.ACTUATORCONFIGURATION: "actuator configuration", CoreResourceKinds.DATACONTAINER: "data container", CoreResourceKinds.DISCOVERYSPACE: "space", + CoreResourceKinds.DOCUMENT: "document", CoreResourceKinds.OPERATION: "operation", CoreResourceKinds.SAMPLESTORE: "sample store", } diff --git a/ado/core/__init__.py b/ado/core/__init__.py index 3d40e7f62..263b07459 100644 --- a/ado/core/__init__.py +++ b/ado/core/__init__.py @@ -4,6 +4,7 @@ from .actuatorconfiguration.resource import ActuatorConfigurationResource from .datacontainer.resource import DataContainerResource from .discoveryspace.resource import DiscoverySpaceResource +from .document.resource import DocumentResource from .operation.resource import OperationResource from .resources import ADOResource as ADOResource from .resources import CoreResourceKinds @@ -15,4 +16,5 @@ CoreResourceKinds.SAMPLESTORE.value: SampleStoreResource, CoreResourceKinds.ACTUATORCONFIGURATION.value: ActuatorConfigurationResource, CoreResourceKinds.DATACONTAINER.value: DataContainerResource, + CoreResourceKinds.DOCUMENT.value: DocumentResource, } diff --git a/ado/core/document/__init__.py b/ado/core/document/__init__.py new file mode 100644 index 000000000..2a4c79e8b --- /dev/null +++ b/ado/core/document/__init__.py @@ -0,0 +1,2 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT diff --git a/ado/core/document/config.py b/ado/core/document/config.py new file mode 100644 index 000000000..0824ac217 --- /dev/null +++ b/ado/core/document/config.py @@ -0,0 +1,38 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +from typing import Annotated, Literal + +import pydantic +from pydantic import ConfigDict + +from ado.core.metadata import ConfigurationMetadata + + +class DocumentConfiguration(pydantic.BaseModel): + """Configuration for a document resource.""" + + model_config = ConfigDict(extra="forbid") + + content: Annotated[ + str, + pydantic.Field(description="Body of the document (markdown or HTML)"), + ] + contentType: Annotated[ + Literal["markdown", "html"], + pydantic.Field(description="Format of the content field"), + ] = "markdown" + relatedResources: Annotated[ + list[str], + pydantic.Field( + default_factory=list, + description="Identifiers of related ado resources, if any", + ), + ] + metadata: Annotated[ + ConfigurationMetadata, + pydantic.Field( + description="Metadata about the document including optional name, " + "description, labels for filtering, and any additional custom fields" + ), + ] = ConfigurationMetadata() diff --git a/ado/core/document/resource.py b/ado/core/document/resource.py new file mode 100644 index 000000000..0eaee31bf --- /dev/null +++ b/ado/core/document/resource.py @@ -0,0 +1,26 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT +import uuid +from typing import Annotated, Literal + +import pydantic + +from ado.core.document.config import DocumentConfiguration +from ado.core.resources import ADOResource, CoreResourceKinds +from ado.utilities.pydantic import Defaultable + + +class DocumentResource(ADOResource): + """A resource that stores markdown or HTML documents.""" + + version: Annotated[str, pydantic.Field()] = "v1" + kind: Annotated[Literal[CoreResourceKinds.DOCUMENT], pydantic.Field()] = ( + CoreResourceKinds.DOCUMENT + ) + config: DocumentConfiguration + identifier: Annotated[ + Defaultable[str], + pydantic.Field( + default_factory=lambda: f"document-{str(uuid.uuid4())[:8]}", + ), + ] diff --git a/ado/core/resources.py b/ado/core/resources.py index ec8f88429..5638e76aa 100644 --- a/ado/core/resources.py +++ b/ado/core/resources.py @@ -22,6 +22,7 @@ class CoreResourceKinds(str, enum.Enum): ACTUATORCONFIGURATION = "actuatorconfiguration" SAMPLESTORE = "samplestore" DATACONTAINER = "datacontainer" + DOCUMENT = "document" def VersionIsGreaterThan(v1: str, v2: str) -> bool: diff --git a/ado/metastore/base.py b/ado/metastore/base.py index 3d25d2bc8..f83f13aab 100644 --- a/ado/metastore/base.py +++ b/ado/metastore/base.py @@ -310,6 +310,10 @@ def delete_data_container(self, identifier: str) -> None: def delete_actuator_configuration(self, identifier: str) -> None: pass + @abc.abstractmethod + def delete_document(self, identifier: str) -> None: + pass + @abc.abstractmethod def get_resources_by_relationship( self, diff --git a/ado/metastore/sqlstore.py b/ado/metastore/sqlstore.py index cd293265d..64f2082f6 100644 --- a/ado/metastore/sqlstore.py +++ b/ado/metastore/sqlstore.py @@ -1474,6 +1474,36 @@ def delete_actuator_configuration(self, identifier: str) -> None: rollback_occurred=True, ) from e + def delete_document(self, identifier: str) -> None: + import sqlalchemy.orm + + with sqlalchemy.orm.Session(self.engine) as session: + try: + with session.begin(): + session.execute( + sqlalchemy.text( + r"DELETE FROM resource_relationships WHERE object_identifier=:identifier" + ).bindparams(identifier=identifier) + ) + + session.execute( + sqlalchemy.text( + r"DELETE FROM resources " + r"WHERE identifier=:identifier AND kind=:kind" + ).bindparams( + identifier=identifier, + kind=CoreResourceKinds.DOCUMENT.value, + ) + ) + + except Exception as e: + session.rollback() + raise DeleteFromDatabaseError( + resource_id=identifier, + resource_kind=CoreResourceKinds.DOCUMENT, + rollback_occurred=True, + ) from e + # --------------------------------------------------------------------------- # Hierarchy traversal # --------------------------------------------------------------------------- diff --git a/tests/ado/create/test_ado_create_document.py b/tests/ado/create/test_ado_create_document.py new file mode 100644 index 000000000..1287ba464 --- /dev/null +++ b/tests/ado/create/test_ado_create_document.py @@ -0,0 +1,126 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import pathlib +from collections.abc import Callable + +import yaml +from typer.testing import CliRunner + +from ado.cli.core.cli import app as ado +from ado.core.document.config import DocumentConfiguration +from ado.core.document.resource import DocumentResource +from ado.metastore.project import ProjectContext +from ado.metastore.sqlstore import SQLStore + + +def test_create_document_dry_run_success(tmp_path: pathlib.Path) -> None: + """Dry-run validates a document configuration without persisting it.""" + document_file = "tests/fixtures/document.yaml" + runner = CliRunner() + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "create", + "document", + "-f", + document_file, + "--dry-run", + ], + ) + assert result.exit_code == 0 + assert "The configuration passed is valid!" in result.output + + +def test_create_document_dry_run_failure(tmp_path: pathlib.Path) -> None: + """Dry-run rejects an invalid document configuration.""" + document_file = pathlib.Path("tests/fixtures/document.yaml") + invalid_document_file = tmp_path / "invalid.yaml" + document_configuration = yaml.safe_load(document_file.read_text()) + del document_configuration["content"] + invalid_document_file.write_text(yaml.safe_dump(document_configuration)) + + runner = CliRunner() + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "create", + "document", + "-f", + invalid_document_file, + "--dry-run", + ], + ) + assert result.exit_code == 1 + assert "The document provided was not valid:" in result.output + + +def test_create_document( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], +) -> None: + """Create persists a document resource.""" + document_file = "tests/fixtures/document.yaml" + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "create", + "document", + "-f", + document_file, + ], + ) + + assert result.exit_code == 0 + assert result.output.startswith("Success! Created document with identifier") + + +def test_get_document_config_round_trip( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + sql_store: SQLStore, +) -> None: + """Get document -o config returns the stored configuration.""" + config = DocumentConfiguration.model_validate( + yaml.safe_load(pathlib.Path("tests/fixtures/document.yaml").read_text()) + ) + resource = DocumentResource(config=config) + sql_store.addResource(resource) + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "get", + "document", + resource.identifier, + "-o", + "config", + ], + ) + + assert result.exit_code == 0 + restored = DocumentConfiguration.model_validate(yaml.safe_load(result.output)) + assert restored.content == config.content + assert restored.relatedResources == config.relatedResources diff --git a/tests/ado/delete/test_ado_delete_document.py b/tests/ado/delete/test_ado_delete_document.py new file mode 100644 index 000000000..1bbd4839e --- /dev/null +++ b/tests/ado/delete/test_ado_delete_document.py @@ -0,0 +1,72 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import pathlib +from collections.abc import Callable + +from typer.testing import CliRunner + +from ado.cli.core.cli import app as ado +from ado.core.document.config import DocumentConfiguration +from ado.core.document.resource import DocumentResource +from ado.metastore.project import ProjectContext +from ado.metastore.sqlstore import SQLStore + + +def test_delete_document( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + sql_store: SQLStore, +) -> None: + """Delete removes a document resource.""" + config = DocumentConfiguration(content="Example report") + resource = DocumentResource(config=config) + sql_store.addResource(resource) + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "delete", + "document", + resource.identifier, + ], + ) + assert result.exit_code == 0, result.output + assert "Success!" in result.output.strip() + + +def test_delete_nonexistent_document( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], +) -> None: + """Delete reports an error for a missing document.""" + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "delete", + "document", + "does-not-exist", + ], + ) + assert result.exit_code == 1, result.output + assert "Failed to delete does-not-exist" in result.output + assert "Resource does not exist" in result.output diff --git a/tests/ado/describe/test_ado_describe.py b/tests/ado/describe/test_ado_describe.py index a246525ec..0f4472c4b 100644 --- a/tests/ado/describe/test_ado_describe.py +++ b/tests/ado/describe/test_ado_describe.py @@ -4,7 +4,9 @@ import os import pathlib from collections.abc import Callable +from urllib.parse import unquote, urlparse +import pytest from testcontainers.mysql import MySqlContainer from typer.testing import CliRunner @@ -126,3 +128,205 @@ def test_describe_use_latest_rejected_for_experiment() -> None: runner = CliRunner() result = runner.invoke(ado, ["describe", "experiment", "--use-latest"]) assert result.exit_code == 1 + + +def test_describe_document( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + sql_store: SQLStore, +) -> None: + """Describe writes raw markdown when stdout is not a terminal.""" + from ado.core.document.config import DocumentConfiguration + from ado.core.document.resource import DocumentResource + + content = "# Operation report\n\nExample body for describe." + config = DocumentConfiguration( + content=content, + relatedResources=["operation-test-12345678"], + metadata={"name": "Describe test report"}, + ) + resource = DocumentResource(config=config) + sql_store.addResource(resource) + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "describe", + "document", + resource.identifier, + ], + ) + assert result.exit_code == 0, result.output + assert result.output == f"{content}\n" + + +def test_describe_document_terminal_renders_markdown( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + sql_store: SQLStore, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Describe renders markdown with rich when stdout is a terminal.""" + from typer.testing import _NamedTextIOWrapper + + from ado.core.document.config import DocumentConfiguration + from ado.core.document.resource import DocumentResource + + # CliRunner replaces sys.stdout; patch the wrapper class it installs. + monkeypatch.setattr(_NamedTextIOWrapper, "isatty", lambda self: True) + + config = DocumentConfiguration( + content="# Operation report\n\nExample body for describe.", + relatedResources=["operation-test-12345678"], + metadata={"name": "Describe test report"}, + ) + resource = DocumentResource(config=config) + sql_store.addResource(resource) + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "describe", + "document", + resource.identifier, + ], + ) + assert result.exit_code == 0, result.output + assert resource.identifier in result.output + assert "Operation report" in result.output + assert "# Operation report" not in result.output + assert "Example body for describe" in result.output + + +def test_describe_document_html_redirect_prints_content( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + sql_store: SQLStore, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Describe writes raw HTML when stdout is not a terminal.""" + from ado.core.document.config import DocumentConfiguration + from ado.core.document.resource import DocumentResource + + html_body = ( + "

HTML report

Opened via describe.

" + ) + config = DocumentConfiguration( + content=html_body, + contentType="html", + metadata={"name": "HTML describe test"}, + ) + resource = DocumentResource(config=config) + sql_store.addResource(resource) + + opened_urls: list[str] = [] + + def _fake_open(url: str) -> bool: + opened_urls.append(url) + return True + + monkeypatch.setattr( + "ado.cli.resources.document.describe.webbrowser.open", _fake_open + ) + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "describe", + "document", + resource.identifier, + ], + ) + assert result.exit_code == 0, result.output + assert result.output == f"{html_body}\n" + assert opened_urls == [] + + +def test_describe_document_html_opens_browser( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + sql_store: SQLStore, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Describe opens HTML document content in the default browser on a TTY.""" + from typer.testing import _NamedTextIOWrapper + + from ado.core.document.config import DocumentConfiguration + from ado.core.document.resource import DocumentResource + + # CliRunner replaces sys.stdout; patch the wrapper class it installs. + monkeypatch.setattr(_NamedTextIOWrapper, "isatty", lambda self: True) + + html_body = ( + "

HTML report

Opened via describe.

" + ) + config = DocumentConfiguration( + content=html_body, + contentType="html", + metadata={"name": "HTML describe test"}, + ) + resource = DocumentResource(config=config) + sql_store.addResource(resource) + + opened_urls: list[str] = [] + + def _fake_open(url: str) -> bool: + opened_urls.append(url) + return True + + monkeypatch.setattr( + "ado.cli.resources.document.describe.webbrowser.open", _fake_open + ) + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "describe", + "document", + resource.identifier, + ], + ) + assert result.exit_code == 0, result.output + assert resource.identifier in result.output + assert "html" in result.output + assert "Opened HTML document content" in result.output + assert len(opened_urls) == 1 + assert opened_urls[0].startswith("file://") + opened_path = pathlib.Path(unquote(urlparse(opened_urls[0]).path)) + assert opened_path.read_text(encoding="utf-8") == html_body diff --git a/tests/core/document/conftest.py b/tests/core/document/conftest.py new file mode 100644 index 000000000..ce3bb147f --- /dev/null +++ b/tests/core/document/conftest.py @@ -0,0 +1,18 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import pathlib + +import pytest + + +@pytest.fixture +def document_configuration_file() -> pathlib.Path: + """Return path to a valid document configuration fixture.""" + return pathlib.Path("tests/fixtures/document.yaml") + + +@pytest.fixture +def document_html_configuration_file() -> pathlib.Path: + """Return path to a valid HTML document configuration fixture.""" + return pathlib.Path("tests/fixtures/document_html.yaml") diff --git a/tests/core/document/test_document_config.py b/tests/core/document/test_document_config.py new file mode 100644 index 000000000..1bdac2c17 --- /dev/null +++ b/tests/core/document/test_document_config.py @@ -0,0 +1,73 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import pathlib + +import pydantic +import pytest +import yaml + +from ado.core.document.config import DocumentConfiguration +from ado.utilities.output import pydantic_model_as_yaml + + +def test_document_configuration_requires_content() -> None: + """DocumentConfiguration requires content.""" + with pytest.raises(pydantic.ValidationError): + DocumentConfiguration.model_validate({}) + + +def test_document_configuration_default_content_type() -> None: + """DocumentConfiguration defaults contentType to markdown.""" + config = DocumentConfiguration(content="Example report") + assert config.contentType == "markdown" + + +def test_document_configuration_round_trip_yaml() -> None: + """DocumentConfiguration round-trips through YAML.""" + config = DocumentConfiguration( + content="# Report\n\nBody text", + contentType="markdown", + relatedResources=["operation-abc-12345678"], + metadata={"name": "Test report"}, + ) + yaml_text = pydantic_model_as_yaml(config) + restored = DocumentConfiguration.model_validate(yaml.safe_load(yaml_text)) + assert restored == config + + +def test_document_configuration_html_round_trip_yaml() -> None: + """DocumentConfiguration with HTML contentType round-trips through YAML.""" + config = DocumentConfiguration( + content="

Report

", + contentType="html", + relatedResources=["operation-abc-12345678"], + metadata={"name": "HTML report"}, + ) + yaml_text = pydantic_model_as_yaml(config) + restored = DocumentConfiguration.model_validate(yaml.safe_load(yaml_text)) + assert restored == config + assert restored.contentType == "html" + + +def test_document_configuration_from_fixture( + document_configuration_file: pathlib.Path, +) -> None: + """Document fixture validates as DocumentConfiguration.""" + config = DocumentConfiguration.model_validate( + yaml.safe_load(document_configuration_file.read_text()) + ) + assert config.content.startswith("# Operation report") + assert config.relatedResources == ["operation-test-12345678"] + assert config.contentType == "markdown" + + +def test_document_configuration_from_html_fixture( + document_html_configuration_file: pathlib.Path, +) -> None: + """HTML document fixture validates as DocumentConfiguration.""" + config = DocumentConfiguration.model_validate( + yaml.safe_load(document_html_configuration_file.read_text()) + ) + assert config.contentType == "html" + assert "

Operation report

" in config.content diff --git a/tests/core/document/test_document_resource.py b/tests/core/document/test_document_resource.py new file mode 100644 index 000000000..7f8c0723b --- /dev/null +++ b/tests/core/document/test_document_resource.py @@ -0,0 +1,18 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +from ado.core.document.config import DocumentConfiguration +from ado.core.document.resource import DocumentResource + + +def test_document_resource_lifecycle() -> None: + """DocumentResource round-trips through model_dump and model_validate.""" + config = DocumentConfiguration( + content="# Report\n\nExample body", + relatedResources=["operation-test-12345678"], + ) + resource = DocumentResource(config=config) + + dumped = resource.model_dump() + restored = DocumentResource.model_validate(dumped) + assert restored == resource diff --git a/tests/fixtures/document.yaml b/tests/fixtures/document.yaml new file mode 100644 index 000000000..64f928782 --- /dev/null +++ b/tests/fixtures/document.yaml @@ -0,0 +1,11 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT +metadata: + name: Example operation report + description: Summary report for an operation +content: | + # Operation report + + This is an example document resource. +relatedResources: + - operation-test-12345678 diff --git a/tests/fixtures/document_html.yaml b/tests/fixtures/document_html.yaml new file mode 100644 index 000000000..773710a5a --- /dev/null +++ b/tests/fixtures/document_html.yaml @@ -0,0 +1,10 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT +metadata: + name: Example HTML report + description: Summary report stored as HTML +contentType: html +content: | +

Operation report

Example HTML body.

+relatedResources: + - operation-test-12345678