From 3c906c3874841c96e0acd2beb19f2448734fe438 Mon Sep 17 00:00:00 2001 From: jasonsky-pub <275120179+jasonsky-pub@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:52:07 -0400 Subject: [PATCH 1/8] Add prompts JSON CLI OpenSpec change --- .../add-prompts-json-cli/.openspec.yaml | 2 + .../changes/add-prompts-json-cli/design.md | 141 ++++++++++++++++++ .../changes/add-prompts-json-cli/proposal.md | 29 ++++ .../specs/project-prompts-json/spec.md | 122 +++++++++++++++ .../changes/add-prompts-json-cli/tasks.md | 22 +++ 5 files changed, 316 insertions(+) create mode 100644 openspec/changes/add-prompts-json-cli/.openspec.yaml create mode 100644 openspec/changes/add-prompts-json-cli/design.md create mode 100644 openspec/changes/add-prompts-json-cli/proposal.md create mode 100644 openspec/changes/add-prompts-json-cli/specs/project-prompts-json/spec.md create mode 100644 openspec/changes/add-prompts-json-cli/tasks.md diff --git a/openspec/changes/add-prompts-json-cli/.openspec.yaml b/openspec/changes/add-prompts-json-cli/.openspec.yaml new file mode 100644 index 0000000..db47328 --- /dev/null +++ b/openspec/changes/add-prompts-json-cli/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-02 diff --git a/openspec/changes/add-prompts-json-cli/design.md b/openspec/changes/add-prompts-json-cli/design.md new file mode 100644 index 0000000..c0d562c --- /dev/null +++ b/openspec/changes/add-prompts-json-cli/design.md @@ -0,0 +1,141 @@ +## Context + +OpenPlate already has the core mechanics needed for machine-driven prompting: parameter resolution can fail instead of prompting, and sibling templates are keyed in project state by template source plus destination folder. What is missing is a structured CLI contract to export pending prompt state, let automation edit that state, and feed it back into `project init` and `project update` without scraping terminal output. + +This change needs to cross the CLI parser, prompt resolver, and recursive template walk. It also needs to fit the current processing model instead of introducing a parallel planner that fully evaluates conditions, config files, or future sibling selection ahead of time. + +## Goals / Non-Goals + +**Goals:** +- Add `--print-prompts-json` for `project init` and `project update` to emit the declared prompt document as JSON. +- Make `--print-prompts-json` a read-only planning/export mode that never writes files or updates project state. +- Add `--prompts-json-file` and `--prompts-json-stdin` for `project init` and `project update` to consume prompt answers from JSON and fail instead of prompting. +- Use one hierarchical JSON document for both export and import so automation can round-trip the output by editing `value` fields. +- Scope prompt answers to a template instance using the template reference and destination folder, with parameters nested under that template node. +- During `--print-prompts-json`, walk the full declared template tree without applying sibling `condition` filters. +- Include template `condition` metadata in printed JSON when present, but ignore it during import. +- Distinguish incomplete parameter discovery from truly parameterless templates in the printed JSON document. +- Reuse fetched template sources within a single command invocation so validation/discovery does not clone the same template source twice. +- Preserve current console-style error reporting while adding always-on warnings for extra supplied parameters and always-on ignored-template log messages. + +**Non-Goals:** +- Add a separate `--non-interactive` flag. +- Add a top-level JSON wrapper, schema version field, or alternate machine-readable error format. +- Fully precompute condition outcomes that depend on dynamic config files or later template state. +- Change the current runtime behavior where a duplicate sibling template declaration is ignored after the first matching template instance is processed. + +## Decisions + +### 1. JSON input flags imply no prompting + +`--prompts-json-file` and `--prompts-json-stdin` will act as the machine-driven mode for `project init` and `project update`. When either flag is used, OpenPlate will not call interactive prompt paths. Any unresolved parameter or template-command confirmation that would have prompted today will instead fail using the existing error flow. + +This keeps the public surface smaller than adding both JSON-input flags and a separate `--non-interactive` switch. + +Alternatives considered: +- Add `--non-interactive` anyway. Rejected because the JSON-input flags already imply the same policy and would duplicate configuration. +- Allow JSON-input modes to fall back to interactive prompts. Rejected because stdin cannot safely serve as both JSON input and prompt input, and fallback would make automation behavior less predictable. + +### 2. Print mode does full-tree discovery, execution modes do not + +`--print-prompts-json` will output a JSON array. Each entry represents one declared template instance and groups its parameters beneath it. The export is a read-only planning step: it must not write files, update `.openplate.project.yaml`, or mutate the working tree. + +`--print-prompts-json` is the only mode that performs full-tree discovery beyond the normal execution path. For that print-only discovery walk, OpenPlate will traverse declared sibling templates without using `condition` values to prune the tree. + +Normal init, update, and JSON-input execution will keep the existing condition-aware processing path. They will not perform a separate full-tree validation walk before doing real work. + +The printed JSON therefore reflects the declared template tree rather than the exact set of templates that a normal execution run will process. + +Each template node will include: +- `template`: the raw template reference string for that template instance +- `dest_folder`: the raw destination-folder string for that template instance +- `condition`: the raw condition string when the template declaration has one +- `parameters`: either an object keyed by parameter name or `null` when print-mode discovery could not enumerate parameter metadata for that node + +When `parameters` is an object, each parameter entry will include: +- `value` +- `default` +- `existing` +- `description` +- `choices` +- `hidden` +- `required` + +`value` semantics are explicit: +- `null` means no value has been supplied for this parameter in the prompt document +- `""` means an explicit blank string value +- omission of the `value` field in an imported parameter entry is invalid + +`required` is derived from current OpenPlate prompt behavior: a parameter is required when it has no resolved existing value, no resolved default value, and therefore would fail if JSON-input mode reaches it without a supplied value. + +If print-mode discovery can enumerate a template declaration but cannot load that template's config closely enough to discover parameter metadata during export, OpenPlate will still emit the template node using the preserved raw `template`, `dest_folder`, and optional `condition`, and it will emit `parameters` as `null`. + +Import will accept the same structure. During import, OpenPlate will use only the template identity fields and each parameter's `value`. A template node with `parameters: null` is treated as having no supplied parameter values. Other fields are treated as export metadata and ignored if returned, including `condition`. + +Alternatives considered: +- Use a flat list of parameter rows. Rejected because it duplicates template identity on every entry and makes sibling targeting harder to read. +- Add a root object with schema metadata. Rejected because the user wants the JSON shape to start directly at the template list. + +### 3. Template identity stays raw and is keyed by template plus destination folder + +Printed JSON will use the templated strings rather than rendered values for sibling template references and destination folders. This keeps the contract aligned with template configuration as written and avoids making the export format depend on partial rendering state. + +Because the current walk mutates sibling declarations as it renders them, the implementation must preserve the original raw template reference, raw destination-folder string, and raw condition before any rendering or in-place mutation occurs. JSON export and JSON-input matching will use those preserved raw values. + +For import validation, template-instance uniqueness is the pair `(template, dest_folder)`. Duplicate entries with the same pair in supplied JSON will be rejected before processing begins. + +If print-mode discovery encounters the same raw `(template, dest_folder)` pair more than once, export will keep only the first discovered node so the emitted JSON remains valid input for later import. + +Alternatives considered: +- Print rendered template identifiers. Rejected because the rendered value may depend on partial state and is harder to keep stable during prompt editing. +- Add a synthetic template-instance identifier. Rejected because the project already has a natural identity boundary and a new identifier would need separate lifecycle rules. + +### 4. Validation is incremental, with duplicate errors, ignored unused templates, and warnings for extra parameters + +OpenPlate will validate supplied JSON against the actual template instances that are processed during init or update. + +- If supplied JSON contains a duplicate template node, OpenPlate errors before processing. +- If supplied JSON contains a template node that is never processed, OpenPlate ignores that node and prints an always-on log message identifying the raw `template` and `dest_folder` that were ignored. +- If supplied JSON contains parameter values that are not needed by the matched template instance, OpenPlate collects warnings and prints them at the end even when debug logging is disabled. + +This approach avoids a larger refactor to fully predict selection outcomes up front, especially for conditions that can depend on config files or later template state. + +The full declared tree exported by `--print-prompts-json` is broader than the set of templates that may actually run. During execution, OpenPlate will still validate supplied JSON against the templates that are actually processed and will log ignored-template messages for template nodes that remain unused by the end of the run. + +Alternatives considered: +- Fully evaluate template selection before processing. Rejected because it would require broader planning/reflection support than the current pipeline provides. +- Silently ignore extra templates or parameters. Rejected because it would hide stale automation inputs and make prompt documents harder to trust. + +### 5. Duplicate discovered siblings retain current first-match behavior + +If the existing template walk encounters the same sibling template instance more than once, OpenPlate will continue to use the first discovered instance and ignore later duplicates. The new JSON features will follow that same runtime behavior rather than changing sibling-resolution semantics in this change. Print-mode export will mirror that rule by collapsing duplicate discovered nodes to the first raw `(template, dest_folder)` pair. + +Alternatives considered: +- Fail runtime processing when duplicate sibling declarations are discovered. Rejected because it changes existing behavior and is broader than the JSON prompt contract. + +### 6. One command invocation must not fetch the same source twice for validation and execution + +Within a single command invocation, OpenPlate must reuse fetched or cloned template sources between prompt discovery, prompt validation, and actual processing. JSON-input execution must validate against the same fetched sources that the command will use for real init or update work, rather than doing a pre-validation fetch and then fetching the same repo again for execution. + +This keeps JSON-driven runs from paying the cost of a redundant clone/fetch and avoids introducing extra source-state drift within one command. + +Alternatives considered: +- Add a separate preflight validation pass that clones sources before the real run. Rejected because it duplicates work and creates the exact double-pull behavior the user wants to avoid. + +## Risks / Trade-offs + +- [Print-mode full-tree discovery may include templates not processed later] -> Keep full-tree discovery limited to `--print-prompts-json` and log ignored-template messages rather than failing when exported-but-unused nodes are returned. +- [Raw template identity may be less convenient for humans than rendered values] -> Keep the JSON stable for round-tripping and include descriptions/defaults so the editable fields stay obvious. +- [Export may encounter declarations whose parameter metadata cannot be loaded during print discovery] -> Emit those nodes with preserved raw identity and `parameters: null` so automation can distinguish incomplete discovery from truly parameterless templates. +- [Warnings for extra parameters could be noisy in stale automation documents] -> Limit warnings to actually supplied but unused parameter values and keep the text actionable. +- [JSON-input modes affect both parameter prompts and template-command confirmations] -> Document that these flags imply no prompting of any kind, not only parameter entry. + +## Migration Plan + +- Add the new flags without changing existing interactive init or update flows when the JSON flags are not used. +- Document the read-only export/import JSON workflow for automation and AI-assisted runs, including that full-tree discovery is print-only, runtime validation uses actual processed templates, and template sources are reused within a command. +- Keep existing human-readable error messages, with new validation and warning text layered onto the current failure paths. + +## Open Questions + +None at this time. \ No newline at end of file diff --git a/openspec/changes/add-prompts-json-cli/proposal.md b/openspec/changes/add-prompts-json-cli/proposal.md new file mode 100644 index 0000000..f0bfa11 --- /dev/null +++ b/openspec/changes/add-prompts-json-cli/proposal.md @@ -0,0 +1,29 @@ +## Why + +OpenPlate can already stop when a prompt would be required, but it does not give automation a structured way to discover pending prompts or feed answers back in for `project init` and `project update`. That forces AI and CI workflows to scrape terminal text, guess sibling template identity, or fall back to interactive runs. + +## What Changes + +- Add JSON-based prompt export for `project init` and `project update` with `--print-prompts-json`. +- Add JSON-based prompt input for `project init` and `project update` with `--prompts-json-file` and `--prompts-json-stdin`. +- Define a hierarchical JSON format grouped by template reference and destination folder, with parameter values nested inside each template node. +- Make `--print-prompts-json` a read-only planning/export mode that does not write project files or mutate workspace state. +- Make `--print-prompts-json` the only mode that walks the full declared template tree without applying sibling `condition` filters. +- Include template `condition` metadata in printed JSON output for visibility, while ignoring that field on import. +- Require JSON-input modes to fail instead of prompting when required values are still unresolved. +- Warn when supplied JSON contains extra parameters that were not needed, and print a log message including the template and destination folder when a template instance from the JSON is ignored because it is not processed by the run. +- Reject duplicate template-instance entries that use the same template reference and destination folder. +- Define `value: null` as unspecified, `value: ""` as an intentional blank string, and omission of `value` in an imported parameter entry as invalid. +- When print-mode can discover a template declaration but cannot inspect its parameter metadata, emit `parameters: null` so automation can distinguish incomplete discovery from templates that truly have no parameters. +- Reuse fetched template sources within a command invocation so OpenPlate does not pull a repo once for prompt validation and then pull it again for actual processing. + +## Capabilities + +### New Capabilities +- `project-prompts-json`: Defines prompt discovery and prompt input through JSON for `project init` and `project update`, including template-instance scoping, validation, and warning behavior. + +### Modified Capabilities + +## Impact + +Affected areas include CLI argument parsing in `src/openplate/__main__.py`, prompt resolution in `src/openplate/project_config_resolver.py`, recursive sibling-template walking in `src/openplate/walk/source_template_recursive_walk.py`, template source reuse for prompt discovery and execution, prompt JSON serialization/deserialization helpers, and documentation for machine-driven init and update workflows. \ No newline at end of file diff --git a/openspec/changes/add-prompts-json-cli/specs/project-prompts-json/spec.md b/openspec/changes/add-prompts-json-cli/specs/project-prompts-json/spec.md new file mode 100644 index 0000000..645a00a --- /dev/null +++ b/openspec/changes/add-prompts-json-cli/specs/project-prompts-json/spec.md @@ -0,0 +1,122 @@ +## ADDED Requirements + +### Requirement: OpenPlate prints prompt state as template-grouped JSON +OpenPlate SHALL support `--print-prompts-json` for `project init` and `project update`. The command SHALL print a JSON array of template nodes instead of interactive prompt text for the declared prompt document. + +`--print-prompts-json` SHALL be a read-only planning/export mode. It MUST NOT write files, update project configuration, or otherwise mutate workspace state. + +`--print-prompts-json` SHALL be the only mode that walks the full declared template tree without applying sibling `condition` filters. The printed JSON SHALL include the full declared template tree that can be discovered from the template configuration, including conditional sibling declarations. This export MAY include template nodes that later execution does not process. + +Each template node SHALL include `template`, `dest_folder`, and `parameters`. If the template declaration includes a condition, the node SHALL also include `condition`. Template and destination values in this JSON SHALL use the raw templated strings rather than rendered values. + +When OpenPlate can inspect a declared template node closely enough to enumerate parameter metadata during `--print-prompts-json`, `parameters` SHALL be an object keyed by parameter name. Each parameter entry SHALL include `value`, `default`, `existing`, `description`, `choices`, `hidden`, and `required` so the printed document can be edited and sent back through the import flow. + +If print-mode discovery can enumerate a template declaration but cannot load that template's config closely enough to discover parameter metadata during export, OpenPlate SHALL still include the template node and SHALL emit `parameters` as `null`. + +In printed and imported parameter entries, `value: null` SHALL mean no value has been supplied, `value: ""` SHALL mean an explicit blank string, and omission of the `value` field SHALL be invalid on import. + +#### Scenario: Print prompts JSON for init +- **WHEN** a user runs `openplate project init --print-prompts-json` +- **THEN** OpenPlate prints a JSON array of template nodes for the declared init template tree +- **AND** each template node groups its parameters beneath the template and destination fields + +#### Scenario: Print prompts JSON without mutating the workspace +- **WHEN** a user runs `openplate project update --print-prompts-json` +- **THEN** OpenPlate does not write project files or template output files +- **AND** OpenPlate does not update `.openplate.project.yaml` as part of printing the JSON document + +#### Scenario: Include conditional sibling declarations in export +- **WHEN** a template declares a sibling template behind a condition +- **THEN** OpenPlate includes that sibling template node in the printed JSON tree +- **AND** `--print-prompts-json` does not use that condition to prune the export tree + +#### Scenario: Export unresolved declaration with null parameters +- **WHEN** print-mode discovery can identify a template declaration but cannot load that template's config to enumerate its parameter metadata +- **THEN** OpenPlate still includes the template node in the printed JSON +- **AND** OpenPlate emits `parameters` as `null` for that node + +#### Scenario: Print condition metadata without requiring round-trip use +- **WHEN** a template declaration includes a `condition` +- **THEN** OpenPlate includes that raw `condition` string in the printed JSON node +- **AND** the printed `condition` field is treated as informational metadata rather than required input + +### Requirement: OpenPlate accepts prompt answers from JSON without prompting +OpenPlate SHALL support `--prompts-json-file ` and `--prompts-json-stdin` for `project init` and `project update`. When either flag is used, OpenPlate MUST consume prompt answers from the provided JSON document and MUST NOT fall back to interactive prompting. + +`--prompts-json-file` and `--prompts-json-stdin` SHALL use the normal runtime execution walk. They MUST NOT switch to the full-tree, condition-ignoring discovery behavior that is reserved for `--print-prompts-json`. + +If required values remain unresolved after applying supplied JSON, OpenPlate MUST fail instead of prompting. OpenPlate MAY fail on the first unresolved prompt rather than aggregating all missing values. + +If template-command confirmation would have prompted during the run and the command has not otherwise been authorized, OpenPlate MUST fail instead of prompting. + +Within a single command invocation, OpenPlate MUST NOT fetch or clone the same template source once for prompt validation and then again for actual processing. JSON-input execution MUST reuse fetched template sources for validation and execution. + +#### Scenario: Supply answers from a file +- **WHEN** a user runs `openplate project update --prompts-json-file prompts.json` +- **THEN** OpenPlate reads prompt answers from `prompts.json` +- **AND** OpenPlate does not prompt for missing values during that run + +#### Scenario: Supply answers from stdin +- **WHEN** a user pipes a prompts JSON document to `openplate project init --prompts-json-stdin` +- **THEN** OpenPlate reads prompt answers from standard input +- **AND** OpenPlate does not reuse standard input as an interactive prompt channel + +#### Scenario: Missing required value in JSON mode +- **WHEN** JSON-input mode is active and a required parameter still has no resolved value +- **THEN** OpenPlate fails +- **AND** OpenPlate does not ask the user for that value interactively + +#### Scenario: JSON input uses the normal runtime walk +- **WHEN** a user runs `openplate project update --prompts-json-file prompts.json` +- **THEN** OpenPlate evaluates sibling conditions as part of the normal runtime execution walk +- **AND** OpenPlate does not switch to the print-only full-tree discovery behavior + +#### Scenario: Validation and execution reuse the same source fetch +- **WHEN** a user runs `openplate project init --prompts-json-file prompts.json` +- **THEN** OpenPlate validates prompt input and processes that command without fetching or cloning the same template source twice + +### Requirement: OpenPlate validates supplied prompt documents by template instance +OpenPlate SHALL match supplied prompt answers to template instances using the pair `(template, dest_folder)`. The imported JSON document SHALL use the same hierarchical structure that `--print-prompts-json` emits. + +During import, OpenPlate MUST use each parameter entry's `value` field as the supplied answer. Other metadata fields in template nodes and parameter entries MAY be present and MUST be ignored for import semantics. + +During import, a template node with `parameters: null` MUST be treated as having no supplied parameter values. + +During import, OpenPlate MUST reject a parameter entry that omits the `value` field. + +OpenPlate MUST reject a supplied JSON document that contains duplicate template nodes with the same `(template, dest_folder)` pair. + +If `--print-prompts-json` discovers the same raw `(template, dest_folder)` pair more than once, OpenPlate SHALL emit only the first such node in the exported JSON document. + +If a supplied template node does not correspond to a template instance that is actually processed, OpenPlate MUST ignore that supplied node and MUST print a log message that identifies the raw `template` and `dest_folder` that were ignored. If supplied parameter values are present for a matched template instance but are not needed during processing, OpenPlate MUST print warnings for those unused parameters even when debug logging is disabled. + +#### Scenario: Round-trip edited prompt document +- **WHEN** a user edits only `value` fields in a document previously printed by `--print-prompts-json` +- **THEN** OpenPlate accepts the document as prompt input +- **AND** OpenPlate ignores unchanged metadata fields such as `condition`, `description`, `default`, and `existing` + +#### Scenario: Omitted value field is invalid +- **WHEN** a supplied JSON parameter entry omits the `value` field +- **THEN** OpenPlate fails validation for the imported prompt document + +#### Scenario: Blank string is treated as an explicit value +- **WHEN** a supplied JSON parameter entry sets `value` to `""` +- **THEN** OpenPlate treats that parameter as explicitly supplied with a blank string value + +#### Scenario: Duplicate template entries in supplied JSON +- **WHEN** a supplied JSON document contains two template nodes with the same `template` and `dest_folder` +- **THEN** OpenPlate fails validation before template processing begins + +#### Scenario: Export collapses duplicate discovered template nodes +- **WHEN** `--print-prompts-json` discovers the same raw `template` and `dest_folder` pair more than once +- **THEN** OpenPlate emits only the first discovered node for that pair in the exported JSON document + +#### Scenario: Supplied template is not processed +- **WHEN** a supplied JSON document includes a template node that is never processed by the run +- **THEN** OpenPlate ignores that supplied template node +- **AND** OpenPlate prints a log message identifying the raw `template` and `dest_folder` that were ignored + +#### Scenario: Supplied parameter is not needed +- **WHEN** a supplied JSON document includes a parameter value for a matched template instance and that parameter is not needed during processing +- **THEN** OpenPlate completes normal processing behavior for the template instance +- **AND** OpenPlate prints a warning about the unused supplied parameter \ No newline at end of file diff --git a/openspec/changes/add-prompts-json-cli/tasks.md b/openspec/changes/add-prompts-json-cli/tasks.md new file mode 100644 index 0000000..7696f68 --- /dev/null +++ b/openspec/changes/add-prompts-json-cli/tasks.md @@ -0,0 +1,22 @@ +## 1. CLI and prompt document plumbing + +- [ ] 1.1 Add `--print-prompts-json`, `--prompts-json-file`, and `--prompts-json-stdin` to `project init` and `project update`, including argument validation so the JSON-input flags imply non-interactive behavior. +- [ ] 1.2 Introduce a prompt-document model and JSON serialization/deserialization helpers for the template-grouped array format used by export and import. +- [ ] 1.3 Reject duplicate supplied template nodes up front by validating the `(template, dest_folder)` pair before template processing begins. +- [ ] 1.4 Preserve raw sibling template declarations before rendering so prompt export and JSON-input matching can use the original `template`, `dest_folder`, and `condition` strings. +- [ ] 1.5 Add per-command source reuse for prompt discovery, validation, and execution so the same template source is not fetched twice within one invocation. + +## 2. Prompt export and import behavior + +- [ ] 2.1 Implement `--print-prompts-json` as a read-only planning mode that performs the full print-only tree walk without applying sibling `condition` filters, emits raw `template`, raw `dest_folder`, optional `condition`, and never writes project state. +- [ ] 2.2 During print-mode discovery, emit `parameters: null` for template declarations whose configs cannot be loaded closely enough to enumerate parameter metadata, so exported JSON distinguishes incomplete discovery from truly parameterless templates. +- [ ] 2.3 Deduplicate exported template nodes by raw `(template, dest_folder)` using first-match behavior so exported JSON remains valid input. +- [ ] 2.4 Thread imported prompt values into parameter resolution so JSON input uses parameter `value` fields, distinguishes `null` from `""`, rejects omitted `value` fields, and never falls back to interactive prompting. +- [ ] 2.5 Ensure JSON-input modes stay on the normal runtime walk, fail on unresolved parameters or template-command confirmations instead of prompting, and do not switch to the print-only full-tree discovery behavior. + +## 3. Validation, warnings, and coverage + +- [ ] 3.1 Track supplied template and parameter usage during processing so unmatched template nodes produce always-on ignored-template log messages and unused supplied parameters produce always-on warnings. +- [ ] 3.2 Preserve the existing first-match runtime behavior for duplicate discovered sibling templates while keeping export deduplication and import validation strict for duplicate supplied template nodes. +- [ ] 3.3 Add focused tests for read-only prompt JSON export, print-only full-tree export without applying conditions, `parameters: null` declaration nodes, export deduplication, JSON import from file and stdin, duplicate-template validation, ignored-template log messages, omitted-value failures, blank-string handling, unused-parameter warnings, single-fetch reuse, and unresolved-value failure paths. +- [ ] 3.4 Update command documentation with the JSON round-trip workflow for machine-driven `project init` and `project update`. \ No newline at end of file From be3c58c0adf5b6f8cbf2c69c1658c42871a1b9fe Mon Sep 17 00:00:00 2001 From: jasonsky-pub <275120179+jasonsky-pub@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:59:47 -0400 Subject: [PATCH 2/8] Implement prompts JSON CLI workflow --- docs/commands.md | 42 ++ .../add-prompts-json-cli/.openspec.yaml | 18 + .../changes/add-prompts-json-cli/tasks.md | 28 +- .../.openspec.yaml | 18 + openspec/config.yaml | 18 + src/openplate/__main__.py | 63 +- src/openplate/cfg/project_config.py | 32 +- src/openplate/commands/project_init.py | 33 +- src/openplate/commands/project_update.py | 34 +- src/openplate/project_config_resolver.py | 134 ++-- src/openplate/project_metadata_resolver.py | 75 +++ src/openplate/project_template_identity.py | 42 ++ src/openplate/prompts/__init__.py | 19 + src/openplate/prompts/prompt_document.py | 269 ++++++++ src/openplate/prompts/prompt_document_cli.py | 62 ++ .../prompts/prompt_document_collector.py | 213 ++++++ src/openplate/prompts/prompt_input_logging.py | 34 + .../prompts/prompt_parameter_resolver.py | 249 +++++++ src/openplate/sibling_template_resolver.py | 126 ++++ src/openplate/sources/source_cache.py | 70 ++ .../walk/source_template_recursive_walk.py | 76 +-- tests/test_existing_runtime_regressions.py | 257 ++++++++ tests/test_project_init_source_urls.py | 124 +++- tests/test_prompt_document.py | 174 +++++ tests/test_prompt_json_cli.py | 617 ++++++++++++++++++ 25 files changed, 2646 insertions(+), 181 deletions(-) create mode 100644 src/openplate/project_metadata_resolver.py create mode 100644 src/openplate/project_template_identity.py create mode 100644 src/openplate/prompts/__init__.py create mode 100644 src/openplate/prompts/prompt_document.py create mode 100644 src/openplate/prompts/prompt_document_cli.py create mode 100644 src/openplate/prompts/prompt_document_collector.py create mode 100644 src/openplate/prompts/prompt_input_logging.py create mode 100644 src/openplate/prompts/prompt_parameter_resolver.py create mode 100644 src/openplate/sibling_template_resolver.py create mode 100644 src/openplate/sources/source_cache.py create mode 100644 tests/test_existing_runtime_regressions.py create mode 100644 tests/test_prompt_document.py create mode 100644 tests/test_prompt_json_cli.py diff --git a/docs/commands.md b/docs/commands.md index b1fd189..ad5bc8a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -77,6 +77,48 @@ openplate update The legacy nested `project` variant still works for compatibility, but `openplate update` is the documented command. +## Prompt JSON Workflow + +For machine-driven `init` and `update` runs, OpenPlate can print the prompt state as JSON, let you fill in only the `value` fields you care about, and then consume that JSON without falling back to interactive prompting. + +Export the declared prompt tree: + +``` +openplate init https://github.com/my-org/ot-template.git#v1 --print-prompts-json +openplate update --print-prompts-json +``` + +Import answers from a file or standard input: + +``` +openplate init https://github.com/my-org/ot-template.git#v1 --prompts-json-file prompts.json +openplate update --prompts-json-file prompts.json +type prompts.json | openplate init https://github.com/my-org/ot-template.git#v1 --prompts-json-stdin +``` + +The printed document is a top-level JSON array grouped by template instance. Each template node includes: + +- `template`: the raw template reference for that template instance +- `dest_folder`: the raw destination-folder string for that template instance +- `condition`: included when the template declaration has one +- `parameters`: either an object keyed by parameter name or `null` when OpenPlate cannot inspect that template closely enough to enumerate parameter metadata + +Each parameter entry includes `value`, `default`, `existing`, `description`, `choices`, `hidden`, and `required`. + +`value` semantics: + +- `null` means no answer was supplied in the prompt document +- `""` means an intentional blank string +- omitting `value` is invalid on import + +Notes: + +- `--print-prompts-json` is read-only. It does not update `.openplate.project.yaml` or write template output. +- `--print-prompts-json` is the only mode that walks the full declared sibling tree without applying sibling `condition` filters. +- `--prompts-json-file` and `--prompts-json-stdin` stay on the normal runtime walk and fail instead of prompting if required values or template-command confirmations are still unresolved. +- OpenPlate ignores extra template nodes that are not processed by the run and logs which raw `template` and `dest_folder` entries were ignored. +- OpenPlate warns when supplied parameter values are left unused for a matched template instance. + ## Command: project verify Verify that the project has not drifted from the template. Exit with code -1 if so. diff --git a/openspec/changes/add-prompts-json-cli/.openspec.yaml b/openspec/changes/add-prompts-json-cli/.openspec.yaml index db47328..cfba737 100644 --- a/openspec/changes/add-prompts-json-cli/.openspec.yaml +++ b/openspec/changes/add-prompts-json-cli/.openspec.yaml @@ -1,2 +1,20 @@ +## +## Copyright 2025 Comcast Cable Communications Management, LLC +## +## Licensed under the Apache License, Version 2.0 (the "License"); +## you may not use this file except in compliance with the License. +## You may obtain a copy of the License at +## +## http://www.apache.org/licenses/LICENSE-2.0 +## +## Unless required by applicable law or agreed to in writing, software +## distributed under the License is distributed on an "AS IS" BASIS, +## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +## See the License for the specific language governing permissions and +## limitations under the License. +## +## SPDX-License-Identifier: Apache-2.0 +## +## This product includes software developed at Comcast (https://www.comcast.com/).## schema: spec-driven created: 2026-06-02 diff --git a/openspec/changes/add-prompts-json-cli/tasks.md b/openspec/changes/add-prompts-json-cli/tasks.md index 7696f68..9058ca7 100644 --- a/openspec/changes/add-prompts-json-cli/tasks.md +++ b/openspec/changes/add-prompts-json-cli/tasks.md @@ -1,22 +1,22 @@ ## 1. CLI and prompt document plumbing -- [ ] 1.1 Add `--print-prompts-json`, `--prompts-json-file`, and `--prompts-json-stdin` to `project init` and `project update`, including argument validation so the JSON-input flags imply non-interactive behavior. -- [ ] 1.2 Introduce a prompt-document model and JSON serialization/deserialization helpers for the template-grouped array format used by export and import. -- [ ] 1.3 Reject duplicate supplied template nodes up front by validating the `(template, dest_folder)` pair before template processing begins. -- [ ] 1.4 Preserve raw sibling template declarations before rendering so prompt export and JSON-input matching can use the original `template`, `dest_folder`, and `condition` strings. -- [ ] 1.5 Add per-command source reuse for prompt discovery, validation, and execution so the same template source is not fetched twice within one invocation. +- [x] 1.1 Add `--print-prompts-json`, `--prompts-json-file`, and `--prompts-json-stdin` to `project init` and `project update`, including argument validation so the JSON-input flags imply non-interactive behavior. +- [x] 1.2 Introduce a prompt-document model and JSON serialization/deserialization helpers for the template-grouped array format used by export and import. +- [x] 1.3 Reject duplicate supplied template nodes up front by validating the `(template, dest_folder)` pair before template processing begins. +- [x] 1.4 Preserve raw sibling template declarations before rendering so prompt export and JSON-input matching can use the original `template`, `dest_folder`, and `condition` strings. +- [x] 1.5 Add per-command source reuse for prompt discovery, validation, and execution so the same template source is not fetched twice within one invocation. ## 2. Prompt export and import behavior -- [ ] 2.1 Implement `--print-prompts-json` as a read-only planning mode that performs the full print-only tree walk without applying sibling `condition` filters, emits raw `template`, raw `dest_folder`, optional `condition`, and never writes project state. -- [ ] 2.2 During print-mode discovery, emit `parameters: null` for template declarations whose configs cannot be loaded closely enough to enumerate parameter metadata, so exported JSON distinguishes incomplete discovery from truly parameterless templates. -- [ ] 2.3 Deduplicate exported template nodes by raw `(template, dest_folder)` using first-match behavior so exported JSON remains valid input. -- [ ] 2.4 Thread imported prompt values into parameter resolution so JSON input uses parameter `value` fields, distinguishes `null` from `""`, rejects omitted `value` fields, and never falls back to interactive prompting. -- [ ] 2.5 Ensure JSON-input modes stay on the normal runtime walk, fail on unresolved parameters or template-command confirmations instead of prompting, and do not switch to the print-only full-tree discovery behavior. +- [x] 2.1 Implement `--print-prompts-json` as a read-only planning mode that performs the full print-only tree walk without applying sibling `condition` filters, emits raw `template`, raw `dest_folder`, optional `condition`, and never writes project state. +- [x] 2.2 During print-mode discovery, emit `parameters: null` for template declarations whose configs cannot be loaded closely enough to enumerate parameter metadata, so exported JSON distinguishes incomplete discovery from truly parameterless templates. +- [x] 2.3 Deduplicate exported template nodes by raw `(template, dest_folder)` using first-match behavior so exported JSON remains valid input. +- [x] 2.4 Thread imported prompt values into parameter resolution so JSON input uses parameter `value` fields, distinguishes `null` from `""`, rejects omitted `value` fields, and never falls back to interactive prompting. +- [x] 2.5 Ensure JSON-input modes stay on the normal runtime walk, fail on unresolved parameters or template-command confirmations instead of prompting, and do not switch to the print-only full-tree discovery behavior. ## 3. Validation, warnings, and coverage -- [ ] 3.1 Track supplied template and parameter usage during processing so unmatched template nodes produce always-on ignored-template log messages and unused supplied parameters produce always-on warnings. -- [ ] 3.2 Preserve the existing first-match runtime behavior for duplicate discovered sibling templates while keeping export deduplication and import validation strict for duplicate supplied template nodes. -- [ ] 3.3 Add focused tests for read-only prompt JSON export, print-only full-tree export without applying conditions, `parameters: null` declaration nodes, export deduplication, JSON import from file and stdin, duplicate-template validation, ignored-template log messages, omitted-value failures, blank-string handling, unused-parameter warnings, single-fetch reuse, and unresolved-value failure paths. -- [ ] 3.4 Update command documentation with the JSON round-trip workflow for machine-driven `project init` and `project update`. \ No newline at end of file +- [x] 3.1 Track supplied template and parameter usage during processing so unmatched template nodes produce always-on ignored-template log messages and unused supplied parameters produce always-on warnings. +- [x] 3.2 Preserve the existing first-match runtime behavior for duplicate discovered sibling templates while keeping export deduplication and import validation strict for duplicate supplied template nodes. +- [x] 3.3 Add focused tests for read-only prompt JSON export, print-only full-tree export without applying conditions, `parameters: null` declaration nodes, export deduplication, JSON import from file and stdin, duplicate-template validation, ignored-template log messages, omitted-value failures, blank-string handling, unused-parameter warnings, single-fetch reuse, and unresolved-value failure paths. +- [x] 3.4 Update command documentation with the JSON round-trip workflow for machine-driven `project init` and `project update`. \ No newline at end of file diff --git a/openspec/changes/simplify-project-init-sources/.openspec.yaml b/openspec/changes/simplify-project-init-sources/.openspec.yaml index db47328..cfba737 100644 --- a/openspec/changes/simplify-project-init-sources/.openspec.yaml +++ b/openspec/changes/simplify-project-init-sources/.openspec.yaml @@ -1,2 +1,20 @@ +## +## Copyright 2025 Comcast Cable Communications Management, LLC +## +## Licensed under the Apache License, Version 2.0 (the "License"); +## you may not use this file except in compliance with the License. +## You may obtain a copy of the License at +## +## http://www.apache.org/licenses/LICENSE-2.0 +## +## Unless required by applicable law or agreed to in writing, software +## distributed under the License is distributed on an "AS IS" BASIS, +## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +## See the License for the specific language governing permissions and +## limitations under the License. +## +## SPDX-License-Identifier: Apache-2.0 +## +## This product includes software developed at Comcast (https://www.comcast.com/).## schema: spec-driven created: 2026-06-02 diff --git a/openspec/config.yaml b/openspec/config.yaml index 392946c..3fef929 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -1,3 +1,21 @@ +## +## Copyright 2025 Comcast Cable Communications Management, LLC +## +## Licensed under the Apache License, Version 2.0 (the "License"); +## you may not use this file except in compliance with the License. +## You may obtain a copy of the License at +## +## http://www.apache.org/licenses/LICENSE-2.0 +## +## Unless required by applicable law or agreed to in writing, software +## distributed under the License is distributed on an "AS IS" BASIS, +## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +## See the License for the specific language governing permissions and +## limitations under the License. +## +## SPDX-License-Identifier: Apache-2.0 +## +## This product includes software developed at Comcast (https://www.comcast.com/).## schema: spec-driven # Project context (optional) diff --git a/src/openplate/__main__.py b/src/openplate/__main__.py index 5a5d5e7..b82d9ad 100644 --- a/src/openplate/__main__.py +++ b/src/openplate/__main__.py @@ -23,6 +23,37 @@ import platform import sys +from openplate import __semver__ as module_semver +from openplate import __version__ as module_version +from openplate.cfg import open_plate_settings +from openplate.cfg.open_plate_settings import OpenPlateRuntimeSettings +from openplate.cfg.project_config import ProjectTemplateConfig +from openplate.prompts.prompt_document_cli import add_prompt_document_arguments, load_prompt_document as load_prompt_document_from_args +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +import argparse +import asyncio +import logging +import os +import platform +import sys + from openplate import __semver__ as module_semver from openplate import __version__ as module_version from openplate.cfg import open_plate_settings @@ -33,12 +64,16 @@ config_set, project_init, project_update, - project_verify + project_verify, ) from openplate.commands.config_set import ConfigSetOptions from openplate.commands.project_init import InitOptions from openplate.commands.project_update import UpdateOptions from openplate.commands.project_verify import VerifyOptions +from openplate.prompts.prompt_document_cli import ( + add_prompt_document_arguments, + load_prompt_document as load_prompt_document_from_args, +) def add_common_project_runtime_arguments(parser): @@ -79,6 +114,7 @@ def configure_project_init_parser(parser): help="One-time override to allow template-provided init_commands to run during this init.", action="store_true" ) + add_prompt_document_arguments(parser) def configure_project_update_parser(parser): @@ -89,6 +125,7 @@ def configure_project_update_parser(parser): parser.add_argument("-f", "--update-full", required=False, help="Full update, overwrite existing non-template files (WARNING: will overwrite changes)", action=argparse.BooleanOptionalAction) + add_prompt_document_arguments(parser) def configure_project_verify_parser(parser): @@ -174,6 +211,10 @@ def resolve_project_init_source_reference(result) -> str: return source_reference +def load_prompt_document(result): + return load_prompt_document_from_args(result) + + async def async_main(args): arg_parser = create_arg_parser(args) @@ -209,7 +250,6 @@ async def async_main(args): else: runtime_settings = OpenPlateRuntimeSettings(False, False, False, result.automation) - # set-config can run without a valid configuration file if result.command == "config-set": defaults = {} @@ -235,6 +275,7 @@ async def async_main(args): elif result.command == "project-init": ignore_paths = result.ignore or [] source_reference = resolve_project_init_source_reference(result) + prompt_document = load_prompt_document(result) if result.url and not result.source: print( @@ -249,13 +290,27 @@ async def async_main(args): template = ProjectTemplateConfig(source_reference, None, None, result.dest_folder, None, {}, ignore_paths, no_cache) - options = InitOptions(template, absolute_project_folder, result.overwrite, result.allow_template_commands) + options = InitOptions( + template, + absolute_project_folder, + result.overwrite, + result.allow_template_commands, + result.print_prompts_json, + prompt_document, + ) await project_init.run(configuration, runtime_settings, options) elif result.command == "project-verify": options = VerifyOptions(absolute_project_folder) await project_verify.run(configuration, runtime_settings, options) elif result.command == "project-update": - options = UpdateOptions(absolute_project_folder, result.update_missing, result.update_full) + prompt_document = load_prompt_document(result) + options = UpdateOptions( + absolute_project_folder, + result.update_missing, + result.update_full, + result.print_prompts_json, + prompt_document, + ) await project_update.run(configuration, runtime_settings, options) else: raise Exception(f"Unknown Command: {result.command}") diff --git a/src/openplate/cfg/project_config.py b/src/openplate/cfg/project_config.py index 8d02356..8fb1610 100644 --- a/src/openplate/cfg/project_config.py +++ b/src/openplate/cfg/project_config.py @@ -28,6 +28,9 @@ from openplate.walk.recursive_walker import norm_relative_path +_RAW_DEST_FOLDER_UNSET = object() + + class ProjectTemplateFileInfo: def __init__(self, relative_path: str, is_readonly: bool): if relative_path is None: @@ -46,11 +49,22 @@ def __init__( version: Optional[str], parameters: dict[str, str], template_ignore_paths: Optional[list[str]], - no_cache: Optional[bool] + no_cache: Optional[bool], + raw_template_reference: Optional[str] = None, + raw_dest_folder = _RAW_DEST_FOLDER_UNSET, + raw_condition: Optional[str] = None, ): self.src_url = src_url self.src_name = src_name self.src_folder = src_folder + self.raw_template_reference = raw_template_reference or src_url or src_name or src_folder + + if raw_dest_folder is _RAW_DEST_FOLDER_UNSET: + self.raw_dest_folder = dest_folder + else: + self.raw_dest_folder = raw_dest_folder + + self.raw_condition = raw_condition self.dest_folder = None if dest_folder and dest_folder.strip(): @@ -66,6 +80,18 @@ def __init__( self.template_ignore_paths = template_ignore_paths self.no_cache = no_cache + def __getstate__(self): + return { + "src_url": self.src_url, + "src_name": self.src_name, + "src_folder": self.src_folder, + "dest_folder": self.dest_folder, + "version": self.version, + "parameters": self.parameters, + "template_ignore_paths": self.template_ignore_paths, + "no_cache": self.no_cache, + } + def __str__(self): return \ f"src_url: {self.src_url} " if self.src_url else "" \ @@ -245,7 +271,9 @@ def deserialize_template(settings: OpenPlateSettings, data): data.get("version"), deserialize_string_dictionary(data.get("parameters"), "template_parameters"), deserialize_string_list(data.get("template_ignore_paths"), "template_ignore_paths"), - data.get("no_cache") + data.get("no_cache"), + data.get("src_url") or data.get("src_name") or data.get("src_folder"), + data.get("dest_folder"), ) project_config_file_name = ".openplate.project.yaml" diff --git a/src/openplate/commands/project_init.py b/src/openplate/commands/project_init.py index 4048408..7a87332 100644 --- a/src/openplate/commands/project_init.py +++ b/src/openplate/commands/project_init.py @@ -17,9 +17,13 @@ # # This product includes software developed at Comcast (https://www.comcast.com/).# import os +from typing import Optional from openplate.cfg import project_config from openplate.cfg.open_plate_settings import OpenPlateSettings, OpenPlateRuntimeSettings +from openplate.prompts.prompt_input_logging import log_ignored_prompt_templates +from openplate.prompts.prompt_document_collector import collect_prompt_document_single +from openplate.prompts.prompt_document import PromptDocument, PromptInputTracker from openplate.walk.source_template_recursive_walk import VerifyWalkOptions, source_template_recursive_walk_single @@ -29,7 +33,9 @@ def __init__( add_template: project_config.ProjectTemplateConfig, destination: str, overwrite_existing_files: bool, - allow_template_commands: bool + allow_template_commands: bool, + print_prompts_json: bool, + prompt_document: Optional[PromptDocument], ): if add_template is None: raise TypeError @@ -39,6 +45,8 @@ def __init__( self.destination = destination self.overwrite_existing_files = overwrite_existing_files or False self.allow_template_commands = allow_template_commands or False + self.print_prompts_json = print_prompts_json or False + self.prompt_document = prompt_document async def run( @@ -46,7 +54,8 @@ async def run( runtime_settings: OpenPlateRuntimeSettings, options: InitOptions, ): - print(f"Running init on folder: {options.destination} source: {options.add_template.__str__()}") + if not options.print_prompts_json: + print(f"Running init on folder: {options.destination} source: {options.add_template.__str__()}") config_project = project_config.from_file( settings, @@ -57,6 +66,21 @@ async def run( allow_template_commands = settings.allow_template_commands or options.allow_template_commands + if options.print_prompts_json: + prompt_document = await collect_prompt_document_single( + settings, + runtime_settings, + options.add_template, + options.destination, + config_project, + ) + print(prompt_document.to_json_string()) + return + + prompt_input_tracker = None + if options.prompt_document is not None: + prompt_input_tracker = PromptInputTracker(options.prompt_document) + await source_template_recursive_walk_single( settings, runtime_settings, @@ -74,9 +98,12 @@ async def run( True, options.overwrite_existing_files, True, - False + options.prompt_document is not None, + prompt_input_tracker, ) + log_ignored_prompt_templates(prompt_input_tracker) + # Always update config from an init: project_config.to_file( config_project, diff --git a/src/openplate/commands/project_update.py b/src/openplate/commands/project_update.py index d163806..501dd4f 100644 --- a/src/openplate/commands/project_update.py +++ b/src/openplate/commands/project_update.py @@ -18,9 +18,13 @@ # This product includes software developed at Comcast (https://www.comcast.com/).# import logging import os +from typing import Optional from openplate.cfg import project_config from openplate.cfg.open_plate_settings import OpenPlateSettings, OpenPlateRuntimeSettings +from openplate.prompts.prompt_input_logging import log_ignored_prompt_templates +from openplate.prompts.prompt_document_collector import collect_prompt_document_all +from openplate.prompts.prompt_document import PromptDocument, PromptInputTracker from openplate.walk.source_template_recursive_walk import VerifyWalkOptions, source_template_recursive_walk_all @@ -29,13 +33,17 @@ def __init__( self, destination: str, create_non_template_files: bool, - update_non_template_files: bool + update_non_template_files: bool, + print_prompts_json: bool, + prompt_document: Optional[PromptDocument], ): if destination is None: raise TypeError self.destination = destination self.create_non_template_files = create_non_template_files self.update_non_template_files = update_non_template_files + self.print_prompts_json = print_prompts_json or False + self.prompt_document = prompt_document async def run( @@ -43,14 +51,29 @@ async def run( runtime_settings: OpenPlateRuntimeSettings, options ): - print(f"Running update on folder: {options.destination}") - logging.debug(f"create_non_template_files: {options.create_non_template_files}, update_non_template_files: {options.update_non_template_files}") + if not options.print_prompts_json: + print(f"Running update on folder: {options.destination}") + logging.debug(f"create_non_template_files: {options.create_non_template_files}, update_non_template_files: {options.update_non_template_files}") config_project = project_config.from_file( settings, os.path.join(options.destination, project_config.project_config_file_name) ) + if options.print_prompts_json: + prompt_document = await collect_prompt_document_all( + settings, + runtime_settings, + options.destination, + config_project, + ) + print(prompt_document.to_json_string()) + return + + prompt_input_tracker = None + if options.prompt_document is not None: + prompt_input_tracker = PromptInputTracker(options.prompt_document) + (config_updated, found_changes, sha) = await source_template_recursive_walk_all( settings, runtime_settings, @@ -67,9 +90,12 @@ async def run( options.create_non_template_files, options.update_non_template_files, False, - False + options.prompt_document is not None, + prompt_input_tracker, ) + log_ignored_prompt_templates(prompt_input_tracker) + # if config_updated: # Always attempt to fix config file (to update name references which should be urls for example) diff --git a/src/openplate/project_config_resolver.py b/src/openplate/project_config_resolver.py index 5fff42a..df0d75f 100644 --- a/src/openplate/project_config_resolver.py +++ b/src/openplate/project_config_resolver.py @@ -17,17 +17,19 @@ # # This product includes software developed at Comcast (https://www.comcast.com/).# import logging -import os -import re - -from openplate import template_processor - -from openplate.template_processor import compile_template_options +from typing import Optional from openplate.cfg import template_config, project_config from openplate.cfg.open_plate_settings import OpenPlateRuntimeSettings, OpenPlateSettings from openplate.cfg.template_config import TemplateConfigParameter -from openplate.git import get_git_url, get_git_email +from openplate.project_metadata_resolver import resolve_project_metadata +from openplate.prompts.prompt_document import PromptInputTracker +from openplate.prompts.prompt_parameter_resolver import ( + log_unused_prompt_parameters, + mark_template_used, + resolve_runtime_parameter_fallback, + try_resolve_parameter_without_prompt, +) def resolve_parameter_hidden_state( @@ -82,54 +84,32 @@ def resolve_parameter( template_base_folder: str, parameter: TemplateConfigParameter, already_asked: bool, - fail_on_prompt: bool + fail_on_prompt: bool, + prompt_input_tracker: Optional[PromptInputTracker] = None, ) -> (bool, str): key_exists = parameter.name in config_project_template.parameters - value = config_project_template.parameters.get(parameter.name) - - # create temporary template_options to resolve things like dest_folder or project_folder_name - temp_options = compile_template_options( + existing_value = config_project_template.parameters.get(parameter.name) + fallback_value = resolve_runtime_parameter_fallback( + settings, + runtime_settings, config_template, config_project, config_project_template, project_base_folder, template_base_folder, - runtime_settings.ignore_tool_version + parameter, ) - # default is existing value or parameter default (do not process this one, use as-is) - default_value = value - - if default_value is None: - global_setting = settings.default_values.get(parameter.name) - if global_setting is not None: - logging.debug(f"preparing global default value[{global_setting}] for [{parameter.name}]") - default_value = template_processor.process( - temp_options, - str(global_setting), - [], - "Global Default: " + global_setting, - config_template.override_tag_start, - config_template.override_tag_end, - config_template.override_statement_start, - config_template.override_statement_end - ) - - if default_value is None: - if parameter.default is not None: - # for template default values, we want to resolve existing variables (such as dest_folder or project_folder_name) - default_value = template_processor.process( - temp_options, - str(parameter.default), - [], - "Parameter Default: " + str(parameter.default), - config_template.override_tag_start, - config_template.override_tag_end, - config_template.override_statement_start, - config_template.override_statement_end - ) - - + resolved_answer = try_resolve_parameter_without_prompt( + config_project_template, + parameter, + existing_value, + fallback_value if existing_value is None else None, + fail_on_prompt, + prompt_input_tracker, + ) + if resolved_answer is not None: + return resolved_answer effective_hidden = resolve_parameter_hidden_state( config_template, @@ -144,12 +124,12 @@ def resolve_parameter( # Auto answer case, hidden: if effective_hidden and not runtime_settings.ask_hidden: logging.debug(f"not prompting for hidden parameter[{parameter.name}]") - return False, default_value + return False, fallback_value # Auto answer case, already answered and not re-asking: if not runtime_settings.ask_again and key_exists: logging.debug(f"not re-prompting for already answered parameter[{parameter.name}]") - return False, default_value + return False, fallback_value # Ask: while True: @@ -168,7 +148,7 @@ def resolve_parameter( allowed_values_string = " (Must be one of: " + ", ".join(parameter.choices) + ")" description = (parameter.description or parameter.name) \ + allowed_values_string \ - + (f"(Default: \"{default_value}\")" if default_value is not None else "") + ": " + + (f"(Default: \"{fallback_value}\")" if fallback_value is not None else "") + ": " value = input(description) # if answered, return that: @@ -179,9 +159,9 @@ def resolve_parameter( return True, value.strip() # auto-answer case: if no answer and a default exists, take it: - if default_value is not None: - logging.debug(f"Taking default value [{default_value}] for unanswered parameter[{parameter.name}]") - return True, default_value + if fallback_value is not None: + logging.debug(f"Taking default value [{fallback_value}] for unanswered parameter[{parameter.name}]") + return True, fallback_value print("ERROR: This question is mandatory, please answer") @@ -194,51 +174,13 @@ def resolve( config_project_template: project_config.ProjectTemplateConfig, project_base_folder: str, template_base_folder: str, - fail_on_prompt: bool + fail_on_prompt: bool, + prompt_input_tracker: Optional[PromptInputTracker] = None, ) -> bool: - any_changed = False + any_changed = resolve_project_metadata(runtime_settings, config_project, project_base_folder) any_asked = False - project_folder_name = os.path.basename(os.path.abspath(os.path.normpath(project_base_folder))) - if not config_project.project_folder_name or config_project.project_folder_name != project_folder_name: - config_project.project_folder_name = project_folder_name - any_changed = True - - try: - project_src_url = get_git_url(project_base_folder) - project_repo_name = None - project_repo_org = None - if project_src_url is not None: - project_src_url = project_src_url.strip() - # Extract org name using regex - match = re.search(r'[:/](?P[^/]+)/(?P[^/]+?)(\.git)?$', project_src_url) - project_repo_org = match.group('org') if match else None - project_repo_name = match.group('repo') if match else None - - if not config_project.project_src_url or config_project.project_src_url != project_src_url: - config_project.project_src_url = project_src_url - any_changed = True - - if not config_project.project_repo_org or config_project.project_repo_org != project_repo_org: - config_project.project_repo_org = project_repo_org - any_changed = True - - if not config_project.project_repo_name or config_project.project_repo_name != project_repo_name: - config_project.project_repo_name = project_repo_name - any_changed = True - - except Exception: - pass - - # Do not update the user email when doing automated processing: - if not runtime_settings.is_automation: - try: - last_updater_email = get_git_email(project_base_folder) - if not config_project.last_updater_email or config_project.last_updater_email != last_updater_email: - config_project.last_updater_email = last_updater_email - any_changed = True - except Exception: - pass + mark_template_used(prompt_input_tracker, config_project_template) for parameter in config_template.parameters: original_value = config_project_template.parameters.get(parameter.name) @@ -253,7 +195,8 @@ def resolve( template_base_folder, parameter, any_asked, - fail_on_prompt + fail_on_prompt, + prompt_input_tracker, ) if asked: @@ -263,5 +206,6 @@ def resolve( any_changed = True config_project_template.parameters[parameter.name] = new_value + log_unused_prompt_parameters(prompt_input_tracker, config_project_template) return any_changed diff --git a/src/openplate/project_metadata_resolver.py b/src/openplate/project_metadata_resolver.py new file mode 100644 index 0000000..0002bc3 --- /dev/null +++ b/src/openplate/project_metadata_resolver.py @@ -0,0 +1,75 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +import os +import re + +from openplate.cfg import project_config +from openplate.cfg.open_plate_settings import OpenPlateRuntimeSettings +from openplate.git import get_git_email, get_git_url + + +def resolve_project_metadata( + runtime_settings: OpenPlateRuntimeSettings, + config_project: project_config.ProjectConfig, + project_base_folder: str, +) -> bool: + any_changed = False + + project_folder_name = os.path.basename(os.path.abspath(os.path.normpath(project_base_folder))) + if not config_project.project_folder_name or config_project.project_folder_name != project_folder_name: + config_project.project_folder_name = project_folder_name + any_changed = True + + try: + project_src_url = get_git_url(project_base_folder) + project_repo_name = None + project_repo_org = None + if project_src_url is not None: + project_src_url = project_src_url.strip() + # Extract org name using regex + match = re.search(r'[:/](?P[^/]+)/(?P[^/]+?)(\.git)?$', project_src_url) + project_repo_org = match.group('org') if match else None + project_repo_name = match.group('repo') if match else None + + if not config_project.project_src_url or config_project.project_src_url != project_src_url: + config_project.project_src_url = project_src_url + any_changed = True + + if not config_project.project_repo_org or config_project.project_repo_org != project_repo_org: + config_project.project_repo_org = project_repo_org + any_changed = True + + if not config_project.project_repo_name or config_project.project_repo_name != project_repo_name: + config_project.project_repo_name = project_repo_name + any_changed = True + + except Exception: + pass + + # Do not update the user email when doing automated processing: + if not runtime_settings.is_automation: + try: + last_updater_email = get_git_email(project_base_folder) + if not config_project.last_updater_email or config_project.last_updater_email != last_updater_email: + config_project.last_updater_email = last_updater_email + any_changed = True + except Exception: + pass + + return any_changed \ No newline at end of file diff --git a/src/openplate/project_template_identity.py b/src/openplate/project_template_identity.py new file mode 100644 index 0000000..0a63311 --- /dev/null +++ b/src/openplate/project_template_identity.py @@ -0,0 +1,42 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +from openplate.cfg.open_plate_settings import OpenPlateSettings +from openplate.sources.name_converter import convert_name + + +def prompt_template_reference(config_project_template): + return config_project_template.raw_template_reference + + +def prompt_dest_folder(config_project_template): + return config_project_template.raw_dest_folder + + +def prompt_condition(config_project_template): + return config_project_template.raw_condition + + +def source_cache_key(settings: OpenPlateSettings, config_project_template): + if config_project_template.src_url: + return ("url", config_project_template.src_url) + if config_project_template.src_name: + return ("url", convert_name(settings, config_project_template.src_name)) + if config_project_template.src_folder: + return ("folder", config_project_template.src_folder) + raise ValueError("Unknown template source") \ No newline at end of file diff --git a/src/openplate/prompts/__init__.py b/src/openplate/prompts/__init__.py new file mode 100644 index 0000000..00f38f8 --- /dev/null +++ b/src/openplate/prompts/__init__.py @@ -0,0 +1,19 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +"""Prompt JSON helpers.""" \ No newline at end of file diff --git a/src/openplate/prompts/prompt_document.py b/src/openplate/prompts/prompt_document.py new file mode 100644 index 0000000..c05d486 --- /dev/null +++ b/src/openplate/prompts/prompt_document.py @@ -0,0 +1,269 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +import json +from dataclasses import dataclass +from typing import Optional + + +def _require_optional_string(value, field_name: str): + if value is not None and not isinstance(value, str): + raise TypeError(f"Prompt parameter '{field_name}' must be a string or null") + + +def _require_optional_bool(value, field_name: str): + if value is not None and not isinstance(value, bool): + raise TypeError(f"Prompt parameter '{field_name}' must be a boolean or null") + + +@dataclass(frozen=True) +class PromptTemplateKey: + template: str + dest_folder: Optional[str] + + +@dataclass +class PromptParameterValue: + value: Optional[str] + default: Optional[str] + existing: Optional[str] + description: Optional[str] + choices: Optional[list[str]] + hidden: Optional[bool] + required: Optional[bool] + + @classmethod + def from_json_data(cls, data): + if not isinstance(data, dict): + raise TypeError("Prompt parameter entry must be an object") + if "value" not in data: + raise ValueError("Prompt parameter entry must include a 'value' field") + + choices = data.get("choices") + if choices is not None and not isinstance(choices, list): + raise TypeError("Prompt parameter 'choices' must be a list when provided") + if choices is not None: + for choice in choices: + if not isinstance(choice, str): + raise TypeError("Prompt parameter 'choices' entries must be strings") + + _require_optional_string(data.get("value"), "value") + _require_optional_string(data.get("default"), "default") + _require_optional_string(data.get("existing"), "existing") + _require_optional_string(data.get("description"), "description") + _require_optional_bool(data.get("hidden"), "hidden") + _require_optional_bool(data.get("required"), "required") + + return cls( + data.get("value"), + data.get("default"), + data.get("existing"), + data.get("description"), + choices, + data.get("hidden"), + data.get("required"), + ) + + def to_json_data(self): + return { + "value": self.value, + "default": self.default, + "existing": self.existing, + "description": self.description, + "choices": self.choices, + "hidden": self.hidden, + "required": self.required, + } + + +@dataclass +class PromptTemplateNode: + template: str + dest_folder: Optional[str] + parameters: Optional[dict[str, PromptParameterValue]] + condition: Optional[str] = None + + @property + def key(self) -> PromptTemplateKey: + return PromptTemplateKey(self.template, self.dest_folder) + + @classmethod + def from_json_data(cls, data): + if not isinstance(data, dict): + raise TypeError("Prompt template entry must be an object") + + template = data.get("template") + if not isinstance(template, str) or not template.strip(): + raise ValueError("Prompt template entry must include a non-empty 'template' field") + + if "dest_folder" not in data: + raise ValueError("Prompt template entry must include a 'dest_folder' field") + dest_folder = data.get("dest_folder") + if dest_folder is not None and not isinstance(dest_folder, str): + raise TypeError("Prompt template 'dest_folder' must be a string or null") + + if "parameters" not in data: + raise ValueError("Prompt template entry must include a 'parameters' field") + raw_parameters = data.get("parameters") + parameters = None + if raw_parameters is not None: + if not isinstance(raw_parameters, dict): + raise TypeError("Prompt template 'parameters' must be an object or null") + parameters = {} + for name, parameter_data in raw_parameters.items(): + if not isinstance(name, str) or not name: + raise ValueError("Prompt parameter names must be non-empty strings") + parameters[name] = PromptParameterValue.from_json_data(parameter_data) + + condition = data.get("condition") + if condition is not None and not isinstance(condition, str): + raise TypeError("Prompt template 'condition' must be a string when provided") + + return cls(template.strip(), dest_folder, parameters, condition) + + def to_json_data(self): + result = { + "template": self.template, + "dest_folder": self.dest_folder, + "parameters": None, + } + if self.condition is not None: + result["condition"] = self.condition + if self.parameters is not None: + result["parameters"] = { + name: parameter.to_json_data() + for name, parameter in self.parameters.items() + } + return result + + +@dataclass +class PromptDocument: + templates: list[PromptTemplateNode] + + @classmethod + def from_json_string(cls, json_string: str): + raw_data = json.loads(json_string) + if not isinstance(raw_data, list): + raise TypeError("Prompt document must be a JSON array") + + templates = [] + seen_keys = set() + for entry in raw_data: + node = PromptTemplateNode.from_json_data(entry) + if node.key in seen_keys: + raise ValueError( + f"Duplicate prompt template entry: template={node.template!r}, dest_folder={node.dest_folder!r}" + ) + seen_keys.add(node.key) + templates.append(node) + + return cls(templates) + + def to_json_string(self) -> str: + return json.dumps([node.to_json_data() for node in self.templates], indent=2) + + +class PromptDocumentBuilder: + def __init__(self): + self._templates = [] + self._seen_keys = set() + + def add_template( + self, + template: str, + dest_folder: Optional[str], + parameters: Optional[dict[str, PromptParameterValue]], + condition: Optional[str] = None, + ): + key = PromptTemplateKey(template, dest_folder) + if key in self._seen_keys: + return False + + self._seen_keys.add(key) + self._templates.append(PromptTemplateNode(template, dest_folder, parameters, condition)) + return True + + def build(self) -> PromptDocument: + return PromptDocument(list(self._templates)) + + +class PromptInputTracker: + def __init__(self, document: Optional[PromptDocument]): + self._document = document + self._by_key = {} + self._used_template_keys = set() + self._used_parameter_names = {} + + if document is None: + return + + for node in document.templates: + self._by_key[node.key] = node + self._used_parameter_names[node.key] = set() + + @classmethod + def from_json_string(cls, json_string: str): + return cls(PromptDocument.from_json_string(json_string)) + + def get_template(self, template: str, dest_folder: Optional[str]) -> Optional[PromptTemplateNode]: + key = PromptTemplateKey(template, dest_folder) + node = self._by_key.get(key) + if node is not None: + self._used_template_keys.add(key) + return node + + def mark_template_used(self, template: str, dest_folder: Optional[str]): + key = PromptTemplateKey(template, dest_folder) + if key in self._by_key: + self._used_template_keys.add(key) + + def get_parameter_value(self, template: str, dest_folder: Optional[str], name: str): + node = self.get_template(template, dest_folder) + if node is None or node.parameters is None: + return None, False + + parameter = node.parameters.get(name) + if parameter is None: + return None, False + + self._used_parameter_names[node.key].add(name) + return parameter.value, True + + def ignored_templates(self) -> list[PromptTemplateNode]: + if self._document is None: + return [] + return [ + node for node in self._document.templates + if node.key not in self._used_template_keys + ] + + def unused_parameters(self, template: str, dest_folder: Optional[str]) -> list[str]: + key = PromptTemplateKey(template, dest_folder) + node = self._by_key.get(key) + if node is None or node.parameters is None: + return [] + + used_names = self._used_parameter_names.get(key, set()) + unused = [] + for name, parameter in node.parameters.items(): + if parameter.value is None: + continue + if name not in used_names: + unused.append(name) + return unused \ No newline at end of file diff --git a/src/openplate/prompts/prompt_document_cli.py b/src/openplate/prompts/prompt_document_cli.py new file mode 100644 index 0000000..4cf670e --- /dev/null +++ b/src/openplate/prompts/prompt_document_cli.py @@ -0,0 +1,62 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +import sys + +from openplate.prompts.prompt_document import PromptDocument + + +def add_prompt_document_arguments(parser): + parser.add_argument( + "--print-prompts-json", + required=False, + default=False, + help="Print the prompt document as JSON without modifying project state.", + action="store_true" + ) + parser.add_argument( + "--prompts-json-file", + required=False, + help="Load prompt answers from a JSON file." + ) + parser.add_argument( + "--prompts-json-stdin", + required=False, + default=False, + help="Load prompt answers from JSON on standard input.", + action="store_true" + ) + + +def load_prompt_document(result) -> PromptDocument | None: + prompt_input_flags = int(bool(getattr(result, "prompts_json_file", None))) + int(bool(getattr(result, "prompts_json_stdin", False))) + + if prompt_input_flags > 1: + raise ValueError("Specify only one prompts JSON input source, either --prompts-json-file or --prompts-json-stdin") + + if getattr(result, "print_prompts_json", False) and prompt_input_flags > 0: + raise ValueError("--print-prompts-json cannot be combined with --prompts-json-file or --prompts-json-stdin") + + if getattr(result, "prompts_json_file", None): + with open(result.prompts_json_file, encoding="utf-8") as prompts_json_file: + return PromptDocument.from_json_string(prompts_json_file.read()) + + if getattr(result, "prompts_json_stdin", False): + return PromptDocument.from_json_string(sys.stdin.read()) + + return None \ No newline at end of file diff --git a/src/openplate/prompts/prompt_document_collector.py b/src/openplate/prompts/prompt_document_collector.py new file mode 100644 index 0000000..bdeaefb --- /dev/null +++ b/src/openplate/prompts/prompt_document_collector.py @@ -0,0 +1,213 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +import logging +import os + +from openplate import template_processor +from openplate.cfg import project_config, template_config +from openplate.cfg.open_plate_settings import OpenPlateRuntimeSettings, OpenPlateSettings +from openplate.cfg.project_config import ProjectTemplateConfig +from openplate.project_metadata_resolver import resolve_project_metadata +from openplate.project_template_identity import prompt_condition, prompt_dest_folder, prompt_template_reference, source_cache_key +from openplate.prompts.prompt_document import PromptDocument, PromptDocumentBuilder +from openplate.prompts.prompt_parameter_resolver import describe_prompt_parameters +from openplate.sibling_template_resolver import copy_template_with_raw_identity, find_matching_template, render_sibling_template_config +from openplate.sources.source_cache import CommandTemplateSourceCache, close_command_template_source_cache + + +async def _collect_prompt_document_template( + settings: OpenPlateSettings, + runtime_settings: OpenPlateRuntimeSettings, + config_project_template: ProjectTemplateConfig, + project_folder: str, + config_project: project_config.ProjectConfig, + prompt_document_builder: PromptDocumentBuilder, + source_cache: CommandTemplateSourceCache, + visited_template_keys: set, +): + if not os.path.exists(project_folder): + raise FileNotFoundError("Project folder not found: " + project_folder) + + visit_key = (source_cache_key(settings, config_project_template), config_project_template.dest_folder) + if visit_key in visited_template_keys: + return + + visited_template_keys.add(visit_key) + + try: + source = source_cache.get_source(config_project_template) + config_template = template_config.from_file( + os.path.join(source.folder_path(), template_config.template_config_file_name) + ) + except Exception as ex: + logging.debug("Unable to fully inspect template for prompt export: %s", ex) + prompt_document_builder.add_template( + prompt_template_reference(config_project_template), + prompt_dest_folder(config_project_template), + None, + prompt_condition(config_project_template), + ) + return + + if config_project_template.dest_folder is None: + config_project_template.dest_folder = config_template.default_dest_folder or "" + + resolve_project_metadata(runtime_settings, config_project, project_folder) + + try: + parameters = describe_prompt_parameters( + settings, + runtime_settings, + config_template, + config_project, + config_project_template, + project_folder, + source.folder_path(), + ) + template_options = template_processor.compile_template_options( + config_template, + config_project, + config_project_template, + source.folder_path(), + project_folder, + runtime_settings.ignore_tool_version, + ) + except Exception as ex: + logging.debug( + "Unable to enumerate prompt metadata for %s: %s", + config_project_template.get_template_source_name(), + ex, + ) + prompt_document_builder.add_template( + prompt_template_reference(config_project_template), + prompt_dest_folder(config_project_template), + None, + prompt_condition(config_project_template), + ) + return + + prompt_document_builder.add_template( + prompt_template_reference(config_project_template), + prompt_dest_folder(config_project_template), + parameters, + prompt_condition(config_project_template), + ) + + if config_template.require_sibling_templates is None: + return + + for sibling_template in config_template.require_sibling_templates: + raw_template_reference = sibling_template.template_url + raw_dest_folder = sibling_template.dest_folder + raw_condition = sibling_template.condition + + try: + rendered_sibling_template = render_sibling_template_config( + config_template, + template_options, + sibling_template, + source, + ) + except Exception as ex: + logging.debug("Unable to resolve sibling declaration for prompt export: %s", ex) + prompt_document_builder.add_template( + raw_template_reference, + raw_dest_folder, + None, + raw_condition, + ) + continue + + matching_template = find_matching_template(config_project, rendered_sibling_template) + if matching_template is not None: + next_template = copy_template_with_raw_identity( + matching_template, + raw_template_reference, + raw_dest_folder, + raw_condition, + ) + else: + next_template = rendered_sibling_template + + await _collect_prompt_document_template( + settings, + runtime_settings, + next_template, + project_folder, + config_project, + prompt_document_builder, + source_cache, + visited_template_keys, + ) + + +async def collect_prompt_document_single( + settings: OpenPlateSettings, + runtime_settings: OpenPlateRuntimeSettings, + config_project_template: ProjectTemplateConfig, + project_folder: str, + config_project: project_config.ProjectConfig, +) -> PromptDocument: + prompt_document_builder = PromptDocumentBuilder() + source_cache = CommandTemplateSourceCache(settings) + visited_template_keys = set() + + try: + await _collect_prompt_document_template( + settings, + runtime_settings, + config_project_template, + project_folder, + config_project, + prompt_document_builder, + source_cache, + visited_template_keys, + ) + finally: + close_command_template_source_cache(source_cache) + + return prompt_document_builder.build() + + +async def collect_prompt_document_all( + settings: OpenPlateSettings, + runtime_settings: OpenPlateRuntimeSettings, + destination: str, + config_project: project_config.ProjectConfig, +) -> PromptDocument: + prompt_document_builder = PromptDocumentBuilder() + source_cache = CommandTemplateSourceCache(settings) + visited_template_keys = set() + + try: + for config_project_template in list(config_project.templates): + await _collect_prompt_document_template( + settings, + runtime_settings, + config_project_template, + destination, + config_project, + prompt_document_builder, + source_cache, + visited_template_keys, + ) + finally: + close_command_template_source_cache(source_cache) + + return prompt_document_builder.build() \ No newline at end of file diff --git a/src/openplate/prompts/prompt_input_logging.py b/src/openplate/prompts/prompt_input_logging.py new file mode 100644 index 0000000..75ec576 --- /dev/null +++ b/src/openplate/prompts/prompt_input_logging.py @@ -0,0 +1,34 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +import logging +from typing import Optional + +from openplate.prompts.prompt_document import PromptInputTracker + + +def log_ignored_prompt_templates(prompt_input_tracker: Optional[PromptInputTracker]): + if prompt_input_tracker is None: + return + + for ignored_template in prompt_input_tracker.ignored_templates(): + logging.warning( + "Ignoring supplied prompt template because it was not processed: template=%r dest_folder=%r", + ignored_template.template, + ignored_template.dest_folder, + ) \ No newline at end of file diff --git a/src/openplate/prompts/prompt_parameter_resolver.py b/src/openplate/prompts/prompt_parameter_resolver.py new file mode 100644 index 0000000..0fd3d1a --- /dev/null +++ b/src/openplate/prompts/prompt_parameter_resolver.py @@ -0,0 +1,249 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +import logging +from typing import Optional + +from openplate import template_processor +from openplate.template_processor import compile_template_options + +from openplate.cfg import project_config, template_config +from openplate.cfg.open_plate_settings import OpenPlateRuntimeSettings, OpenPlateSettings +from openplate.cfg.template_config import TemplateConfigParameter +from openplate.project_template_identity import prompt_dest_folder, prompt_template_reference +from openplate.prompts.prompt_document import PromptInputTracker, PromptParameterValue + + +def _compile_parameter_options( + settings: OpenPlateSettings, + runtime_settings: OpenPlateRuntimeSettings, + config_template: template_config.TemplateConfig, + config_project: project_config.ProjectConfig, + config_project_template: project_config.ProjectTemplateConfig, + project_base_folder: str, + template_base_folder: str, +): + # create temporary template_options to resolve things like dest_folder or project_folder_name + temp_options = compile_template_options( + config_template, + config_project, + config_project_template, + project_base_folder, + template_base_folder, + runtime_settings.ignore_tool_version, + ) + + return temp_options + + +def _resolve_processed_default( + settings: OpenPlateSettings, + runtime_settings: OpenPlateRuntimeSettings, + config_template: template_config.TemplateConfig, + config_project: project_config.ProjectConfig, + config_project_template: project_config.ProjectTemplateConfig, + project_base_folder: str, + template_base_folder: str, + parameter: TemplateConfigParameter, +) -> Optional[str]: + temp_options = _compile_parameter_options( + settings, + runtime_settings, + config_template, + config_project, + config_project_template, + project_base_folder, + template_base_folder, + ) + + default_value = None + + global_setting = settings.default_values.get(parameter.name) + if global_setting is not None: + logging.debug(f"preparing global default value[{global_setting}] for [{parameter.name}]") + default_value = template_processor.process( + temp_options, + str(global_setting), + [], + "Global Default: " + global_setting, + config_template.override_tag_start, + config_template.override_tag_end, + config_template.override_statement_start, + config_template.override_statement_end, + ) + + if default_value is None and parameter.default is not None: + # for template default values, we want to resolve existing variables (such as dest_folder or project_folder_name) + default_value = template_processor.process( + temp_options, + str(parameter.default), + [], + "Parameter Default: " + str(parameter.default), + config_template.override_tag_start, + config_template.override_tag_end, + config_template.override_statement_start, + config_template.override_statement_end, + ) + + return default_value + + +def resolve_parameter_defaults( + settings: OpenPlateSettings, + runtime_settings: OpenPlateRuntimeSettings, + config_template: template_config.TemplateConfig, + config_project: project_config.ProjectConfig, + config_project_template: project_config.ProjectTemplateConfig, + project_base_folder: str, + template_base_folder: str, + parameter: TemplateConfigParameter, +) -> tuple[Optional[str], Optional[str]]: + existing_value = config_project_template.parameters.get(parameter.name) + default_value = _resolve_processed_default( + settings, + runtime_settings, + config_template, + config_project, + config_project_template, + project_base_folder, + template_base_folder, + parameter, + ) + + return existing_value, default_value + + +def resolve_runtime_parameter_fallback( + settings: OpenPlateSettings, + runtime_settings: OpenPlateRuntimeSettings, + config_template: template_config.TemplateConfig, + config_project: project_config.ProjectConfig, + config_project_template: project_config.ProjectTemplateConfig, + project_base_folder: str, + template_base_folder: str, + parameter: TemplateConfigParameter, +) -> Optional[str]: + existing_value = config_project_template.parameters.get(parameter.name) + + # default is existing value or parameter default (do not process this one, use as-is) + if existing_value is not None: + return existing_value + + return _resolve_processed_default( + settings, + runtime_settings, + config_template, + config_project, + config_project_template, + project_base_folder, + template_base_folder, + parameter, + ) + + +def describe_prompt_parameters( + settings: OpenPlateSettings, + runtime_settings: OpenPlateRuntimeSettings, + config_template: template_config.TemplateConfig, + config_project: project_config.ProjectConfig, + config_project_template: project_config.ProjectTemplateConfig, + project_base_folder: str, + template_base_folder: str, +) -> dict[str, PromptParameterValue]: + result = {} + + for parameter in config_template.parameters: + existing_value, default_value = resolve_parameter_defaults( + settings, + runtime_settings, + config_template, + config_project, + config_project_template, + project_base_folder, + template_base_folder, + parameter, + ) + result[parameter.name] = PromptParameterValue( + None, + default_value, + existing_value, + parameter.description, + parameter.choices, + parameter.hidden, + existing_value is None and default_value is None, + ) + + return result + + +def try_resolve_parameter_without_prompt( + config_project_template: project_config.ProjectTemplateConfig, + parameter: TemplateConfigParameter, + existing_value: Optional[str], + default_value: Optional[str], + fail_on_prompt: bool, + prompt_input_tracker: Optional[PromptInputTracker], +): + fallback_value = existing_value if existing_value is not None else default_value + + if prompt_input_tracker is not None: + supplied_value, has_supplied_value = prompt_input_tracker.get_parameter_value( + prompt_template_reference(config_project_template), + prompt_dest_folder(config_project_template), + parameter.name, + ) + if has_supplied_value and supplied_value is not None: + if parameter.choices and supplied_value not in parameter.choices: + raise ValueError(f"Template parameter '{parameter.name}' must be one of: {', '.join(parameter.choices)}") + return False, supplied_value + + if fail_on_prompt and fallback_value is not None: + return False, fallback_value + + return None + + +def mark_template_used( + prompt_input_tracker: Optional[PromptInputTracker], + config_project_template: project_config.ProjectTemplateConfig, +): + if prompt_input_tracker is None: + return + + prompt_input_tracker.mark_template_used( + prompt_template_reference(config_project_template), + prompt_dest_folder(config_project_template), + ) + + +def log_unused_prompt_parameters( + prompt_input_tracker: Optional[PromptInputTracker], + config_project_template: project_config.ProjectTemplateConfig, +): + if prompt_input_tracker is None: + return + + template_reference = prompt_template_reference(config_project_template) + dest_folder = prompt_dest_folder(config_project_template) + for unused_name in prompt_input_tracker.unused_parameters(template_reference, dest_folder): + logging.warning( + "Ignoring unused supplied prompt parameter for template=%r dest_folder=%r parameter=%r", + template_reference, + dest_folder, + unused_name, + ) \ No newline at end of file diff --git a/src/openplate/sibling_template_resolver.py b/src/openplate/sibling_template_resolver.py new file mode 100644 index 0000000..6cf3682 --- /dev/null +++ b/src/openplate/sibling_template_resolver.py @@ -0,0 +1,126 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +from openplate import template_processor + +from openplate.cfg import project_config, template_config +from openplate.cfg.project_config import ProjectTemplateConfig + + +def render_sibling_template_config( + config_template: template_config.TemplateConfig, + template_options, + sibling_template: template_config.RequireSiblingTemplate, + source, +) -> ProjectTemplateConfig: + raw_template_reference = sibling_template.template_url + raw_dest_folder = sibling_template.dest_folder + raw_condition = sibling_template.condition + + processed_template_reference = template_processor.process( + template_options, + str(raw_template_reference), + [], + "Sibling Template URL", + config_template.override_tag_start, + config_template.override_tag_end, + config_template.override_statement_start, + config_template.override_statement_end, + ) + + parameters = {} + if sibling_template.parameters is not None: + for key, value in sibling_template.parameters.items(): + parameters[key] = template_processor.process( + template_options, + str(value), + [], + "Sibling Template Parameter[" + key + "]", + config_template.override_tag_start, + config_template.override_tag_end, + config_template.override_statement_start, + config_template.override_statement_end, + ) + + # If specified, take the one in the template: + if raw_dest_folder: + new_dest_folder = raw_dest_folder + else: + # Else, throw an exception: + raise ValueError(f"Template {source.__str__()} requires sibling but does not specify it's dest_folder") + + processed_dest_folder = template_processor.process( + template_options, + str(new_dest_folder), + [], + "Destination folder: " + str(new_dest_folder), + config_template.override_tag_start, + config_template.override_tag_end, + config_template.override_statement_start, + config_template.override_statement_end, + ) + + return ProjectTemplateConfig( + processed_template_reference, + None, + None, + processed_dest_folder, + None, + parameters, + None, + None, + raw_template_reference, + raw_dest_folder, + raw_condition, + ) + + +def find_matching_template( + config_project: project_config.ProjectConfig, + config_project_template: ProjectTemplateConfig, +): + for current_template in config_project.templates: + if current_template.dest_folder != config_project_template.dest_folder: + continue + if current_template.src_url != config_project_template.src_url: + continue + + return current_template + + return None + + +def copy_template_with_raw_identity( + config_project_template: ProjectTemplateConfig, + raw_template_reference: str, + raw_dest_folder, + raw_condition, +): + return ProjectTemplateConfig( + config_project_template.src_url, + config_project_template.src_name, + config_project_template.src_folder, + config_project_template.dest_folder, + config_project_template.version, + dict(config_project_template.parameters), + list(config_project_template.template_ignore_paths) if config_project_template.template_ignore_paths is not None else None, + config_project_template.no_cache, + raw_template_reference, + raw_dest_folder, + raw_condition, + ) \ No newline at end of file diff --git a/src/openplate/sources/source_cache.py b/src/openplate/sources/source_cache.py new file mode 100644 index 0000000..2e7299b --- /dev/null +++ b/src/openplate/sources/source_cache.py @@ -0,0 +1,70 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +import logging +import sys + +from openplate.cfg.open_plate_settings import OpenPlateSettings +from openplate.project_template_identity import source_cache_key + + +class CommandTemplateSourceCache: + def __init__(self, settings: OpenPlateSettings): + self._settings = settings + self._sources = {} + + def get_source(self, config_project_template): + key = source_cache_key(self._settings, config_project_template) + + source = self._sources.get(key) + if source is not None: + return source + + source = config_project_template.to_source(self._settings) + source.__enter__() + self._sources[key] = source + return source + + def close(self): + first_error = None + + for source in reversed(list(self._sources.values())): + try: + source.__exit__(None, None, None) + except Exception as ex: + if first_error is None: + first_error = ex + + self._sources.clear() + + if first_error is not None: + raise first_error + + +def close_command_template_source_cache(source_cache: CommandTemplateSourceCache): + if source_cache is None: + return + + if sys.exc_info()[1] is None: + source_cache.close() + return + + try: + source_cache.close() + except Exception as ex: + logging.warning("Error closing template source cache after command failure: %s", ex) diff --git a/src/openplate/walk/source_template_recursive_walk.py b/src/openplate/walk/source_template_recursive_walk.py index ff69784..a37b030 100644 --- a/src/openplate/walk/source_template_recursive_walk.py +++ b/src/openplate/walk/source_template_recursive_walk.py @@ -18,12 +18,14 @@ # This product includes software developed at Comcast (https://www.comcast.com/).# import logging import os +from typing import Optional from openplate import project_config_resolver, template_processor +from openplate.prompts.prompt_document import PromptInputTracker from openplate.cfg import template_config, project_config from openplate.cfg.open_plate_settings import OpenPlateSettings, OpenPlateRuntimeSettings -from openplate.cfg.project_config import ProjectTemplateConfig from openplate.git import get_git_last_tag +from openplate.sibling_template_resolver import render_sibling_template_config from openplate.shell_command_processor import process_command from openplate.util import str_to_bool from openplate.walk import template_init_commands_gate @@ -46,7 +48,8 @@ async def source_template_recursive_walk_all( create_non_template_files: bool, update_non_template_files: bool, raise_error_on_verify: bool, - fail_on_prompt: bool + fail_on_prompt: bool, + prompt_input_tracker: Optional[PromptInputTracker] = None, ): project_config_changed = False found_changes = False @@ -66,7 +69,8 @@ async def source_template_recursive_walk_all( create_non_template_files, update_non_template_files, raise_error_on_verify, - fail_on_prompt + fail_on_prompt, + prompt_input_tracker, ) if current_project_config_changed: project_config_changed = True @@ -91,7 +95,8 @@ async def source_template_recursive_walk_single( create_non_template_files: bool, update_non_template_files: bool, raise_error_on_verify: bool, - fail_on_prompt: bool + fail_on_prompt: bool, + prompt_input_tracker: Optional[PromptInputTracker] = None, ): # Note, Dest folder is no longer a root for the template # both the repo root and the dest folder will be available to the template @@ -135,10 +140,9 @@ async def source_template_recursive_walk_single( settings, os.path.join(source.folder_path(), project_config.project_config_file_name) ) - except Exception as e: + except Exception: logging.debug(f"No Template Project config or other error (ok)") - # Answer questions: if project_config_resolver.resolve( settings, @@ -148,7 +152,8 @@ async def source_template_recursive_walk_single( config_project_template, project_folder, source.folder_path(), - fail_on_prompt + fail_on_prompt, + prompt_input_tracker, ): project_config_changed = True @@ -184,57 +189,11 @@ async def source_template_recursive_walk_single( logging.debug(f"Skipping sibling template {sibling_template.template_url} due to condition result: {rendered_condition}") continue - sibling_template.template_url = template_processor.process( - template_options, - str(sibling_template.template_url), - [], - "Sibling Template URL", - config_template.override_tag_start, - config_template.override_tag_end, - config_template.override_statement_start, - config_template.override_statement_end - ) - parameters = {} - if sibling_template.parameters is not None: - for key, value in sibling_template.parameters.items(): - parameters[key] = template_processor.process( - template_options, - str(value), - [], - "Sibling Template Parameter[" + key + "]", - config_template.override_tag_start, - config_template.override_tag_end, - config_template.override_statement_start, - config_template.override_statement_end - ) - - # If specified, take the one in the template: - if sibling_template.dest_folder: - new_dest_folder = sibling_template.dest_folder - else: - # Else, throw an exception: - raise ValueError(f"Template {source.__str__()} requires sibling but does not specify it's dest_folder") - - processed_dest_folder = template_processor.process( + model_template_config = render_sibling_template_config( + config_template, template_options, - str(new_dest_folder), - [], - "Destination folder: " + str(new_dest_folder), - config_template.override_tag_start, - config_template.override_tag_end, - config_template.override_statement_start, - config_template.override_statement_end - ) - - model_template_config = ProjectTemplateConfig( - sibling_template.template_url, - None, - None, - processed_dest_folder, - None, - parameters, - None, - None + sibling_template, + source, ) # If the template isnt already on the project, add it and walk it before continuing: @@ -257,7 +216,8 @@ async def source_template_recursive_walk_single( create_non_template_files, update_non_template_files, raise_error_on_verify, - fail_on_prompt + fail_on_prompt, + prompt_input_tracker, ) if sub_found_changes: found_changes = True diff --git a/tests/test_existing_runtime_regressions.py b/tests/test_existing_runtime_regressions.py new file mode 100644 index 0000000..50dd55d --- /dev/null +++ b/tests/test_existing_runtime_regressions.py @@ -0,0 +1,257 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +import asyncio +import subprocess +from pathlib import Path +import yaml + +import pytest + +from openplate import project_config_resolver +from openplate.__main__ import async_main +from openplate.cfg.open_plate_settings import OpenPlateRuntimeSettings, defaultSettings +from openplate.cfg.project_config import ProjectConfig, ProjectTemplateConfig, project_config_file_name +from openplate.cfg.template_config import TemplateConfig, TemplateConfigParameter +from openplate.commands.project_init import InitOptions +from openplate.commands.project_update import UpdateOptions +from openplate.commands import project_init, project_update +from openplate.sources.url_source import UrlTemplateSource + + +pytestmark = pytest.mark.unit + + +def _create_git_repo(repo_path: Path): + repo_path.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "init"], cwd=repo_path, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8") + subprocess.run(["git", "branch", "-M", "main"], cwd=repo_path, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8") + subprocess.run(["git", "config", "user.email", "tests@example.com"], cwd=repo_path, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8") + subprocess.run(["git", "config", "user.name", "OpenPlate Tests"], cwd=repo_path, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=repo_path, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8") + subprocess.run(["git", "commit", "-m", "init"], cwd=repo_path, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8") + + +def _write_template_repo(repo_path: Path, template_yaml: str): + repo_path.mkdir(parents=True, exist_ok=True) + (repo_path / "openplate.template.yaml").write_text(template_yaml, encoding="utf-8") + (repo_path / "README.md").write_text("template\n", encoding="utf-8") + _create_git_repo(repo_path) + return f"{repo_path.as_uri()}#main" + + +def _build_template_config(parameter: TemplateConfigParameter) -> TemplateConfig: + return TemplateConfig( + parameters=[parameter], + ignore_paths=[], + replacement_paths=[], + user_paths=[], + readonly_paths=[], + optional_paths=[], + rename_rules={}, + config_files={}, + override_tag_start=None, + override_tag_end=None, + override_statement_start=None, + override_statement_end=None, + min_tool_version=None, + remove_files=None, + require_sibling_templates=None, + ignore_inherited_files=None, + init_commands=None, + default_dest_folder=".", + multiplex=[], + conditional=[], + ) + + +def test_resolve_keeps_existing_parameter_without_processing_default(tmp_path): + config_project_template = ProjectTemplateConfig( + "https://example.com/template#main", + None, + None, + ".", + None, + {"service_name": "existing"}, + [], + None, + ) + config_project = ProjectConfig( + [config_project_template], + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + {}, + {}, + None, + ) + config_template = _build_template_config( + TemplateConfigParameter("service_name", "Service Name", "{{", False, None) + ) + + changed = project_config_resolver.resolve( + defaultSettings, + OpenPlateRuntimeSettings(False, False, True, True), + config_template, + config_project, + config_project_template, + str(tmp_path), + str(tmp_path), + False, + ) + + assert changed is True + assert config_project_template.parameters["service_name"] == "existing" + + +def test_project_init_prints_status_before_project_config_load_failure(tmp_path, monkeypatch, capsys): + def fail_from_file(*_args, **_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(project_init.project_config, "from_file", fail_from_file) + + options = InitOptions( + ProjectTemplateConfig("https://example.com/template#main", None, None, ".", None, {}, [], None), + str(tmp_path), + False, + False, + False, + None, + ) + + with pytest.raises(RuntimeError, match="boom"): + asyncio.run(project_init.run(defaultSettings, OpenPlateRuntimeSettings(False, False, True, True), options)) + + assert "Running init on folder:" in capsys.readouterr().out + + +def test_project_update_prints_status_before_project_config_load_failure(tmp_path, monkeypatch, capsys): + def fail_from_file(*_args, **_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(project_update.project_config, "from_file", fail_from_file) + + options = UpdateOptions( + str(tmp_path), + False, + False, + False, + None, + ) + + with pytest.raises(RuntimeError, match="boom"): + asyncio.run(project_update.run(defaultSettings, OpenPlateRuntimeSettings(False, False, True, True), options)) + + assert "Running update on folder:" in capsys.readouterr().out + + +def test_runtime_init_reopens_same_source_for_recursive_siblings(tmp_path, monkeypatch, capsys): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +require_sibling_templates: + - template_url: "{{ template_src_url }}" + dest_folder: "api" +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + async def fake_walk_init(*_args, **_kwargs): + return [] + + monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_init", fake_walk_init) + + enter_count = 0 + original_enter = UrlTemplateSource.__enter__ + + def counting_enter(self): + nonlocal enter_count + enter_count += 1 + return original_enter(self) + + monkeypatch.setattr(UrlTemplateSource, "__enter__", counting_enter) + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--dest-folder", + ".", + ] + + asyncio.run(async_main(args)) + + capsys.readouterr() + assert enter_count == 2 + + +def test_project_config_does_not_persist_raw_prompt_identity_fields(tmp_path): + config = ProjectConfig( + [ + ProjectTemplateConfig( + "https://example.com/template#main", + None, + None, + ".", + None, + {}, + [], + False, + "{{ template_src_url }}", + "services/{{ project_folder_name }}", + "{{ include_api }}", + ) + ], + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + {}, + {}, + None, + ) + + config_path = tmp_path / project_config_file_name + project_init.project_config.to_file(config, str(config_path)) + + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + template_data = data["templates"][0] + + assert "raw_template_reference" not in template_data + assert "raw_dest_folder" not in template_data + assert "raw_condition" not in template_data \ No newline at end of file diff --git a/tests/test_project_init_source_urls.py b/tests/test_project_init_source_urls.py index ecc89fc..50db724 100644 --- a/tests/test_project_init_source_urls.py +++ b/tests/test_project_init_source_urls.py @@ -1,10 +1,29 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# import asyncio import subprocess +from io import StringIO from pathlib import Path import pytest -from openplate.__main__ import async_main, create_arg_parser, resolve_project_init_source_reference +from openplate.__main__ import async_main, create_arg_parser, load_prompt_document, resolve_project_init_source_reference from openplate.cfg import open_plate_settings from openplate.git import GitTemplateReference from openplate.sources.url_source import UrlTemplateSource @@ -79,6 +98,18 @@ def test_create_arg_parser_top_level_help_hides_project_command(): assert "==SUPPRESS==" not in help_text +def test_create_arg_parser_accepts_prompts_json_flags_for_top_level_init(): + args = ["openplate", "init", "https://example.com/template.git#main", "--print-prompts-json"] + parser = create_arg_parser(args) + + result = parser.parse_args(args[1:]) + + assert result.command == "project-init" + assert result.print_prompts_json is True + assert result.prompts_json_file is None + assert result.prompts_json_stdin is False + + def test_resolve_project_init_source_reference_rejects_conflicting_inputs(): result = create_arg_parser(["openplate", "project", "init"]).parse_args([ "project", "init", "https://example.com/template.git#main", "-r", "https://example.com/other.git#main" @@ -88,6 +119,39 @@ def test_resolve_project_init_source_reference_rejects_conflicting_inputs(): resolve_project_init_source_reference(result) +def test_load_prompt_document_rejects_multiple_input_sources(): + result = create_arg_parser(["openplate", "project", "update"]).parse_args([ + "project", "update", "--prompts-json-file", "prompts.json", "--prompts-json-stdin" + ]) + + with pytest.raises(ValueError, match="Specify only one prompts JSON input source"): + load_prompt_document(result) + + +def test_load_prompt_document_rejects_print_mode_with_input_file(tmp_path): + prompts_json = tmp_path / "prompts.json" + prompts_json.write_text("[]", encoding="utf-8") + + result = create_arg_parser(["openplate", "project", "update"]).parse_args([ + "project", "update", "--print-prompts-json", "--prompts-json-file", str(prompts_json) + ]) + + with pytest.raises(ValueError, match="cannot be combined"): + load_prompt_document(result) + + +def test_load_prompt_document_reads_json_from_stdin(monkeypatch): + result = create_arg_parser(["openplate", "project", "update"]).parse_args([ + "project", "update", "--prompts-json-stdin" + ]) + monkeypatch.setattr("sys.stdin", StringIO("[]")) + + document = load_prompt_document(result) + + assert document is not None + assert document.templates == [] + + def test_create_arg_parser_rejects_removed_name_option(): parser = create_arg_parser(["openplate", "project", "init"]) @@ -136,6 +200,64 @@ async def fake_run(*_args, **_kwargs): assert "deprecated" in captured.err +def test_async_main_passes_prompt_document_to_project_update(monkeypatch, tmp_path): + captured_options = {} + + async def fake_run(_settings, _runtime_settings, options): + captured_options["print_prompts_json"] = options.print_prompts_json + captured_options["prompt_document"] = options.prompt_document + + monkeypatch.setattr("openplate.commands.project_update.run", fake_run) + + prompts_json = tmp_path / "prompts.json" + prompts_json.write_text("[]", encoding="utf-8") + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(tmp_path), + "update", + "--prompts-json-file", + str(prompts_json), + ] + + asyncio.run(async_main(args)) + + assert captured_options["print_prompts_json"] is False + assert captured_options["prompt_document"] is not None + assert captured_options["prompt_document"].templates == [] + + +def test_async_main_passes_print_prompts_json_to_project_init(monkeypatch, tmp_path): + captured_options = {} + + async def fake_run(_settings, _runtime_settings, options): + captured_options["print_prompts_json"] = options.print_prompts_json + captured_options["prompt_document"] = options.prompt_document + + monkeypatch.setattr("openplate.commands.project_init.run", fake_run) + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(tmp_path), + "init", + "https://example.com/template.git#main", + "--print-prompts-json", + ] + + asyncio.run(async_main(args)) + + assert captured_options["print_prompts_json"] is True + assert captured_options["prompt_document"] is None + + def test_git_template_reference_parses_query_path_and_ref(): reference = GitTemplateReference.parse("https://github.com/my-org/template-catalog.git?path=python/api#v1") diff --git a/tests/test_prompt_document.py b/tests/test_prompt_document.py new file mode 100644 index 0000000..92b5ec6 --- /dev/null +++ b/tests/test_prompt_document.py @@ -0,0 +1,174 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +import pytest + +from openplate.prompts.prompt_document import PromptDocument, PromptInputTracker + + +pytestmark = pytest.mark.unit + + +def test_prompt_document_rejects_duplicate_template_entries(): + json_string = """ + [ + {"template": "repo#main", "dest_folder": ".", "parameters": {}}, + {"template": "repo#main", "dest_folder": ".", "parameters": {}} + ] + """ + + with pytest.raises(ValueError, match="Duplicate prompt template entry"): + PromptDocument.from_json_string(json_string) + + +def test_prompt_document_rejects_missing_parameter_value_field(): + json_string = """ + [ + { + "template": "repo#main", + "dest_folder": ".", + "parameters": { + "service_name": { + "default": "demo" + } + } + } + ] + """ + + with pytest.raises(ValueError, match="must include a 'value' field"): + PromptDocument.from_json_string(json_string) + + +def test_prompt_document_rejects_non_string_parameter_value(): + json_string = """ + [ + { + "template": "repo#main", + "dest_folder": ".", + "parameters": { + "service_name": { + "value": 123 + } + } + } + ] + """ + + with pytest.raises(TypeError, match="'value' must be a string or null"): + PromptDocument.from_json_string(json_string) + + +def test_prompt_document_rejects_non_boolean_hidden_flag(): + json_string = """ + [ + { + "template": "repo#main", + "dest_folder": ".", + "parameters": { + "service_name": { + "value": null, + "hidden": "yes" + } + } + } + ] + """ + + with pytest.raises(TypeError, match="'hidden' must be a boolean or null"): + PromptDocument.from_json_string(json_string) + + +def test_prompt_document_round_trips_null_parameters(): + json_string = """ + [ + { + "template": "repo#main", + "dest_folder": ".", + "condition": "{{ include_api }}", + "parameters": null + } + ] + """ + + document = PromptDocument.from_json_string(json_string) + + assert document.templates[0].parameters is None + assert '"parameters": null' in document.to_json_string() + + +def test_prompt_input_tracker_treats_null_parameters_as_no_supplied_values(): + tracker = PromptInputTracker.from_json_string( + """ + [ + { + "template": "repo#main", + "dest_folder": ".", + "parameters": null + } + ] + """ + ) + + value, found = tracker.get_parameter_value("repo#main", ".", "service_name") + + assert value is None + assert found is False + + +def test_prompt_input_tracker_reports_unused_supplied_parameters(): + tracker = PromptInputTracker.from_json_string( + """ + [ + { + "template": "repo#main", + "dest_folder": ".", + "parameters": { + "used": {"value": "x"}, + "unused": {"value": "y"}, + "null_value": {"value": null} + } + } + ] + """ + ) + + value, found = tracker.get_parameter_value("repo#main", ".", "used") + + assert value == "x" + assert found is True + assert tracker.unused_parameters("repo#main", ".") == ["unused"] + + +def test_prompt_input_tracker_reports_ignored_templates(): + tracker = PromptInputTracker.from_json_string( + """ + [ + {"template": "repo#main", "dest_folder": ".", "parameters": {}}, + {"template": "repo#main", "dest_folder": "src/api", "parameters": {}} + ] + """ + ) + + tracker.get_template("repo#main", ".") + + ignored = tracker.ignored_templates() + + assert len(ignored) == 1 + assert ignored[0].template == "repo#main" + assert ignored[0].dest_folder == "src/api" \ No newline at end of file diff --git a/tests/test_prompt_json_cli.py b/tests/test_prompt_json_cli.py new file mode 100644 index 0000000..e962ff1 --- /dev/null +++ b/tests/test_prompt_json_cli.py @@ -0,0 +1,617 @@ +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +import asyncio +import json +import logging +import subprocess +from io import StringIO +from pathlib import Path + +import pytest +import yaml + +from openplate.__main__ import async_main +from openplate.cfg import project_config +from openplate.sources.url_source import UrlTemplateSource + + +pytestmark = pytest.mark.unit + + +def _create_git_repo(repo_path: Path): + repo_path.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "init"], cwd=repo_path, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8") + subprocess.run(["git", "branch", "-M", "main"], cwd=repo_path, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8") + subprocess.run(["git", "config", "user.email", "tests@example.com"], cwd=repo_path, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8") + subprocess.run(["git", "config", "user.name", "OpenPlate Tests"], cwd=repo_path, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=repo_path, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8") + subprocess.run(["git", "commit", "-m", "init"], cwd=repo_path, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8") + + +def _write_template_repo(repo_path: Path, template_yaml: str): + repo_path.mkdir(parents=True, exist_ok=True) + (repo_path / "openplate.template.yaml").write_text(template_yaml, encoding="utf-8") + (repo_path / "README.md").write_text("template\n", encoding="utf-8") + _create_git_repo(repo_path) + return f"{repo_path.as_uri()}#main" + + +def _write_project_config(project_path: Path, source_url: str, dest_folder: str = "."): + project_path.mkdir(parents=True, exist_ok=True) + project_config_path = project_path / project_config.project_config_file_name + project_config_path.write_text( + yaml.safe_dump( + { + "templates": [ + { + "src_url": source_url, + "dest_folder": dest_folder, + "parameters": {}, + } + ], + "parameters": {}, + "template_file_cache": {}, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + +def test_project_init_print_prompts_json_is_read_only_and_includes_conditional_sibling(tmp_path, capsys): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +parameters: + - name: service_name + description: Service Name + - name: include_api + description: Include API + default: "false" +require_sibling_templates: + - template_url: "{{ template_src_url }}" + dest_folder: "services/{{ project_folder_name }}/api" + condition: "{{ include_api }}" + parameters: + service_name: "{{ service_name }}" +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--dest-folder", + ".", + "--print-prompts-json", + ] + + asyncio.run(async_main(args)) + + printed = json.loads(capsys.readouterr().out) + assert not (project_path / project_config.project_config_file_name).exists() + + root_node = next(node for node in printed if node["template"] == source_url) + assert root_node["dest_folder"] == "." + assert root_node["parameters"]["service_name"]["value"] is None + assert root_node["parameters"]["service_name"]["required"] is True + assert root_node["parameters"]["include_api"]["default"] == "false" + assert root_node["parameters"]["include_api"]["required"] is False + + sibling_node = next(node for node in printed if node["template"] == "{{ template_src_url }}") + assert sibling_node["dest_folder"] == "services/{{ project_folder_name }}/api" + assert sibling_node["condition"] == "{{ include_api }}" + + +def test_project_init_print_prompts_json_uses_null_parameters_when_sibling_config_cannot_load(tmp_path, capsys): + repo_path = tmp_path / "template" + sibling_source_url = f"{repo_path.as_uri()}?path=missing#main" + source_url = _write_template_repo( + repo_path, + "\n".join( + [ + "require_sibling_templates:", + f" - template_url: \"{sibling_source_url}\"", + " dest_folder: \"broken\"", + ] + ), + ) + project_path = tmp_path / "project" + project_path.mkdir() + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--dest-folder", + ".", + "--print-prompts-json", + ] + + asyncio.run(async_main(args)) + + printed = json.loads(capsys.readouterr().out) + sibling_node = next(node for node in printed if node["template"] == sibling_source_url) + assert sibling_node["parameters"] is None + + +def test_project_init_print_prompts_json_deduplicates_duplicate_discovered_templates(tmp_path, capsys): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +require_sibling_templates: + - template_url: "{{ template_src_url }}" + dest_folder: "shared" + - template_url: "{{ template_src_url }}" + dest_folder: "shared" +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--dest-folder", + ".", + "--print-prompts-json", + ] + + asyncio.run(async_main(args)) + + printed = json.loads(capsys.readouterr().out) + shared_nodes = [node for node in printed if node["template"] == "{{ template_src_url }}" and node["dest_folder"] == "shared"] + assert len(shared_nodes) == 1 + + +def test_project_update_print_prompts_json_preserves_raw_sibling_identity_for_existing_template(tmp_path, capsys, monkeypatch): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +parameters: + - name: include_api + description: Include API + default: "true" +require_sibling_templates: + - template_url: "{{ template_src_url }}" + dest_folder: "services/{{ project_folder_name }}/api" + condition: "{{ include_api }}" +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + prompts_path = tmp_path / "prompts.json" + prompts_path.write_text( + json.dumps( + [ + { + "template": source_url, + "dest_folder": ".", + "parameters": { + "include_api": {"value": "true"}, + }, + } + ] + ), + encoding="utf-8", + ) + + async def fake_walk_init(*_args, **_kwargs): + return [] + + async def fake_walk_update(*_args, **_kwargs): + return None + + monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_init", fake_walk_init) + monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_update", fake_walk_update) + + init_args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--dest-folder", + ".", + "--prompts-json-file", + str(prompts_path), + ] + asyncio.run(async_main(init_args)) + capsys.readouterr() + + update_args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "update", + "--print-prompts-json", + ] + asyncio.run(async_main(update_args)) + + printed = json.loads(capsys.readouterr().out) + sibling_nodes = [ + node for node in printed + if node["template"] == "{{ template_src_url }}" + and node["dest_folder"] == "services/{{ project_folder_name }}/api" + ] + + assert len(sibling_nodes) == 1 + assert sibling_nodes[0]["condition"] == "{{ include_api }}" + + +def test_project_update_warns_for_ignored_templates_and_unused_parameters(tmp_path, caplog, monkeypatch): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +parameters: + - name: service_name + description: Service Name +""", + ) + project_path = tmp_path / "project" + _write_project_config(project_path, source_url) + + async def fake_walk_update(*_args, **_kwargs): + return None + + monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_update", fake_walk_update) + + prompts_path = tmp_path / "prompts.json" + prompts_path.write_text( + json.dumps( + [ + { + "template": source_url, + "dest_folder": ".", + "parameters": { + "service_name": {"value": "demo"}, + "unused": {"value": "leftover"}, + }, + }, + { + "template": "unused-template#main", + "dest_folder": "ignored", + "parameters": {}, + }, + ] + ), + encoding="utf-8", + ) + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "update", + "--prompts-json-file", + str(prompts_path), + ] + + with caplog.at_level(logging.WARNING): + asyncio.run(async_main(args)) + + assert any("Ignoring unused supplied prompt parameter" in record.message for record in caplog.records) + assert any("Ignoring supplied prompt template because it was not processed" in record.message for record in caplog.records) + + +def test_project_init_accepts_blank_string_prompt_value(tmp_path): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +parameters: + - name: service_name + description: Service Name +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + prompts_path = tmp_path / "prompts.json" + prompts_path.write_text( + json.dumps( + [ + { + "template": source_url, + "dest_folder": ".", + "parameters": { + "service_name": {"value": ""}, + }, + } + ] + ), + encoding="utf-8", + ) + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--dest-folder", + ".", + "--prompts-json-file", + str(prompts_path), + ] + + asyncio.run(async_main(args)) + + written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) + assert written_config["templates"][0]["parameters"]["service_name"] == "" + + +def test_project_init_accepts_prompts_json_from_stdin(tmp_path, monkeypatch): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +parameters: + - name: service_name + description: Service Name +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + monkeypatch.setattr( + "sys.stdin", + StringIO( + json.dumps( + [ + { + "template": source_url, + "dest_folder": ".", + "parameters": { + "service_name": {"value": "stdin-demo"}, + }, + } + ] + ) + ), + ) + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--dest-folder", + ".", + "--prompts-json-stdin", + ] + + asyncio.run(async_main(args)) + + written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) + assert written_config["templates"][0]["parameters"]["service_name"] == "stdin-demo" + + +def test_project_init_uses_default_value_in_json_mode_without_prompting(tmp_path): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +parameters: + - name: service_name + description: Service Name + default: demo +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + prompts_path = tmp_path / "prompts.json" + prompts_path.write_text( + json.dumps( + [ + { + "template": source_url, + "dest_folder": ".", + "parameters": None + } + ] + ), + encoding="utf-8", + ) + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--dest-folder", + ".", + "--prompts-json-file", + str(prompts_path), + ] + + asyncio.run(async_main(args)) + + written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) + assert written_config["templates"][0]["parameters"]["service_name"] == "demo" + + +def test_project_init_fails_when_required_value_remains_unresolved_in_json_mode(tmp_path): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +parameters: + - name: service_name + description: Service Name +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + prompts_path = tmp_path / "prompts.json" + prompts_path.write_text( + json.dumps( + [ + { + "template": source_url, + "dest_folder": ".", + "parameters": { + "service_name": {"value": None}, + }, + } + ] + ), + encoding="utf-8", + ) + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--dest-folder", + ".", + "--prompts-json-file", + str(prompts_path), + ] + + with pytest.raises(Exception, match="unresolved parameter"): + asyncio.run(async_main(args)) + + +def test_project_init_fails_template_command_confirmation_in_json_mode(tmp_path): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +init_commands: + - command: echo setup +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + prompts_path = tmp_path / "prompts.json" + prompts_path.write_text("[]", encoding="utf-8") + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--dest-folder", + ".", + "--prompts-json-file", + str(prompts_path), + ] + + with pytest.raises(SystemExit) as ex: + asyncio.run(async_main(args)) + + assert ex.value.code == 1 + + +def test_project_init_print_prompts_json_reuses_single_source_fetch(tmp_path, monkeypatch, capsys): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +require_sibling_templates: + - template_url: "{{ template_src_url }}" + dest_folder: "services/{{ project_folder_name }}/api" +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + enter_count = 0 + original_enter = UrlTemplateSource.__enter__ + + def counting_enter(self): + nonlocal enter_count + enter_count += 1 + return original_enter(self) + + monkeypatch.setattr(UrlTemplateSource, "__enter__", counting_enter) + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--dest-folder", + ".", + "--print-prompts-json", + ] + + asyncio.run(async_main(args)) + + capsys.readouterr() + assert enter_count == 1 \ No newline at end of file From 109b3d4c90f86ecbc9f317101ee8a4e5b39dfea1 Mon Sep 17 00:00:00 2001 From: jasonsky-pub <275120179+jasonsky-pub@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:12:03 -0400 Subject: [PATCH 3/8] Refine prompt JSON semantics --- .gitignore | 2 + docs/commands.md | 19 +- docs/templates.md | 4 +- .../changes/add-prompts-json-cli/design.md | 15 +- .../changes/add-prompts-json-cli/proposal.md | 3 +- .../specs/project-prompts-json/spec.md | 32 +- .../changes/add-prompts-json-cli/tasks.md | 5 +- src/openplate/cfg/template_config.py | 2 +- src/openplate/project_config_resolver.py | 7 + .../prompts/prompt_parameter_resolver.py | 3 + tests/test_existing_runtime_regressions.py | 22 +- tests/test_prompt_json_cli.py | 287 ++++++++++++++++++ 12 files changed, 388 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index a57e1c9..dd6574b 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,5 @@ dmypy.json # Pyre type checker .pyre/ .idea + +trial-runs \ No newline at end of file diff --git a/docs/commands.md b/docs/commands.md index ad5bc8a..e17d83d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -103,18 +103,29 @@ The printed document is a top-level JSON array grouped by template instance. Eac - `condition`: included when the template declaration has one - `parameters`: either an object keyed by parameter name or `null` when OpenPlate cannot inspect that template closely enough to enumerate parameter metadata -Each parameter entry includes `value`, `default`, `existing`, `description`, `choices`, `hidden`, and `required`. +Each exported parameter entry includes `value`, `default`, `existing`, `description`, `choices`, `hidden`, and `required`. + +Hidden parameters are included only when the command uses `--ask-hidden`. Without `--ask-hidden`, hidden parameters are omitted from prompt JSON export and ignored on prompt JSON import. `value` semantics: -- `null` means no answer was supplied in the prompt document -- `""` means an intentional blank string +- `null` means do not answer this parameter from JSON; if the parameter is reached, OpenPlate uses the normal runtime fallback such as an existing value or template/default value +- `""` means an intentional blank string answer +- any other non-null string means an explicit supplied answer for that parameter - omitting `value` is invalid on import +Import semantics: + +- OpenPlate uses only `template`, `dest_folder`, and each parameter entry's `value` when importing prompt JSON. +- `condition`, `default`, `existing`, `description`, `choices`, `hidden`, and `required` are informational metadata on import. +- For parameters in scope for the command, any non-null `value` is authoritative even if runtime fallback already has an existing or default value. +- `--ask-again` affects interactive prompting. It does not prevent a non-null prompt JSON `value` from being applied. + Notes: - `--print-prompts-json` is read-only. It does not update `.openplate.project.yaml` or write template output. - `--print-prompts-json` is the only mode that walks the full declared sibling tree without applying sibling `condition` filters. +- `condition` is included in exported JSON for visibility only and is ignored on import. - `--prompts-json-file` and `--prompts-json-stdin` stay on the normal runtime walk and fail instead of prompting if required values or template-command confirmations are still unresolved. - OpenPlate ignores extra template nodes that are not processed by the run and logs which raw `template` and `dest_folder` entries were ignored. - OpenPlate warns when supplied parameter values are left unused for a matched template instance. @@ -149,6 +160,8 @@ or openplate update --ask-hidden ``` +The same flag controls prompt JSON scope. With `--ask-hidden`, hidden parameters are included in `--print-prompts-json` output and may be answered through `--prompts-json-file` or `--prompts-json-stdin`. Without it, hidden parameters are omitted from export and ignored on import. + # Template Branches NOTE: to use a specific branch or tag of a template, append `#branchname` on init. If you omit `#branchname`, you must also pass `--allow-default-branch`. diff --git a/docs/templates.md b/docs/templates.md index 870da2b..37116a4 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -108,7 +108,9 @@ parameters: hidden: True ``` -In order for a user to specify it, they need to pass "--ask-hidden" to their command and possiblly "--ask-again" +In order for a user to specify it interactively, they need to pass "--ask-hidden" to their command and possibly "--ask-again". + +The same `--ask-hidden` flag also controls prompt JSON scope. Hidden parameters are included in `--print-prompts-json` output only when `--ask-hidden` is used, and hidden values from `--prompts-json-file` or `--prompts-json-stdin` are applied only when `--ask-hidden` is active for that command. ### "Conditionally Hidden" diff --git a/openspec/changes/add-prompts-json-cli/design.md b/openspec/changes/add-prompts-json-cli/design.md index c0d562c..43837f4 100644 --- a/openspec/changes/add-prompts-json-cli/design.md +++ b/openspec/changes/add-prompts-json-cli/design.md @@ -14,6 +14,7 @@ This change needs to cross the CLI parser, prompt resolver, and recursive templa - Scope prompt answers to a template instance using the template reference and destination folder, with parameters nested under that template node. - During `--print-prompts-json`, walk the full declared template tree without applying sibling `condition` filters. - Include template `condition` metadata in printed JSON when present, but ignore it during import. +- Scope hidden parameters to `--ask-hidden` on both prompt export and prompt import. - Distinguish incomplete parameter discovery from truly parameterless templates in the printed JSON document. - Reuse fetched template sources within a single command invocation so validation/discovery does not clone the same template source twice. - Preserve current console-style error reporting while adding always-on warnings for extra supplied parameters and always-on ignored-template log messages. @@ -62,15 +63,20 @@ When `parameters` is an object, each parameter entry will include: - `required` `value` semantics are explicit: -- `null` means no value has been supplied for this parameter in the prompt document -- `""` means an explicit blank string value +- `null` means no answer has been supplied for this parameter, so normal runtime fallback still applies if that parameter is reached +- `""` means an explicit blank string answer +- any other non-null `value` is the authoritative supplied answer for that parameter when the parameter is in scope for the command - omission of the `value` field in an imported parameter entry is invalid `required` is derived from current OpenPlate prompt behavior: a parameter is required when it has no resolved existing value, no resolved default value, and therefore would fail if JSON-input mode reaches it without a supplied value. +Hidden parameters are part of the prompt document only when `--ask-hidden` is active for that command. Without `--ask-hidden`, hidden parameters are omitted from `--print-prompts-json` output and ignored during JSON import. + If print-mode discovery can enumerate a template declaration but cannot load that template's config closely enough to discover parameter metadata during export, OpenPlate will still emit the template node using the preserved raw `template`, `dest_folder`, and optional `condition`, and it will emit `parameters` as `null`. -Import will accept the same structure. During import, OpenPlate will use only the template identity fields and each parameter's `value`. A template node with `parameters: null` is treated as having no supplied parameter values. Other fields are treated as export metadata and ignored if returned, including `condition`. +Import will accept the same structure. During import, OpenPlate will use only the template identity fields and each parameter's `value`. A template node with `parameters: null` is treated as having no supplied parameter values. Other fields are treated as informational export metadata and ignored for import semantics, including `condition`, `default`, `existing`, `description`, `choices`, `hidden`, and `required`. + +For parameters that are in scope for the command, a non-null JSON `value` overrides runtime fallback even when the template already has an existing value or interactive mode would not have re-prompted that parameter. `--ask-again` continues to control interactive re-prompting, but it does not block a non-null supplied JSON answer. Alternatives considered: - Use a flat list of parameter rows. Rejected because it duplicates template identity on every entry and makes sibling targeting harder to read. @@ -96,7 +102,7 @@ OpenPlate will validate supplied JSON against the actual template instances that - If supplied JSON contains a duplicate template node, OpenPlate errors before processing. - If supplied JSON contains a template node that is never processed, OpenPlate ignores that node and prints an always-on log message identifying the raw `template` and `dest_folder` that were ignored. -- If supplied JSON contains parameter values that are not needed by the matched template instance, OpenPlate collects warnings and prints them at the end even when debug logging is disabled. +- If supplied JSON contains parameter values that are not needed by the matched template instance, including hidden parameters supplied without `--ask-hidden`, OpenPlate collects warnings and prints them at the end even when debug logging is disabled. This approach avoids a larger refactor to fully predict selection outcomes up front, especially for conditions that can depend on config files or later template state. @@ -135,6 +141,7 @@ Alternatives considered: - Add the new flags without changing existing interactive init or update flows when the JSON flags are not used. - Document the read-only export/import JSON workflow for automation and AI-assisted runs, including that full-tree discovery is print-only, runtime validation uses actual processed templates, and template sources are reused within a command. - Keep existing human-readable error messages, with new validation and warning text layered onto the current failure paths. +- Document hidden-parameter scope, `null` versus non-null `value` semantics, and metadata-only fields directly in the command reference so automation can author prompt JSON without inferring behavior. ## Open Questions diff --git a/openspec/changes/add-prompts-json-cli/proposal.md b/openspec/changes/add-prompts-json-cli/proposal.md index f0bfa11..597d123 100644 --- a/openspec/changes/add-prompts-json-cli/proposal.md +++ b/openspec/changes/add-prompts-json-cli/proposal.md @@ -10,10 +10,11 @@ OpenPlate can already stop when a prompt would be required, but it does not give - Make `--print-prompts-json` a read-only planning/export mode that does not write project files or mutate workspace state. - Make `--print-prompts-json` the only mode that walks the full declared template tree without applying sibling `condition` filters. - Include template `condition` metadata in printed JSON output for visibility, while ignoring that field on import. +- Scope hidden parameters to `--ask-hidden` on both prompt export and prompt import. - Require JSON-input modes to fail instead of prompting when required values are still unresolved. - Warn when supplied JSON contains extra parameters that were not needed, and print a log message including the template and destination folder when a template instance from the JSON is ignored because it is not processed by the run. - Reject duplicate template-instance entries that use the same template reference and destination folder. -- Define `value: null` as unspecified, `value: ""` as an intentional blank string, and omission of `value` in an imported parameter entry as invalid. +- Define `value: null` as "do not answer this parameter; let runtime fallback apply", `value: ""` as an intentional blank string answer, and any other non-null `value` as the authoritative supplied answer for that in-scope parameter. - When print-mode can discover a template declaration but cannot inspect its parameter metadata, emit `parameters: null` so automation can distinguish incomplete discovery from templates that truly have no parameters. - Reuse fetched template sources within a command invocation so OpenPlate does not pull a repo once for prompt validation and then pull it again for actual processing. diff --git a/openspec/changes/add-prompts-json-cli/specs/project-prompts-json/spec.md b/openspec/changes/add-prompts-json-cli/specs/project-prompts-json/spec.md index 645a00a..12fa87f 100644 --- a/openspec/changes/add-prompts-json-cli/specs/project-prompts-json/spec.md +++ b/openspec/changes/add-prompts-json-cli/specs/project-prompts-json/spec.md @@ -9,12 +9,14 @@ OpenPlate SHALL support `--print-prompts-json` for `project init` and `project u Each template node SHALL include `template`, `dest_folder`, and `parameters`. If the template declaration includes a condition, the node SHALL also include `condition`. Template and destination values in this JSON SHALL use the raw templated strings rather than rendered values. -When OpenPlate can inspect a declared template node closely enough to enumerate parameter metadata during `--print-prompts-json`, `parameters` SHALL be an object keyed by parameter name. Each parameter entry SHALL include `value`, `default`, `existing`, `description`, `choices`, `hidden`, and `required` so the printed document can be edited and sent back through the import flow. +When OpenPlate can inspect a declared template node closely enough to enumerate parameter metadata during `--print-prompts-json`, `parameters` SHALL be an object keyed by parameter name. Each parameter entry SHALL include `value`, `default`, `existing`, `description`, `choices`, `hidden`, and `required` so the printed document can be edited and sent back through the import flow. Hidden parameters SHALL be included only when `--ask-hidden` is active for that command. If print-mode discovery can enumerate a template declaration but cannot load that template's config closely enough to discover parameter metadata during export, OpenPlate SHALL still include the template node and SHALL emit `parameters` as `null`. In printed and imported parameter entries, `value: null` SHALL mean no value has been supplied, `value: ""` SHALL mean an explicit blank string, and omission of the `value` field SHALL be invalid on import. +For parameters that are included in the prompt document for a command, all metadata fields other than `value` SHALL be informational only. + #### Scenario: Print prompts JSON for init - **WHEN** a user runs `openplate project init --print-prompts-json` - **THEN** OpenPlate prints a JSON array of template nodes for the declared init template tree @@ -40,6 +42,14 @@ In printed and imported parameter entries, `value: null` SHALL mean no value has - **THEN** OpenPlate includes that raw `condition` string in the printed JSON node - **AND** the printed `condition` field is treated as informational metadata rather than required input +#### Scenario: Hidden parameters are omitted from export without ask-hidden +- **WHEN** a user runs `openplate project init --print-prompts-json` without `--ask-hidden` +- **THEN** OpenPlate omits hidden parameters from exported `parameters` objects + +#### Scenario: Hidden parameters are included in export with ask-hidden +- **WHEN** a user runs `openplate project init --ask-hidden --print-prompts-json` +- **THEN** OpenPlate includes hidden parameters in exported `parameters` objects + ### Requirement: OpenPlate accepts prompt answers from JSON without prompting OpenPlate SHALL support `--prompts-json-file ` and `--prompts-json-stdin` for `project init` and `project update`. When either flag is used, OpenPlate MUST consume prompt answers from the provided JSON document and MUST NOT fall back to interactive prompting. @@ -80,6 +90,12 @@ OpenPlate SHALL match supplied prompt answers to template instances using the pa During import, OpenPlate MUST use each parameter entry's `value` field as the supplied answer. Other metadata fields in template nodes and parameter entries MAY be present and MUST be ignored for import semantics. +During import, `value: null` MUST mean that OpenPlate does not answer that parameter from JSON and instead uses the normal runtime fallback for that parameter if it is reached. + +During import, any non-null `value`, including `""`, MUST be treated as the authoritative supplied answer for that parameter when the parameter is in scope for the command, even when runtime fallback already has an existing or default value. + +During import, hidden parameters MUST be in scope only when `--ask-hidden` is active for that command. Hidden values supplied without `--ask-hidden` MUST be ignored as unused supplied parameters. + During import, a template node with `parameters: null` MUST be treated as having no supplied parameter values. During import, OpenPlate MUST reject a parameter entry that omits the `value` field. @@ -95,6 +111,11 @@ If a supplied template node does not correspond to a template instance that is a - **THEN** OpenPlate accepts the document as prompt input - **AND** OpenPlate ignores unchanged metadata fields such as `condition`, `description`, `default`, and `existing` +#### Scenario: Null value uses runtime fallback +- **WHEN** a supplied JSON parameter entry sets `value` to `null` +- **THEN** OpenPlate does not answer that parameter from JSON +- **AND** OpenPlate uses the normal runtime fallback for that parameter if it is reached + #### Scenario: Omitted value field is invalid - **WHEN** a supplied JSON parameter entry omits the `value` field - **THEN** OpenPlate fails validation for the imported prompt document @@ -103,6 +124,15 @@ If a supplied template node does not correspond to a template instance that is a - **WHEN** a supplied JSON parameter entry sets `value` to `""` - **THEN** OpenPlate treats that parameter as explicitly supplied with a blank string value +#### Scenario: Non-null value overrides existing fallback +- **WHEN** a supplied JSON parameter entry sets `value` to a non-null string for a parameter that already has an existing runtime value +- **THEN** OpenPlate uses the supplied JSON value for that parameter + +#### Scenario: Hidden value is ignored without ask-hidden +- **WHEN** a supplied JSON document sets `value` for a hidden parameter and the command does not use `--ask-hidden` +- **THEN** OpenPlate ignores that supplied hidden value +- **AND** OpenPlate warns that the supplied parameter was unused + #### Scenario: Duplicate template entries in supplied JSON - **WHEN** a supplied JSON document contains two template nodes with the same `template` and `dest_folder` - **THEN** OpenPlate fails validation before template processing begins diff --git a/openspec/changes/add-prompts-json-cli/tasks.md b/openspec/changes/add-prompts-json-cli/tasks.md index 9058ca7..5252a79 100644 --- a/openspec/changes/add-prompts-json-cli/tasks.md +++ b/openspec/changes/add-prompts-json-cli/tasks.md @@ -13,10 +13,13 @@ - [x] 2.3 Deduplicate exported template nodes by raw `(template, dest_folder)` using first-match behavior so exported JSON remains valid input. - [x] 2.4 Thread imported prompt values into parameter resolution so JSON input uses parameter `value` fields, distinguishes `null` from `""`, rejects omitted `value` fields, and never falls back to interactive prompting. - [x] 2.5 Ensure JSON-input modes stay on the normal runtime walk, fail on unresolved parameters or template-command confirmations instead of prompting, and do not switch to the print-only full-tree discovery behavior. +- [x] 2.6 Scope hidden parameters to `--ask-hidden` on prompt export and prompt import, while treating non-null JSON `value` fields as authoritative for in-scope parameters regardless of existing/default runtime fallback. ## 3. Validation, warnings, and coverage - [x] 3.1 Track supplied template and parameter usage during processing so unmatched template nodes produce always-on ignored-template log messages and unused supplied parameters produce always-on warnings. - [x] 3.2 Preserve the existing first-match runtime behavior for duplicate discovered sibling templates while keeping export deduplication and import validation strict for duplicate supplied template nodes. - [x] 3.3 Add focused tests for read-only prompt JSON export, print-only full-tree export without applying conditions, `parameters: null` declaration nodes, export deduplication, JSON import from file and stdin, duplicate-template validation, ignored-template log messages, omitted-value failures, blank-string handling, unused-parameter warnings, single-fetch reuse, and unresolved-value failure paths. -- [x] 3.4 Update command documentation with the JSON round-trip workflow for machine-driven `project init` and `project update`. \ No newline at end of file +- [x] 3.4 Update command documentation with the JSON round-trip workflow for machine-driven `project init` and `project update`. +- [x] 3.5 Add focused tests for hidden-parameter export scoping, hidden JSON import scoping, and authoritative non-null JSON overrides of existing runtime values. +- [x] 3.6 Clarify the OpenSpec artifacts and docs so `null`, non-null `value`, metadata-only fields, and `--ask-hidden` scope are all explicit. \ No newline at end of file diff --git a/src/openplate/cfg/template_config.py b/src/openplate/cfg/template_config.py index 9eb78ad..4e34895 100644 --- a/src/openplate/cfg/template_config.py +++ b/src/openplate/cfg/template_config.py @@ -300,7 +300,7 @@ def deserialize_conditional_list(data): for template in data: conditional.append( ConditionalFile( - template.get("location"), + template.get("location") or template.get("file"), template.get("condition") ) ) diff --git a/src/openplate/project_config_resolver.py b/src/openplate/project_config_resolver.py index df0d75f..c5fd869 100644 --- a/src/openplate/project_config_resolver.py +++ b/src/openplate/project_config_resolver.py @@ -19,6 +19,7 @@ import logging from typing import Optional +from openplate import template_processor from openplate.cfg import template_config, project_config from openplate.cfg.open_plate_settings import OpenPlateRuntimeSettings, OpenPlateSettings from openplate.cfg.template_config import TemplateConfigParameter @@ -30,6 +31,7 @@ resolve_runtime_parameter_fallback, try_resolve_parameter_without_prompt, ) +from openplate.template_processor import compile_template_options def resolve_parameter_hidden_state( @@ -100,6 +102,11 @@ def resolve_parameter( parameter, ) + # Auto answer case, hidden: + if parameter.hidden and not runtime_settings.ask_hidden: + logging.debug(f"not prompting for hidden parameter[{parameter.name}]") + return False, fallback_value + resolved_answer = try_resolve_parameter_without_prompt( config_project_template, parameter, diff --git a/src/openplate/prompts/prompt_parameter_resolver.py b/src/openplate/prompts/prompt_parameter_resolver.py index 0fd3d1a..88ffc10 100644 --- a/src/openplate/prompts/prompt_parameter_resolver.py +++ b/src/openplate/prompts/prompt_parameter_resolver.py @@ -168,6 +168,9 @@ def describe_prompt_parameters( result = {} for parameter in config_template.parameters: + if parameter.hidden and not runtime_settings.ask_hidden: + continue + existing_value, default_value = resolve_parameter_defaults( settings, runtime_settings, diff --git a/tests/test_existing_runtime_regressions.py b/tests/test_existing_runtime_regressions.py index 50dd55d..765287a 100644 --- a/tests/test_existing_runtime_regressions.py +++ b/tests/test_existing_runtime_regressions.py @@ -25,6 +25,7 @@ from openplate import project_config_resolver from openplate.__main__ import async_main +from openplate.cfg import template_config from openplate.cfg.open_plate_settings import OpenPlateRuntimeSettings, defaultSettings from openplate.cfg.project_config import ProjectConfig, ProjectTemplateConfig, project_config_file_name from openplate.cfg.template_config import TemplateConfig, TemplateConfigParameter @@ -254,4 +255,23 @@ def test_project_config_does_not_persist_raw_prompt_identity_fields(tmp_path): assert "raw_template_reference" not in template_data assert "raw_dest_folder" not in template_data - assert "raw_condition" not in template_data \ No newline at end of file + assert "raw_condition" not in template_data + + +def test_template_config_accepts_legacy_conditional_file_field(): + config = template_config.deserialize_project_config( + { + "parameters": [], + "conditional": [ + { + "file": "src/demo/aws-lambda-tools-defaults.json", + "condition": "{{ include_lambda }}", + } + ], + } + ) + + assert config.conditional is not None + assert len(config.conditional) == 1 + assert config.conditional[0].location == "src/demo/aws-lambda-tools-defaults.json" + assert config.conditional[0].condition == "{{ include_lambda }}" \ No newline at end of file diff --git a/tests/test_prompt_json_cli.py b/tests/test_prompt_json_cli.py index e962ff1..6873ee0 100644 --- a/tests/test_prompt_json_cli.py +++ b/tests/test_prompt_json_cli.py @@ -200,6 +200,86 @@ def test_project_init_print_prompts_json_deduplicates_duplicate_discovered_templ assert len(shared_nodes) == 1 +def test_project_init_print_prompts_json_excludes_hidden_parameters_without_ask_hidden(tmp_path, capsys): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +parameters: + - name: service_name + description: Service Name + - name: hidden_name + description: Hidden Name + default: secret + hidden: true +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--dest-folder", + ".", + "--print-prompts-json", + ] + + asyncio.run(async_main(args)) + + printed = json.loads(capsys.readouterr().out) + root_node = next(node for node in printed if node["template"] == source_url) + + assert "service_name" in root_node["parameters"] + assert "hidden_name" not in root_node["parameters"] + + +def test_project_init_print_prompts_json_includes_hidden_parameters_with_ask_hidden(tmp_path, capsys): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +parameters: + - name: service_name + description: Service Name + - name: hidden_name + description: Hidden Name + default: secret + hidden: true +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "--ask-hidden", + "init", + source_url, + "--dest-folder", + ".", + "--print-prompts-json", + ] + + asyncio.run(async_main(args)) + + printed = json.loads(capsys.readouterr().out) + root_node = next(node for node in printed if node["template"] == source_url) + + assert root_node["parameters"]["hidden_name"]["hidden"] is True + + def test_project_update_print_prompts_json_preserves_raw_sibling_identity_for_existing_template(tmp_path, capsys, monkeypatch): repo_path = tmp_path / "template" source_url = _write_template_repo( @@ -491,6 +571,213 @@ def test_project_init_uses_default_value_in_json_mode_without_prompting(tmp_path assert written_config["templates"][0]["parameters"]["service_name"] == "demo" +def test_project_init_json_mode_uses_supplied_value_for_existing_sibling_parameter(tmp_path, caplog, monkeypatch): + child_repo_path = tmp_path / "child-template" + child_source_url = _write_template_repo( + child_repo_path, + """ +parameters: + - name: artifact_name + description: Artifact Name +""", + ) + root_repo_path = tmp_path / "root-template" + root_source_url = _write_template_repo( + root_repo_path, + f""" +parameters: + - name: service_name + description: Service Name +require_sibling_templates: + - template_url: "{child_source_url}" + dest_folder: "child" + parameters: + artifact_name: "{{{{ service_name }}}}" +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + prompts_path = tmp_path / "prompts.json" + prompts_path.write_text( + json.dumps( + [ + { + "template": root_source_url, + "dest_folder": ".", + "parameters": { + "service_name": {"value": "demo"}, + }, + }, + { + "template": child_source_url, + "dest_folder": "child", + "parameters": { + "artifact_name": {"value": "override"}, + }, + }, + ] + ), + encoding="utf-8", + ) + + async def fake_walk_init(*_args, **_kwargs): + return [] + + async def fake_walk_update(*_args, **_kwargs): + return None + + monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_init", fake_walk_init) + monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_update", fake_walk_update) + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + root_source_url, + "--dest-folder", + ".", + "--prompts-json-file", + str(prompts_path), + ] + + with caplog.at_level(logging.WARNING): + asyncio.run(async_main(args)) + + written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) + child_template = next( + template + for template in written_config["templates"] + if template["src_url"] == child_source_url and template["dest_folder"] == "child" + ) + + assert child_template["parameters"]["artifact_name"] == "override" + assert not any( + "Ignoring unused supplied prompt parameter" in record.message and "artifact_name" in record.message + for record in caplog.records + ) + + +def test_project_init_json_mode_ignores_hidden_value_without_ask_hidden(tmp_path, caplog): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +parameters: + - name: service_name + description: Service Name + default: demo + - name: hidden_name + description: Hidden Name + default: secret + hidden: true +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + prompts_path = tmp_path / "prompts.json" + prompts_path.write_text( + json.dumps( + [ + { + "template": source_url, + "dest_folder": ".", + "parameters": { + "hidden_name": {"value": "override"}, + }, + } + ] + ), + encoding="utf-8", + ) + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--dest-folder", + ".", + "--prompts-json-file", + str(prompts_path), + ] + + with caplog.at_level(logging.WARNING): + asyncio.run(async_main(args)) + + written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) + assert written_config["templates"][0]["parameters"]["hidden_name"] == "secret" + assert any( + "Ignoring unused supplied prompt parameter" in record.message and "hidden_name" in record.message + for record in caplog.records + ) + + +def test_project_init_json_mode_uses_hidden_value_with_ask_hidden(tmp_path): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, + """ +parameters: + - name: service_name + description: Service Name + default: demo + - name: hidden_name + description: Hidden Name + default: secret + hidden: true +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + + prompts_path = tmp_path / "prompts.json" + prompts_path.write_text( + json.dumps( + [ + { + "template": source_url, + "dest_folder": ".", + "parameters": { + "hidden_name": {"value": "override"}, + }, + } + ] + ), + encoding="utf-8", + ) + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "--ask-hidden", + "init", + source_url, + "--dest-folder", + ".", + "--prompts-json-file", + str(prompts_path), + ] + + asyncio.run(async_main(args)) + + written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) + assert written_config["templates"][0]["parameters"]["hidden_name"] == "override" + + def test_project_init_fails_when_required_value_remains_unresolved_in_json_mode(tmp_path): repo_path = tmp_path / "template" source_url = _write_template_repo( From a66367a71192a2497b4c097c8f4f8c5a4a38951d Mon Sep 17 00:00:00 2001 From: jasonsky-pub <275120179+jasonsky-pub@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:55:54 -0400 Subject: [PATCH 4/8] Archive prompts-json change and refine compact node-id spec --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/project-prompts-json/spec.md | 0 .../2026-06-04-add-prompts-json-cli}/tasks.md | 0 .../.openspec.yaml | 2 + .../compact-prompts-json-node-ids/design.md | 235 +++++++++++++++ .../compact-prompts-json-node-ids/proposal.md | 38 +++ .../specs/project-prompts-json/spec.md | 277 ++++++++++++++++++ .../compact-prompts-json-node-ids/tasks.md | 20 ++ openspec/specs/project-prompts-json/spec.md | 152 ++++++++++ 11 files changed, 724 insertions(+) rename openspec/changes/{add-prompts-json-cli => archive/2026-06-04-add-prompts-json-cli}/.openspec.yaml (100%) rename openspec/changes/{add-prompts-json-cli => archive/2026-06-04-add-prompts-json-cli}/design.md (100%) rename openspec/changes/{add-prompts-json-cli => archive/2026-06-04-add-prompts-json-cli}/proposal.md (100%) rename openspec/changes/{add-prompts-json-cli => archive/2026-06-04-add-prompts-json-cli}/specs/project-prompts-json/spec.md (100%) rename openspec/changes/{add-prompts-json-cli => archive/2026-06-04-add-prompts-json-cli}/tasks.md (100%) create mode 100644 openspec/changes/compact-prompts-json-node-ids/.openspec.yaml create mode 100644 openspec/changes/compact-prompts-json-node-ids/design.md create mode 100644 openspec/changes/compact-prompts-json-node-ids/proposal.md create mode 100644 openspec/changes/compact-prompts-json-node-ids/specs/project-prompts-json/spec.md create mode 100644 openspec/changes/compact-prompts-json-node-ids/tasks.md create mode 100644 openspec/specs/project-prompts-json/spec.md diff --git a/openspec/changes/add-prompts-json-cli/.openspec.yaml b/openspec/changes/archive/2026-06-04-add-prompts-json-cli/.openspec.yaml similarity index 100% rename from openspec/changes/add-prompts-json-cli/.openspec.yaml rename to openspec/changes/archive/2026-06-04-add-prompts-json-cli/.openspec.yaml diff --git a/openspec/changes/add-prompts-json-cli/design.md b/openspec/changes/archive/2026-06-04-add-prompts-json-cli/design.md similarity index 100% rename from openspec/changes/add-prompts-json-cli/design.md rename to openspec/changes/archive/2026-06-04-add-prompts-json-cli/design.md diff --git a/openspec/changes/add-prompts-json-cli/proposal.md b/openspec/changes/archive/2026-06-04-add-prompts-json-cli/proposal.md similarity index 100% rename from openspec/changes/add-prompts-json-cli/proposal.md rename to openspec/changes/archive/2026-06-04-add-prompts-json-cli/proposal.md diff --git a/openspec/changes/add-prompts-json-cli/specs/project-prompts-json/spec.md b/openspec/changes/archive/2026-06-04-add-prompts-json-cli/specs/project-prompts-json/spec.md similarity index 100% rename from openspec/changes/add-prompts-json-cli/specs/project-prompts-json/spec.md rename to openspec/changes/archive/2026-06-04-add-prompts-json-cli/specs/project-prompts-json/spec.md diff --git a/openspec/changes/add-prompts-json-cli/tasks.md b/openspec/changes/archive/2026-06-04-add-prompts-json-cli/tasks.md similarity index 100% rename from openspec/changes/add-prompts-json-cli/tasks.md rename to openspec/changes/archive/2026-06-04-add-prompts-json-cli/tasks.md diff --git a/openspec/changes/compact-prompts-json-node-ids/.openspec.yaml b/openspec/changes/compact-prompts-json-node-ids/.openspec.yaml new file mode 100644 index 0000000..f617bd1 --- /dev/null +++ b/openspec/changes/compact-prompts-json-node-ids/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-04 diff --git a/openspec/changes/compact-prompts-json-node-ids/design.md b/openspec/changes/compact-prompts-json-node-ids/design.md new file mode 100644 index 0000000..ae8b0dd --- /dev/null +++ b/openspec/changes/compact-prompts-json-node-ids/design.md @@ -0,0 +1,235 @@ +## Context + +OpenPlate's current prompt JSON contract is still heavier than it needs to be because it uses raw runtime locator details as the import identity and interleaves answers with descriptive metadata. This change performs a full pre-release cutover to a cleaner contract centered on deterministic `node-id` values, a compact `answers` object, and an optional outer `info` object for verbose export. + +This is a cross-cutting change. It affects CLI command and flag parsing for init-only prompt JSON workflows, prompt document serialization and parsing, prompt export collection, prompt input tracking, input logging, docs, tests, and reusable prompt artifacts. Because the feature has not been released yet, the implementation should replace the in-progress contract outright rather than adding compatibility layers. + +## Goals / Non-Goals + +**Goals:** +- Replace init import matching with deterministic `node-id` matching. +- Add `openplate project print-init-json ` as the compact prompt export command. +- Make `openplate project print-init-json --verbose` emit the same nodes plus an outer `info` object with descriptive metadata. +- Keep one node shape for both compact and verbose init import so users can stream-edit exported JSON directly. +- Keep answer semantics simple: non-null is authoritative, `null` is unresolved, and an omitted answer key is also unresolved. +- Preserve current runtime rules for hidden parameters, existing/default fallback, full-tree print discovery, ignored unused nodes, and ignored unused answers. +- Make printed init JSON reusable across different `project init --dest-folder` values. +- Make prompt export fail fast when it cannot inspect a reached node well enough to produce complete prompt metadata. +- Allow short git-style `node-id` values while keeping round-trips correct when short-hash collisions occur. +- Remove the existing `project update` prompt JSON flags and update-command JSON execution path rather than preserving a second JSON surface. + +**Non-Goals:** +- Add a compatibility layer for older pre-release prompt JSON drafts. +- Add a top-level plan identifier or wrapper object. +- Expand prompt JSON automation beyond `project init`. +- Change sibling selection, condition evaluation, or duplicate discovered sibling behavior. +- Introduce a separate answers-only document format in this change. + +## Decisions + +### 1. `node-id` is derived from canonical raw node identity plus init-relative path + +Each template or CLI `dest_folder` input is normalized once at ingestion before the app uses it for template resolution, parameter resolution, prompt export, prompt identity hashing, prompt matching, or logging. + +Normalization rules: +- trim leading and trailing whitespace +- replace `\` with `/` + +For init prompt JSON behavior, OpenPlate distinguishes between: +- the normalized init output root from `project init --dest-folder`, which controls where files are written +- the init-relative destination folder for each reached node, which describes that node's location inside the initialized output tree before the init output root is prepended + +OpenPlate computes init-relative destination folders as follows: +- top-level templates use the template's normalized `default_dest_folder` +- if `default_dest_folder` is unspecified or normalizes empty, the init-relative top-level destination folder is `.` +- sibling declarations require a non-empty normalized `dest_folder`; a missing or empty sibling `dest_folder` is invalid +- a sibling declaration's normalized `dest_folder` is appended beneath the caller node's init-relative destination folder to produce the called node's init-relative destination folder +- when init execution writes files, the normalized init output root is prepended to the init-relative destination folder to produce the real output path +- when prompt export or init prompt-input matching needs node identity, the normalized init output root is excluded; implementations that begin from the real output path must strip the normalized init output root before hashing or lookup + +Each reached init prompt node computes a canonical identity string from: +- raw template reference +- init-relative normalized destination folder + +OpenPlate will hash that canonical identity with SHA-256 and treat the full SHA-256 hash as the node's stable internal identity. + +The serialized full `node-id` is the 64-character lowercase hexadecimal encoding of that SHA-256 hash. The preferred short `node-id` is the first 7 lowercase hexadecimal characters of the full hash. + +Exported `node-id` values use a registry local to the export invocation: +- compute the full hash for the reached init node +- derive a preferred short ID from the leading hash characters +- if the same canonical init node already has an exported ID, reuse it +- if the preferred short ID is unused, assign it to this node +- if the preferred short ID is already assigned to a different canonical node, export the full hash for the new node + +Export collection is keyed by canonical init identity. If the same canonical init node is encountered again during export, OpenPlate reuses and updates the existing emitted node instead of appending a second node to the JSON array. + +Import resolution uses two passes against the actually reached init runtime nodes: +- first, resolve any supplied `node-id` that exactly matches a full hash and mark that runtime node as claimed +- second, resolve the remaining supplied `node-id` values as short prefixes against the remaining unclaimed init runtime node hashes + +If a short `node-id` matches more than one remaining reached init node, import fails as ambiguous. + +This keeps the common case compact while still making collisions deterministic and reversible, while allowing the same printed init JSON to be reused for different init roots. + +Alternatives considered: +- Including the CLI init root in node identity. Rejected because it would force users to regenerate or rewrite prompt JSON whenever they change the init destination. +- Template-authored IDs. Rejected because they identify declarations rather than effective runtime nodes and do not solve converging-path identity. +- Random export-local IDs. Rejected because they are not stable enough for regeneration or debugging. +- Variable-length shortest-unique-prefix IDs for every node. Rejected because the chosen UX is first-come short IDs with full-hash fallback for later collisions. + +### 2. The prompt document uses one node shape with `answers` and optional `info` + +Both exported and imported prompt documents use a top-level JSON array of node objects. Any other top-level JSON shape is invalid. + +Each node uses this shape: + +```json +{ + "node-id": "123abcd", + "answers": { + "param1": null, + "param2": "value" + }, + "info": { + "template": "...", + "dest_folder": "...", + "parameters": { + "param1": { + "default": "...", + "existing": "...", + "description": "...", + "choices": ["..."], + "hidden": false, + "required": true + } + }, + "require_sibling_templates": [ + { + "template": "...", + "dest_folder": "...", + "condition": "..." + } + ] + } +} +``` + +Rules: +- `answers` is always the editable answer surface +- in exported documents, when parameter enumeration succeeds, `answers` includes one key for each discovered in-scope parameter and each key is initialized to `null` +- `answers` values are either strings or `null` +- any non-null answer value whose JSON type is not a string is invalid +- a missing key in `answers` means the same thing as `null`: unresolved, use runtime fallback if the parameter is reached +- `info` is optional and ignored on import +- when present, `info.template` carries the raw template reference string for that node rather than a rendered value +- when present, `info.dest_folder` carries the init-relative normalized destination folder used for that node's identity +- when present, `info.parameters` carries parameter metadata excluding answer values +- when present, `info.require_sibling_templates` carries caller-side sibling declaration metadata, including the raw declared template reference string, the called node's init-relative normalized destination folder, and any optional declaration condition + +Compact export from `openplate project print-init-json ` includes `node-id` and `answers` only. Verbose export from `openplate project print-init-json --verbose` includes the same node plus `info`. + +If print discovery succeeds for a reached node and finds no in-scope parameters, that node still exports normally with `answers: {}`. If print discovery cannot inspect a reached node's parameter metadata or caller-side sibling declaration metadata well enough to produce a trustworthy prompt surface, prompt export fails instead of emitting partial node data. + +Alternatives considered: +- Keeping per-parameter value wrappers. Rejected because they make the primary compact workflow more verbose without adding runtime value. +- Using a separate answers document. Rejected because it would create a second public contract when one node shape already supports both compact and verbose flows. + +### 3. Dedicated init print and init import share the same core contract + +`openplate project print-init-json ` is the read-only export command. By default it prints only: +- `node-id` +- `answers` + +`openplate project print-init-json --verbose` is the metadata-rich export mode. It prints: +- `node-id` +- `answers` +- `info` + +Both forms remain read-only and continue to walk the full declared sibling tree without applying sibling `condition` filters. + +`project init ` no longer exposes `--print-prompts-json`; print/export now exists only on `project print-init-json`, so use of the old flag fails by normal argument parsing because that option is no longer registered on `project init`. + +`project print-init-json ` does not accept `--dest-folder`; that option remains specific to `project init`, and passing it to the print command fails by normal argument parsing because it is not registered for that verb. + +`project init ` accepts a top-level JSON array of node objects from a file or stdin using this same node contract. `node-id` and `answers` are required. `info` is optional and ignored. + +Prompt JSON automation in this contract applies only to `project init`. + +As part of that narrowing, the existing `project update` prompt JSON flags are removed rather than merely undocumented. `project update` no longer accepts `--print-prompts-json`, `--prompts-json-file`, or `--prompts-json-stdin`, and its command path no longer loads prompt documents or prints them. + +Conditions belong to caller-side verbose metadata, not to called nodes. When verbose export includes sibling declaration metadata, it does so on the caller through `info.require_sibling_templates` rather than as `info.condition` on the called node. + +Because prompt export is intended to be a trustworthy planning artifact, any reached node that cannot be fully inspected causes export to fail rather than return a partial document. + +Imported `node-id` values must use lowercase hexadecimal. A 64-character lowercase hexadecimal `node-id` is a full hash. A 7-character lowercase hexadecimal `node-id` is a short prefix. All other `node-id` formats are invalid. + +Alternatives considered: +- Keeping print under `project init` behind a flag. Rejected because print/export is not init execution and deserves a dedicated command. +- Expanding prompt JSON automation beyond `project init` now. Rejected because this contract should stay focused on the init workflow it is actually standardizing. +- Adding separate import flags for compact and verbose documents. Rejected because both shapes are the same node contract with an optional metadata section. + +### 4. Answer semantics stay simple and consistent + +For a reached parameter in scope for the current init command: +- a non-null answer string is authoritative +- `""` is an explicit blank answer +- `null` means unresolved, use runtime fallback +- an omitted answer key also means unresolved, use runtime fallback + +Hidden parameters continue to participate only when `--ask-hidden` is active. + +Import validation changes: +- the imported document must be a top-level JSON array of node objects +- duplicate `node-id` entries in the input document are invalid +- nodes that omit `node-id` or `answers` are invalid +- `node-id` values that are not exactly 7 or 64 lowercase hexadecimal characters are invalid +- answer values whose JSON type is neither string nor `null` are invalid +- nodes not processed by the init run are ignored and logged by `node-id` +- answer keys that are not needed by a matched reached node are warned as unused + +This keeps the matching contract explicit: `node-id` is the identity surface, `answers` are the answer surface, and `info` is descriptive only. + +Alternatives considered: +- Treating omitted keys as invalid. Rejected because omission is the most natural compact spelling of "leave this unresolved". +- Using `info.template` and `info.dest_folder` as a fallback locator. Rejected because the cutover intentionally removes non-`node-id` matching. + +### 5. `info` keeps debug context without reintroducing matching complexity + +The `info` object preserves the helpful inspection context from the richer prompt view without making it part of the apply contract. + +At minimum, `info` should contain: +- `template` +- `dest_folder` +- `parameters` + +When available, `info` may also include caller-side sibling declaration metadata such as `require_sibling_templates`. + +For a deduplicated canonical init node, canonical fields such as `template`, init-relative normalized `dest_folder`, and `parameters` come from the canonical node record. Conditions remain attached to the caller-side sibling declarations that introduced those edges rather than to the called node. + +This keeps the stream-edit workflow intact: a user or model can export verbose, change only `answers`, and send the same structure back in. Import ignores `info`. + +Alternatives considered: +- Removing metadata entirely. Rejected because it makes debugging and AI-assisted editing harder. + +## Risks / Trade-offs + +- [Short-hash collisions could make hand-authored IDs ambiguous] -> Prefer exact full-hash matches first during import and fail when a short hash is ambiguous. +- [Prompt export now fails for nodes that were previously partially printable] -> Treat that as the correct fail-fast behavior, surface the inspection error clearly, and update existing tests that codify partial-success export. +- [Prompt JSON automation applies only to init] -> Keep the contract focused on the workflow it is actually standardizing instead of inventing a second JSON surface without a concrete need. +- [Removing existing update JSON flags narrows currently documented behavior] -> Treat the init-only contract as authoritative and delete the old update JSON parser surface and docs instead of preserving it implicitly. +- [No top-level plan ID means short IDs are scoped only by the reached runtime node set] -> Use deterministic full hashes underneath and conservative short-ID allocation during export. +- [Full-tree print discovery still exports nodes that runtime execution may not reach] -> Preserve ignored-node logging on import and keep this behavior explicit in docs. + +## Migration Plan + +1. Add `node-id` generation and export-ID registry helpers based on canonical raw template-reference plus init-relative normalized destination-folder identity. +2. Replace the prompt document model so nodes carry `node-id`, `answers`, and optional `info`. +3. Add `openplate project print-init-json ` with `--verbose`, remove the old flag-driven print surface, and delete the existing `project update` prompt JSON flags and dispatch path. +4. Update init import parsing to require `node-id` and `answers`, remove all pre-cutover matching paths, preserve single-fetch source reuse, and implement the two-pass full-hash-then-short-hash resolution. +5. Update init prompt JSON docs, tests, and reusable trial artifacts to the new format, including removal of old update JSON examples. +6. Ship the cutover as a single replacement of the unreleased init prompt JSON contract. + +## Open Questions + +None at this time. \ No newline at end of file diff --git a/openspec/changes/compact-prompts-json-node-ids/proposal.md b/openspec/changes/compact-prompts-json-node-ids/proposal.md new file mode 100644 index 0000000..14972c8 --- /dev/null +++ b/openspec/changes/compact-prompts-json-node-ids/proposal.md @@ -0,0 +1,38 @@ +## Why + +The current init prompt JSON flow is now correct and predictable, but it is still more cumbersome than it needs to be because import identity depends on raw `template` plus `dest_folder` and the exported document mixes editable answers with descriptive metadata. This change makes the machine-facing init flow compact, stable, and reusable across different init destinations by switching to deterministic `node-id` matching and by separating answers from optional parameter metadata. + +## What Changes + +- Replace init prompt JSON node matching with deterministic `node-id` values derived from the canonical raw template-reference and the init-relative normalized destination-folder identity inside the initialized tree, excluding any invocation root passed to `project init --dest-folder`. +- Normalize template `dest_folder` values at ingestion by trimming whitespace and converting path separators to `/`, compute each reached node's init-relative destination folder inside the initialized tree, and append the real `project init --dest-folder` root only when materializing output paths; sibling declarations still require a non-empty `dest_folder` after normalization. +- Emit at most one prompt node for each canonical template-reference plus init-relative normalized destination-folder identity reached during init export. +- Perform a full prompt JSON contract cutover before release so `node-id` is the only supported import identity. +- Serialize full `node-id` values as 64-character lowercase hexadecimal SHA-256 strings and prefer the first 7 lowercase hexadecimal characters as the compact exported form. +- Export short `node-id` values when they are collision-free within the reached prompt tree, and fall back to the full hash when the short form is already taken by another node. +- Change the prompt JSON shape so node answers live under an `answers` object keyed by parameter name, with exported discovered answer keys initialized to `null`, with values restricted to strings or `null`, and with `null` or an omitted key both meaning unresolved so runtime fallback still applies. +- Add an optional outer `info` object for verbose export. `info` carries descriptive node metadata such as `template`, `dest_folder`, per-parameter metadata excluding values, and caller-side sibling declaration metadata. +- Add `openplate project print-init-json ` as the read-only prompt export command, using compact output by default and `--verbose` for the metadata-rich export. +- Accept both compact and verbose documents on import when supplied to `project init` as a top-level JSON array of node objects with valid `node-id` and `answers` fields. +- Emit sibling-declaration conditions on the caller's verbose metadata rather than on the called node, so converging called nodes do not need merged condition metadata. +- Fail prompt export when a reached node cannot be fully inspected for prompt metadata, instead of emitting partial node data. +- Scope prompt JSON export and import to `project init` only. +- Remove the existing update JSON CLI surface instead of carrying it forward: `openplate project update --print-prompts-json`, `--prompts-json-file`, and `--prompts-json-stdin` are deleted by this change. +- Document the compact `node-id` plus `answers` flow as the primary init workflow and the verbose `info` shape as an optional stream-editing path. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `project-prompts-json`: Change init prompt JSON node identity to `node-id`, replace per-parameter `value` wrappers with `answers`, add optional outer `info` metadata, use a dedicated init print command, and scope prompt JSON print/import to init only. + +## Impact + +- Affected code includes template ingestion and init-relative `dest_folder` resolution, prompt document serialization/deserialization, prompt input tracking, prompt export collection, CLI command parsing for `project print-init-json`, removal of update-command prompt JSON flags and dispatch paths, init prompt resolution, and prompt input logging. +- User-facing command documentation for prompt JSON export/import will describe only the new init workflow: `openplate project print-init-json ` for export and `project init` JSON input for execution, with `--verbose` documented as the optional metadata-rich variant. +- Existing prompt JSON tests will need to be updated for the new node shape and new import/export matching rules. +- Existing tests that codify partial prompt-export success on discovery failures will need to be replaced with fail-fast export behavior. +- Prompt JSON automation is defined only for `project init`. +- Existing docs and examples for `project init --print-prompts-json`, `project update --print-prompts-json`, `project update --prompts-json-file`, and `project update --prompts-json-stdin` will need to be removed. +- Trial-run prompt artifacts and any hand-authored prompt JSON documents will need regeneration to the new `node-id`-based format. \ No newline at end of file diff --git a/openspec/changes/compact-prompts-json-node-ids/specs/project-prompts-json/spec.md b/openspec/changes/compact-prompts-json-node-ids/specs/project-prompts-json/spec.md new file mode 100644 index 0000000..9b3a5b8 --- /dev/null +++ b/openspec/changes/compact-prompts-json-node-ids/specs/project-prompts-json/spec.md @@ -0,0 +1,277 @@ +## ADDED Requirements + +### Requirement: OpenPlate normalizes destination-folder values consistently for init prompt JSON +OpenPlate SHALL normalize each template or CLI `dest_folder` input before using it for init template resolution, parameter resolution, prompt node identity, prompt export metadata, or prompt-related matching and logging. + +Normalization SHALL: +- trim leading and trailing whitespace +- replace `\` with `/` + +For init prompt JSON behavior, OpenPlate SHALL distinguish between the normalized init output root supplied to `project init --dest-folder` and each node's init-relative normalized `dest_folder` inside the initialized tree. + +After normalization, OpenPlate SHALL resolve init-relative destination folders for prompt-related behavior as follows: +- a top-level template SHALL use the template's normalized `default_dest_folder` +- if `default_dest_folder` is unspecified or normalizes empty, the init-relative top-level destination folder SHALL be `.` +- a sibling declaration SHALL provide a non-empty normalized `dest_folder`; a missing or empty sibling `dest_folder` SHALL be rejected +- a sibling declaration's normalized `dest_folder` SHALL be appended beneath the caller node's init-relative destination folder to produce the called node's init-relative destination folder + +OpenPlate SHALL prepend the normalized init output root only when materializing real output paths during `project init`. The init output root SHALL NOT participate in printed `node-id` values, printed `info.dest_folder`, or init prompt-document matching. + +During init prompt-document matching, OpenPlate SHALL resolve node identity using the same init-relative normalized `dest_folder` used for export. If the implementation begins from a real output path, it SHALL strip the normalized init output root before hashing or matching. + +#### Scenario: Normalize a destination folder before init prompt processing +- **WHEN** a template declares `dest_folder` with surrounding whitespace or `\` separators +- **THEN** OpenPlate normalizes that value before template resolution and parameter resolution continue +- **AND** the normalized value uses `/` separators with surrounding whitespace removed + +#### Scenario: Missing top-level destination folder falls back to init root +- **WHEN** a top-level template has no non-empty `default_dest_folder` +- **THEN** OpenPlate uses `.` as the init-relative destination folder for prompt identity + +#### Scenario: Sibling destination folder is required +- **WHEN** a sibling declaration omits `dest_folder` or its normalized value is empty +- **THEN** OpenPlate rejects that sibling declaration + +#### Scenario: Sibling destination folder composes beneath the caller +- **WHEN** a reached caller node has init-relative destination folder `services/api` +- **AND** that caller declares a sibling with normalized `dest_folder` `workers/job` +- **THEN** the called node's init-relative destination folder is `services/api/workers/job` + +#### Scenario: Printed init JSON is reusable across init roots +- **WHEN** a user prints init prompt JSON and later runs `project init` with a different normalized `--dest-folder` +- **THEN** the printed `node-id` values still match the reached init nodes +- **AND** OpenPlate derives that match from init-relative destination folders rather than the invocation root + +### Requirement: OpenPlate removes legacy prompt JSON flags from non-print verbs +OpenPlate SHALL NOT expose `--print-prompts-json`, `--prompts-json-file`, or `--prompts-json-stdin` on `project update`. + +OpenPlate SHALL remove any `project update` execution path that prints prompt JSON or consumes prompt JSON input. + +OpenPlate SHALL NOT expose `--print-prompts-json` on `project init`; init prompt export SHALL be available only through `project print-init-json`. + +#### Scenario: Update no longer accepts print-prompts-json +- **WHEN** a user runs `openplate project update --print-prompts-json` +- **THEN** OpenPlate rejects the command because `--print-prompts-json` is not a valid `project update` argument + +#### Scenario: Update no longer accepts prompts-json-file +- **WHEN** a user runs `openplate project update --prompts-json-file prompts.json` +- **THEN** OpenPlate rejects the command because `--prompts-json-file` is not a valid `project update` argument + +#### Scenario: Update no longer accepts prompts-json-stdin +- **WHEN** a user runs `openplate project update --prompts-json-stdin` +- **THEN** OpenPlate rejects the command because `--prompts-json-stdin` is not a valid `project update` argument + +#### Scenario: Init no longer accepts print-prompts-json +- **WHEN** a user runs `openplate project init --print-prompts-json` +- **THEN** OpenPlate rejects the command because `--print-prompts-json` is not a valid `project init` argument + +## MODIFIED Requirements + +### Requirement: OpenPlate prints prompt state as template-grouped JSON +This requirement title is retained to modify the existing capability in place, but the printed document defined by this change is node-based: each exported entry is a prompt node identified by `node-id`, not a template-keyed wrapper structure. + +OpenPlate SHALL support `openplate project print-init-json ` as a read-only planning/export command that prints a JSON array of init prompt nodes instead of interactive prompt text. + +`openplate project print-init-json --verbose` SHALL print the same JSON node set with additional descriptive metadata. + +The print-init command SHALL be read-only. It MUST NOT write files, update project configuration, or otherwise mutate workspace state. + +The print-init command SHALL walk the full declared template tree without applying sibling `condition` filters. The printed JSON SHALL include the full declared template tree that can be discovered from the template configuration, including conditional sibling declarations. This export MAY include nodes that later execution does not process. + +Each printed node SHALL include `node-id` and `answers`. + +When print discovery can enumerate a node's in-scope parameters, exported `answers` SHALL include one key for each discovered in-scope parameter and each such key SHALL be initialized to `null`. + +OpenPlate SHALL emit at most one printed node for each canonical raw template-reference plus init-relative normalized destination-folder identity reached by the export walk. + +Each emitted `node-id` SHALL use lowercase hexadecimal. A full-hash `node-id` SHALL be the 64-character lowercase hexadecimal SHA-256 encoding of the canonical node identity. The preferred short `node-id` SHALL be the first 7 lowercase hexadecimal characters of that full hash. + +`openplate project print-init-json ` SHALL emit the compact form and SHALL omit `info`. + +`openplate project print-init-json --verbose` SHALL emit the verbose form and SHALL include `info`. When present, `info` SHALL carry descriptive node metadata such as `template`, init-relative `dest_folder`, and `parameters` metadata excluding answer values. + +When present, `info.template` SHALL use the raw template reference string for that node rather than a rendered value. + +When a node declares sibling templates, verbose `info` MAY also include caller-side sibling declaration metadata such as `require_sibling_templates`. If present, those entries SHALL describe the caller's outgoing declarations, including the raw declared target template reference string, the called node's init-relative normalized destination folder, and any declaration condition. + +Called nodes SHALL NOT carry caller-side declaration conditions as `info.condition`. + +If print export cannot fully inspect a reached node's parameters or caller-side sibling declaration metadata, OpenPlate MUST fail the export instead of emitting partial node data. + +Hidden parameters SHALL be included in exported `answers` and exported `info.parameters` only when `--ask-hidden` is active for that command. + +#### Scenario: Print compact init prompts JSON +- **WHEN** a user runs `openplate project print-init-json ` +- **THEN** OpenPlate prints a JSON array of prompt nodes +- **AND** each node includes `node-id` and `answers` +- **AND** each node omits `info` + +#### Scenario: Print verbose init prompts JSON +- **WHEN** a user runs `openplate project print-init-json --verbose` +- **THEN** OpenPlate prints a JSON array of prompt nodes +- **AND** each node includes `node-id`, `answers`, and `info` + +#### Scenario: Print init prompts JSON without mutating the workspace +- **WHEN** a user runs either form of `project print-init-json` +- **THEN** OpenPlate does not write project files or template output files +- **AND** OpenPlate does not update `.openplate.project.yaml` as part of printing the JSON document + +#### Scenario: Print-init-json rejects dest-folder +- **WHEN** a user runs `openplate project print-init-json --dest-folder other` +- **THEN** OpenPlate rejects the command because `--dest-folder` is not a valid `project print-init-json` argument + +#### Scenario: Include conditional sibling declarations in init print export +- **WHEN** a template declares a sibling template behind a condition +- **THEN** either print form includes that sibling node in the printed JSON tree +- **AND** print export does not use that condition to prune the export tree + +#### Scenario: Reached canonical init node is emitted once +- **WHEN** the export walk encounters the same canonical template-reference plus init-relative normalized destination-folder identity more than once +- **THEN** OpenPlate emits one node for that canonical identity +- **AND** OpenPlate reuses the same emitted `node-id` for that node + +#### Scenario: Enumerated parameters are seeded in answers +- **WHEN** print discovery can enumerate the in-scope parameters for a reached node +- **THEN** exported `answers` includes one key for each discovered in-scope parameter +- **AND** each exported answer value is `null` + +#### Scenario: Parameterless reached node emits empty answers +- **WHEN** print discovery succeeds for a reached node and finds no in-scope parameters +- **THEN** exported `answers` is `{}` + +#### Scenario: Verbose conditions are emitted on the caller +- **WHEN** a node declares a conditional sibling template +- **THEN** verbose export may include that declaration in the caller node's `info.require_sibling_templates` +- **AND** the called node does not emit `info.condition` + +#### Scenario: Hidden parameters are omitted from export without ask-hidden +- **WHEN** a user runs a print form without `--ask-hidden` +- **THEN** OpenPlate omits hidden parameters from exported `answers` +- **AND** OpenPlate omits hidden parameter metadata from verbose `info.parameters` + +#### Scenario: Parameter discovery failure fails export +- **WHEN** print export reaches a node but cannot enumerate that node's prompt parameters +- **THEN** OpenPlate fails the export command +- **AND** OpenPlate does not emit a partial prompt JSON document + +#### Scenario: Caller-side sibling discovery failure fails export +- **WHEN** print export reaches a node but cannot inspect its caller-side sibling declaration metadata well enough to build verbose output +- **THEN** OpenPlate fails the export command +- **AND** OpenPlate does not emit a partial prompt JSON document + +### Requirement: OpenPlate accepts prompt answers from JSON without prompting +OpenPlate SHALL support `--prompts-json-file ` and `--prompts-json-stdin` for `project init`. When either flag is used, OpenPlate MUST consume prompt answers from the provided JSON document and MUST NOT fall back to interactive prompting. + +The supplied prompt document MUST be a top-level JSON array of node objects. Any other top-level JSON shape MUST be rejected. + +`--prompts-json-file` and `--prompts-json-stdin` SHALL use the normal init execution walk. They MUST NOT switch to the full-tree, condition-ignoring discovery behavior reserved for `project print-init-json`. + +Imported nodes MUST provide `node-id` and `answers`. Imported `info` MAY be present and MUST be ignored for import matching and resolution. + +Documents that do not use this node shape MUST be rejected. + +Imported `node-id` values MUST use lowercase hexadecimal. A 64-character lowercase hexadecimal `node-id` MUST be treated as a full hash. A 7-character lowercase hexadecimal `node-id` MUST be treated as a short prefix. All other `node-id` formats MUST be rejected. + +Within `answers`, each value MUST be either a string or `null`. Any other JSON value type MUST be rejected. + +Within `answers`, a non-null answer value MUST be treated as the authoritative supplied answer for a reached in-scope parameter. `null` and an omitted answer key MUST both mean unresolved so normal runtime fallback applies if that parameter is reached. + +Hidden parameters MUST be in scope only when `--ask-hidden` is active for that command. Hidden answers supplied without `--ask-hidden` MUST be ignored as unused supplied answers. + +If required values remain unresolved after applying supplied JSON, OpenPlate MUST fail instead of prompting. OpenPlate MAY fail on the first unresolved prompt rather than aggregating all missing values. + +If template-command confirmation would have prompted during the run and the command has not otherwise been authorized, OpenPlate MUST fail instead of prompting. + +Within a single command invocation, OpenPlate MUST NOT fetch or clone the same template source once for prompt validation and then again for actual processing. JSON-input execution MUST reuse fetched template sources for validation and execution. + +#### Scenario: Supply answers from a compact file +- **WHEN** a user runs `openplate project init --prompts-json-file prompts.json` +- **AND** `prompts.json` contains nodes with `node-id` and `answers` +- **THEN** OpenPlate reads prompt answers from `prompts.json` +- **AND** OpenPlate does not prompt for missing values during that run + +#### Scenario: Non-array prompt document is rejected +- **WHEN** a supplied prompt document is a JSON object, string, number, or other non-array top-level value +- **THEN** OpenPlate rejects the document before template processing begins + +#### Scenario: Supply answers from a verbose file +- **WHEN** a user runs `openplate project init --prompts-json-file prompts.json` +- **AND** `prompts.json` contains nodes with `node-id`, `answers`, and `info` +- **THEN** OpenPlate accepts the document as prompt input +- **AND** OpenPlate ignores `info` for matching and answer resolution + +#### Scenario: Validation and execution reuse the same source fetch +- **WHEN** a user runs `openplate project init --prompts-json-file prompts.json` +- **THEN** OpenPlate validates prompt input and processes that command without fetching or cloning the same template source twice + +#### Scenario: Invalid answer value type is rejected +- **WHEN** a supplied JSON document sets an `answers` value to a non-string, non-null JSON value +- **THEN** OpenPlate rejects the document before template processing begins + +#### Scenario: Null answer uses runtime fallback +- **WHEN** an imported node sets `answers.param1` to `null` +- **THEN** OpenPlate does not answer that parameter from JSON +- **AND** OpenPlate uses the normal runtime fallback for that parameter if it is reached + +#### Scenario: Missing answer key uses runtime fallback +- **WHEN** an imported node omits `param1` from `answers` +- **THEN** OpenPlate does not treat that parameter as answered by the prompt document +- **AND** OpenPlate uses the normal runtime fallback for that parameter if it is reached + +#### Scenario: Non-null answer overrides existing fallback +- **WHEN** an imported node sets `answers.param1` to a non-null string for a reached in-scope parameter that already has an existing runtime value +- **THEN** OpenPlate uses the supplied JSON answer for that parameter + +#### Scenario: Missing required value in JSON mode +- **WHEN** JSON-input mode is active and a required parameter still has no resolved value after considering supplied answers and runtime fallback +- **THEN** OpenPlate fails +- **AND** OpenPlate does not ask the user for that value interactively + +### Requirement: OpenPlate validates supplied prompt documents by template instance +OpenPlate SHALL match supplied prompt nodes using `node-id` only. OpenPlate MUST NOT use `template`, `dest_folder`, or other metadata fields for import matching. + +OpenPlate SHALL derive a full node hash from the canonical raw template-reference plus init-relative normalized destination-folder identity used by init prompt export. + +During export, OpenPlate SHALL emit at most one node for each canonical init node identity, SHALL assign a short `node-id` when the preferred short hash is not already taken, SHALL reuse the same emitted node and exported `node-id` when the same canonical init node is reached again, and SHALL export the full node hash when the preferred short hash is already taken by a different canonical node. + +During import, OpenPlate MUST resolve node identifiers in two passes: +- first, exact full-hash matches +- then, unique short-hash matches against remaining unclaimed reached nodes + +During import, OpenPlate MUST reject a supplied JSON document that contains duplicate `node-id` entries. + +If an imported node does not correspond to a node that is actually processed by the init run, OpenPlate MUST ignore that supplied node and MUST print a log message identifying the unused `node-id`. If supplied answers are present for a matched node but are not needed during processing, OpenPlate MUST print warnings for those unused supplied answers even when debug logging is disabled. + +#### Scenario: Round-trip compact prompt document +- **WHEN** a user edits only `answers` values in a document previously printed by `project print-init-json` +- **THEN** OpenPlate accepts the document as prompt input +- **AND** OpenPlate matches nodes by `node-id` + +#### Scenario: Round-trip verbose prompt document +- **WHEN** a user edits only `answers` values in a document previously printed by `project print-init-json --verbose` +- **THEN** OpenPlate accepts the document as prompt input +- **AND** OpenPlate ignores unchanged `info` metadata during import + +#### Scenario: Full hash takes precedence over colliding short hash +- **WHEN** one imported node uses a full SHA-256 `node-id` +- **AND** another imported node uses a short `node-id` prefix that would otherwise collide with that full-hash node +- **THEN** OpenPlate resolves the full SHA-256 `node-id` first +- **AND** OpenPlate resolves the short `node-id` only against the remaining unclaimed reached nodes + +#### Scenario: Duplicate node identifiers are invalid +- **WHEN** a supplied JSON document contains two nodes with the same `node-id` +- **THEN** OpenPlate fails validation before template processing begins + +#### Scenario: Invalid node-id format is rejected +- **WHEN** a supplied JSON document includes a `node-id` that is not exactly 7 or 64 lowercase hexadecimal characters +- **THEN** OpenPlate fails validation before template processing begins + +#### Scenario: Unused imported node is ignored +- **WHEN** a supplied JSON document includes a node whose `node-id` is never processed by the run +- **THEN** OpenPlate ignores that node +- **AND** OpenPlate prints a log message identifying the unused `node-id` + +#### Scenario: Unused supplied answer is warned +- **WHEN** a supplied JSON document includes an answer for a matched reached node and that answer is not needed during processing +- **THEN** OpenPlate completes normal processing behavior for the node +- **AND** OpenPlate prints a warning about the unused supplied answer \ No newline at end of file diff --git a/openspec/changes/compact-prompts-json-node-ids/tasks.md b/openspec/changes/compact-prompts-json-node-ids/tasks.md new file mode 100644 index 0000000..61c7ca4 --- /dev/null +++ b/openspec/changes/compact-prompts-json-node-ids/tasks.md @@ -0,0 +1,20 @@ +## 1. Prompt Document Model And Identity + +- [ ] 1.1 Replace the prompt document node model so exported and imported nodes use `node-id`, `answers`, and optional `info`, remove all pre-cutover prompt JSON node shapes, and emit at most one node per canonical identity during export. +- [ ] 1.2 Normalize `dest_folder` at template ingestion by trimming whitespace and converting `\` to `/`, then compute init-relative destination folders consistently: top-level templates use `default_dest_folder`, which falls back to `.`, sibling declarations require a non-empty `dest_folder`, and the `project init --dest-folder` root is appended only for real output paths and excluded from prompt JSON identity. +- [ ] 1.3 Add deterministic full-hash node identity generation from canonical raw template-reference plus init-relative normalized destination-folder identity, serialize full hashes as 64-character lowercase hexadecimal strings, prefer the first 7 lowercase hexadecimal characters for short IDs, and implement the export-time short-ID registry with full-hash fallback on collisions. +- [ ] 1.4 Update prompt input tracking and logging to key nodes by `node-id`, reject duplicate `node-id` entries, and ignore `info` during import matching. + +## 2. Export And Import Behavior + +- [ ] 2.1 Add `openplate project print-init-json ` as the compact prompt export command and support `--verbose` to emit the same nodes plus `info` metadata. +- [ ] 2.2 Update prompt export collection so `answers` contains discovered in-scope parameter keys initialized to `null`, parameterless reached nodes emit `answers: {}`, hidden parameters still follow `--ask-hidden`, verbose caller metadata emits sibling declaration conditions through caller-side metadata instead of called-node `condition` fields, and export fails instead of emitting partial nodes when prompt discovery cannot complete. +- [ ] 2.3 Update init JSON import parsing and parameter resolution so the top-level document must be a JSON array of node objects, `answers` is required, `null` and omitted answer keys both defer to runtime fallback, non-null answers remain authoritative, non-string non-null answer values are rejected, non-`node-id` prompt JSON shapes are rejected, and validation/execution reuse the same fetched template source within one command invocation. +- [ ] 2.4 Implement two-pass import resolution so exact full-hash `node-id` matches claim nodes first, short `node-id` matches resolve only against remaining unclaimed runtime nodes, and invalid `node-id` formats are rejected. +- [ ] 2.5 Remove `--print-prompts-json`, `--prompts-json-file`, and `--prompts-json-stdin` from `project update`, remove `--print-prompts-json` from `project init`, and ensure legacy flag uses now fail by normal argument parsing because those options are no longer registered on those verbs. + +## 3. Validation, Docs, And Artifacts + +- [ ] 3.1 Replace and extend init prompt JSON tests to cover compact export, verbose export, canonical-node export deduplication, seeded `null` answers, parameterless reached nodes with empty answers, fail-fast export on prompt or sibling discovery errors, caller-side sibling condition metadata, absence of called-node `condition` metadata, `node-id`-only matching, short-hash collision handling, `node-id` format validation, invalid top-level document shape, hidden-parameter scope, invalid answer value types, init-relative dest-folder resolution, sibling dest-folder composition, invalid legacy flag rejection by parser absence, single-fetch source reuse, and null or omitted answer fallback behavior. +- [ ] 3.2 Update command and template documentation to describe only the new init workflow: `openplate project print-init-json ` for export and `project init` JSON input for execution, with `--verbose` as the optional metadata-rich path, explain the init-relative `dest_folder` behavior, explain caller-side sibling condition metadata, remove references to pre-cutover prompt JSON drafts, and delete old `project init --print-prompts-json` and `project update` JSON examples. +- [ ] 3.3 Regenerate reusable prompt JSON examples and trial artifacts so shipped or checked-in documents use the new `node-id`-based format. \ No newline at end of file diff --git a/openspec/specs/project-prompts-json/spec.md b/openspec/specs/project-prompts-json/spec.md new file mode 100644 index 0000000..12fa87f --- /dev/null +++ b/openspec/specs/project-prompts-json/spec.md @@ -0,0 +1,152 @@ +## ADDED Requirements + +### Requirement: OpenPlate prints prompt state as template-grouped JSON +OpenPlate SHALL support `--print-prompts-json` for `project init` and `project update`. The command SHALL print a JSON array of template nodes instead of interactive prompt text for the declared prompt document. + +`--print-prompts-json` SHALL be a read-only planning/export mode. It MUST NOT write files, update project configuration, or otherwise mutate workspace state. + +`--print-prompts-json` SHALL be the only mode that walks the full declared template tree without applying sibling `condition` filters. The printed JSON SHALL include the full declared template tree that can be discovered from the template configuration, including conditional sibling declarations. This export MAY include template nodes that later execution does not process. + +Each template node SHALL include `template`, `dest_folder`, and `parameters`. If the template declaration includes a condition, the node SHALL also include `condition`. Template and destination values in this JSON SHALL use the raw templated strings rather than rendered values. + +When OpenPlate can inspect a declared template node closely enough to enumerate parameter metadata during `--print-prompts-json`, `parameters` SHALL be an object keyed by parameter name. Each parameter entry SHALL include `value`, `default`, `existing`, `description`, `choices`, `hidden`, and `required` so the printed document can be edited and sent back through the import flow. Hidden parameters SHALL be included only when `--ask-hidden` is active for that command. + +If print-mode discovery can enumerate a template declaration but cannot load that template's config closely enough to discover parameter metadata during export, OpenPlate SHALL still include the template node and SHALL emit `parameters` as `null`. + +In printed and imported parameter entries, `value: null` SHALL mean no value has been supplied, `value: ""` SHALL mean an explicit blank string, and omission of the `value` field SHALL be invalid on import. + +For parameters that are included in the prompt document for a command, all metadata fields other than `value` SHALL be informational only. + +#### Scenario: Print prompts JSON for init +- **WHEN** a user runs `openplate project init --print-prompts-json` +- **THEN** OpenPlate prints a JSON array of template nodes for the declared init template tree +- **AND** each template node groups its parameters beneath the template and destination fields + +#### Scenario: Print prompts JSON without mutating the workspace +- **WHEN** a user runs `openplate project update --print-prompts-json` +- **THEN** OpenPlate does not write project files or template output files +- **AND** OpenPlate does not update `.openplate.project.yaml` as part of printing the JSON document + +#### Scenario: Include conditional sibling declarations in export +- **WHEN** a template declares a sibling template behind a condition +- **THEN** OpenPlate includes that sibling template node in the printed JSON tree +- **AND** `--print-prompts-json` does not use that condition to prune the export tree + +#### Scenario: Export unresolved declaration with null parameters +- **WHEN** print-mode discovery can identify a template declaration but cannot load that template's config to enumerate its parameter metadata +- **THEN** OpenPlate still includes the template node in the printed JSON +- **AND** OpenPlate emits `parameters` as `null` for that node + +#### Scenario: Print condition metadata without requiring round-trip use +- **WHEN** a template declaration includes a `condition` +- **THEN** OpenPlate includes that raw `condition` string in the printed JSON node +- **AND** the printed `condition` field is treated as informational metadata rather than required input + +#### Scenario: Hidden parameters are omitted from export without ask-hidden +- **WHEN** a user runs `openplate project init --print-prompts-json` without `--ask-hidden` +- **THEN** OpenPlate omits hidden parameters from exported `parameters` objects + +#### Scenario: Hidden parameters are included in export with ask-hidden +- **WHEN** a user runs `openplate project init --ask-hidden --print-prompts-json` +- **THEN** OpenPlate includes hidden parameters in exported `parameters` objects + +### Requirement: OpenPlate accepts prompt answers from JSON without prompting +OpenPlate SHALL support `--prompts-json-file ` and `--prompts-json-stdin` for `project init` and `project update`. When either flag is used, OpenPlate MUST consume prompt answers from the provided JSON document and MUST NOT fall back to interactive prompting. + +`--prompts-json-file` and `--prompts-json-stdin` SHALL use the normal runtime execution walk. They MUST NOT switch to the full-tree, condition-ignoring discovery behavior that is reserved for `--print-prompts-json`. + +If required values remain unresolved after applying supplied JSON, OpenPlate MUST fail instead of prompting. OpenPlate MAY fail on the first unresolved prompt rather than aggregating all missing values. + +If template-command confirmation would have prompted during the run and the command has not otherwise been authorized, OpenPlate MUST fail instead of prompting. + +Within a single command invocation, OpenPlate MUST NOT fetch or clone the same template source once for prompt validation and then again for actual processing. JSON-input execution MUST reuse fetched template sources for validation and execution. + +#### Scenario: Supply answers from a file +- **WHEN** a user runs `openplate project update --prompts-json-file prompts.json` +- **THEN** OpenPlate reads prompt answers from `prompts.json` +- **AND** OpenPlate does not prompt for missing values during that run + +#### Scenario: Supply answers from stdin +- **WHEN** a user pipes a prompts JSON document to `openplate project init --prompts-json-stdin` +- **THEN** OpenPlate reads prompt answers from standard input +- **AND** OpenPlate does not reuse standard input as an interactive prompt channel + +#### Scenario: Missing required value in JSON mode +- **WHEN** JSON-input mode is active and a required parameter still has no resolved value +- **THEN** OpenPlate fails +- **AND** OpenPlate does not ask the user for that value interactively + +#### Scenario: JSON input uses the normal runtime walk +- **WHEN** a user runs `openplate project update --prompts-json-file prompts.json` +- **THEN** OpenPlate evaluates sibling conditions as part of the normal runtime execution walk +- **AND** OpenPlate does not switch to the print-only full-tree discovery behavior + +#### Scenario: Validation and execution reuse the same source fetch +- **WHEN** a user runs `openplate project init --prompts-json-file prompts.json` +- **THEN** OpenPlate validates prompt input and processes that command without fetching or cloning the same template source twice + +### Requirement: OpenPlate validates supplied prompt documents by template instance +OpenPlate SHALL match supplied prompt answers to template instances using the pair `(template, dest_folder)`. The imported JSON document SHALL use the same hierarchical structure that `--print-prompts-json` emits. + +During import, OpenPlate MUST use each parameter entry's `value` field as the supplied answer. Other metadata fields in template nodes and parameter entries MAY be present and MUST be ignored for import semantics. + +During import, `value: null` MUST mean that OpenPlate does not answer that parameter from JSON and instead uses the normal runtime fallback for that parameter if it is reached. + +During import, any non-null `value`, including `""`, MUST be treated as the authoritative supplied answer for that parameter when the parameter is in scope for the command, even when runtime fallback already has an existing or default value. + +During import, hidden parameters MUST be in scope only when `--ask-hidden` is active for that command. Hidden values supplied without `--ask-hidden` MUST be ignored as unused supplied parameters. + +During import, a template node with `parameters: null` MUST be treated as having no supplied parameter values. + +During import, OpenPlate MUST reject a parameter entry that omits the `value` field. + +OpenPlate MUST reject a supplied JSON document that contains duplicate template nodes with the same `(template, dest_folder)` pair. + +If `--print-prompts-json` discovers the same raw `(template, dest_folder)` pair more than once, OpenPlate SHALL emit only the first such node in the exported JSON document. + +If a supplied template node does not correspond to a template instance that is actually processed, OpenPlate MUST ignore that supplied node and MUST print a log message that identifies the raw `template` and `dest_folder` that were ignored. If supplied parameter values are present for a matched template instance but are not needed during processing, OpenPlate MUST print warnings for those unused parameters even when debug logging is disabled. + +#### Scenario: Round-trip edited prompt document +- **WHEN** a user edits only `value` fields in a document previously printed by `--print-prompts-json` +- **THEN** OpenPlate accepts the document as prompt input +- **AND** OpenPlate ignores unchanged metadata fields such as `condition`, `description`, `default`, and `existing` + +#### Scenario: Null value uses runtime fallback +- **WHEN** a supplied JSON parameter entry sets `value` to `null` +- **THEN** OpenPlate does not answer that parameter from JSON +- **AND** OpenPlate uses the normal runtime fallback for that parameter if it is reached + +#### Scenario: Omitted value field is invalid +- **WHEN** a supplied JSON parameter entry omits the `value` field +- **THEN** OpenPlate fails validation for the imported prompt document + +#### Scenario: Blank string is treated as an explicit value +- **WHEN** a supplied JSON parameter entry sets `value` to `""` +- **THEN** OpenPlate treats that parameter as explicitly supplied with a blank string value + +#### Scenario: Non-null value overrides existing fallback +- **WHEN** a supplied JSON parameter entry sets `value` to a non-null string for a parameter that already has an existing runtime value +- **THEN** OpenPlate uses the supplied JSON value for that parameter + +#### Scenario: Hidden value is ignored without ask-hidden +- **WHEN** a supplied JSON document sets `value` for a hidden parameter and the command does not use `--ask-hidden` +- **THEN** OpenPlate ignores that supplied hidden value +- **AND** OpenPlate warns that the supplied parameter was unused + +#### Scenario: Duplicate template entries in supplied JSON +- **WHEN** a supplied JSON document contains two template nodes with the same `template` and `dest_folder` +- **THEN** OpenPlate fails validation before template processing begins + +#### Scenario: Export collapses duplicate discovered template nodes +- **WHEN** `--print-prompts-json` discovers the same raw `template` and `dest_folder` pair more than once +- **THEN** OpenPlate emits only the first discovered node for that pair in the exported JSON document + +#### Scenario: Supplied template is not processed +- **WHEN** a supplied JSON document includes a template node that is never processed by the run +- **THEN** OpenPlate ignores that supplied template node +- **AND** OpenPlate prints a log message identifying the raw `template` and `dest_folder` that were ignored + +#### Scenario: Supplied parameter is not needed +- **WHEN** a supplied JSON document includes a parameter value for a matched template instance and that parameter is not needed during processing +- **THEN** OpenPlate completes normal processing behavior for the template instance +- **AND** OpenPlate prints a warning about the unused supplied parameter \ No newline at end of file From 4ffc26e0d3cc250a3021599ea1ce3db3177f9cac Mon Sep 17 00:00:00 2001 From: jasonsky-pub <275120179+jasonsky-pub@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:20:12 -0400 Subject: [PATCH 5/8] Implement node-id init prompt JSON flow --- .../compact-prompts-json-node-ids/tasks.md | 12 +- src/openplate/__main__.py | 72 +- src/openplate/commands/project_init.py | 39 +- src/openplate/commands/project_update.py | 29 +- src/openplate/project_template_identity.py | 41 +- src/openplate/prompts/prompt_document.py | 334 ++++++--- src/openplate/prompts/prompt_document_cli.py | 12 +- .../prompts/prompt_document_collector.py | 83 +-- src/openplate/prompts/prompt_input_logging.py | 3 +- .../prompts/prompt_parameter_resolver.py | 15 +- tests/test_project_init_source_urls.py | 73 +- tests/test_prompt_document.py | 111 +-- tests/test_prompt_json_cli.py | 637 +++++++++--------- 13 files changed, 816 insertions(+), 645 deletions(-) diff --git a/openspec/changes/compact-prompts-json-node-ids/tasks.md b/openspec/changes/compact-prompts-json-node-ids/tasks.md index 61c7ca4..f43fea2 100644 --- a/openspec/changes/compact-prompts-json-node-ids/tasks.md +++ b/openspec/changes/compact-prompts-json-node-ids/tasks.md @@ -1,17 +1,17 @@ ## 1. Prompt Document Model And Identity -- [ ] 1.1 Replace the prompt document node model so exported and imported nodes use `node-id`, `answers`, and optional `info`, remove all pre-cutover prompt JSON node shapes, and emit at most one node per canonical identity during export. +- [x] 1.1 Replace the prompt document node model so exported and imported nodes use `node-id`, `answers`, and optional `info`, remove all pre-cutover prompt JSON node shapes, and emit at most one node per canonical identity during export. - [ ] 1.2 Normalize `dest_folder` at template ingestion by trimming whitespace and converting `\` to `/`, then compute init-relative destination folders consistently: top-level templates use `default_dest_folder`, which falls back to `.`, sibling declarations require a non-empty `dest_folder`, and the `project init --dest-folder` root is appended only for real output paths and excluded from prompt JSON identity. -- [ ] 1.3 Add deterministic full-hash node identity generation from canonical raw template-reference plus init-relative normalized destination-folder identity, serialize full hashes as 64-character lowercase hexadecimal strings, prefer the first 7 lowercase hexadecimal characters for short IDs, and implement the export-time short-ID registry with full-hash fallback on collisions. -- [ ] 1.4 Update prompt input tracking and logging to key nodes by `node-id`, reject duplicate `node-id` entries, and ignore `info` during import matching. +- [x] 1.3 Add deterministic full-hash node identity generation from canonical raw template-reference plus init-relative normalized destination-folder identity, serialize full hashes as 64-character lowercase hexadecimal strings, prefer the first 7 lowercase hexadecimal characters for short IDs, and implement the export-time short-ID registry with full-hash fallback on collisions. +- [x] 1.4 Update prompt input tracking and logging to key nodes by `node-id`, reject duplicate `node-id` entries, and ignore `info` during import matching. ## 2. Export And Import Behavior -- [ ] 2.1 Add `openplate project print-init-json ` as the compact prompt export command and support `--verbose` to emit the same nodes plus `info` metadata. -- [ ] 2.2 Update prompt export collection so `answers` contains discovered in-scope parameter keys initialized to `null`, parameterless reached nodes emit `answers: {}`, hidden parameters still follow `--ask-hidden`, verbose caller metadata emits sibling declaration conditions through caller-side metadata instead of called-node `condition` fields, and export fails instead of emitting partial nodes when prompt discovery cannot complete. +- [x] 2.1 Add `openplate project print-init-json ` as the compact prompt export command and support `--verbose` to emit the same nodes plus `info` metadata. +- [x] 2.2 Update prompt export collection so `answers` contains discovered in-scope parameter keys initialized to `null`, parameterless reached nodes emit `answers: {}`, hidden parameters still follow `--ask-hidden`, verbose caller metadata emits sibling declaration conditions through caller-side metadata instead of called-node `condition` fields, and export fails instead of emitting partial nodes when prompt discovery cannot complete. - [ ] 2.3 Update init JSON import parsing and parameter resolution so the top-level document must be a JSON array of node objects, `answers` is required, `null` and omitted answer keys both defer to runtime fallback, non-null answers remain authoritative, non-string non-null answer values are rejected, non-`node-id` prompt JSON shapes are rejected, and validation/execution reuse the same fetched template source within one command invocation. - [ ] 2.4 Implement two-pass import resolution so exact full-hash `node-id` matches claim nodes first, short `node-id` matches resolve only against remaining unclaimed runtime nodes, and invalid `node-id` formats are rejected. -- [ ] 2.5 Remove `--print-prompts-json`, `--prompts-json-file`, and `--prompts-json-stdin` from `project update`, remove `--print-prompts-json` from `project init`, and ensure legacy flag uses now fail by normal argument parsing because those options are no longer registered on those verbs. +- [x] 2.5 Remove `--print-prompts-json`, `--prompts-json-file`, and `--prompts-json-stdin` from `project update`, remove `--print-prompts-json` from `project init`, and ensure legacy flag uses now fail by normal argument parsing because those options are no longer registered on those verbs. ## 3. Validation, Docs, And Artifacts diff --git a/src/openplate/__main__.py b/src/openplate/__main__.py index b82d9ad..f5ff36d 100644 --- a/src/openplate/__main__.py +++ b/src/openplate/__main__.py @@ -23,37 +23,6 @@ import platform import sys -from openplate import __semver__ as module_semver -from openplate import __version__ as module_version -from openplate.cfg import open_plate_settings -from openplate.cfg.open_plate_settings import OpenPlateRuntimeSettings -from openplate.cfg.project_config import ProjectTemplateConfig -from openplate.prompts.prompt_document_cli import add_prompt_document_arguments, load_prompt_document as load_prompt_document_from_args -# -# Copyright 2025 Comcast Cable Communications Management, LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# -# This product includes software developed at Comcast (https://www.comcast.com/).# -import argparse -import asyncio -import logging -import os -import platform -import sys - from openplate import __semver__ as module_semver from openplate import __version__ as module_version from openplate.cfg import open_plate_settings @@ -71,7 +40,7 @@ from openplate.commands.project_update import UpdateOptions from openplate.commands.project_verify import VerifyOptions from openplate.prompts.prompt_document_cli import ( - add_prompt_document_arguments, + add_prompt_document_input_arguments, load_prompt_document as load_prompt_document_from_args, ) @@ -114,7 +83,7 @@ def configure_project_init_parser(parser): help="One-time override to allow template-provided init_commands to run during this init.", action="store_true" ) - add_prompt_document_arguments(parser) + add_prompt_document_input_arguments(parser) def configure_project_update_parser(parser): @@ -125,7 +94,6 @@ def configure_project_update_parser(parser): parser.add_argument("-f", "--update-full", required=False, help="Full update, overwrite existing non-template files (WARNING: will overwrite changes)", action=argparse.BooleanOptionalAction) - add_prompt_document_arguments(parser) def configure_project_verify_parser(parser): @@ -184,6 +152,24 @@ def create_arg_parser(args): hide_subparser_from_help(subparsers, "project") project_subparsers = parser_project.add_subparsers(required=True) + parser_print_init_json = project_subparsers.add_parser("print-init-json") + parser_print_init_json.set_defaults(command="project-print-init-json") + parser_print_init_json.add_argument("source", help="Template source URL") + parser_print_init_json.add_argument( + "--allow-default-branch", + required=False, + default=False, + help="Allow use of a default branch in a repo reference", + action=argparse.BooleanOptionalAction + ) + parser_print_init_json.add_argument( + "--verbose", + required=False, + default=False, + help="Include descriptive node metadata in the printed JSON output.", + action="store_true" + ) + legacy_parser_init = project_subparsers.add_parser("init") configure_project_init_parser(legacy_parser_init) @@ -197,9 +183,9 @@ def create_arg_parser(args): def resolve_project_init_source_reference(result) -> str: - source_reference = result.source or result.url + source_reference = result.source or getattr(result, "url", None) - if result.source and result.url: + if result.source and getattr(result, "url", None): raise ValueError("Specify exactly one template source URL, either positionally or with -r/--url") if not source_reference: @@ -295,21 +281,27 @@ async def async_main(args): absolute_project_folder, result.overwrite, result.allow_template_commands, - result.print_prompts_json, prompt_document, ) await project_init.run(configuration, runtime_settings, options) + elif result.command == "project-print-init-json": + source_reference = resolve_project_init_source_reference(result) + template = ProjectTemplateConfig(source_reference, None, None, None, None, {}, [], False) + await project_init.print_prompt_document( + configuration, + runtime_settings, + template, + absolute_project_folder, + result.verbose, + ) elif result.command == "project-verify": options = VerifyOptions(absolute_project_folder) await project_verify.run(configuration, runtime_settings, options) elif result.command == "project-update": - prompt_document = load_prompt_document(result) options = UpdateOptions( absolute_project_folder, result.update_missing, result.update_full, - result.print_prompts_json, - prompt_document, ) await project_update.run(configuration, runtime_settings, options) else: diff --git a/src/openplate/commands/project_init.py b/src/openplate/commands/project_init.py index 7a87332..a12d187 100644 --- a/src/openplate/commands/project_init.py +++ b/src/openplate/commands/project_init.py @@ -34,7 +34,6 @@ def __init__( destination: str, overwrite_existing_files: bool, allow_template_commands: bool, - print_prompts_json: bool, prompt_document: Optional[PromptDocument], ): if add_template is None: @@ -45,17 +44,38 @@ def __init__( self.destination = destination self.overwrite_existing_files = overwrite_existing_files or False self.allow_template_commands = allow_template_commands or False - self.print_prompts_json = print_prompts_json or False self.prompt_document = prompt_document +async def print_prompt_document( + settings: OpenPlateSettings, + runtime_settings: OpenPlateRuntimeSettings, + add_template: project_config.ProjectTemplateConfig, + destination: str, + verbose: bool, +): + config_project = project_config.from_file( + settings, + os.path.join(destination, project_config.project_config_file_name) + ) + + config_project.templates.append(add_template) + prompt_document = await collect_prompt_document_single( + settings, + runtime_settings, + add_template, + destination, + config_project, + ) + print(prompt_document.to_json_string(verbose=verbose)) + + async def run( settings: OpenPlateSettings, runtime_settings: OpenPlateRuntimeSettings, options: InitOptions, ): - if not options.print_prompts_json: - print(f"Running init on folder: {options.destination} source: {options.add_template.__str__()}") + print(f"Running init on folder: {options.destination} source: {options.add_template.__str__()}") config_project = project_config.from_file( settings, @@ -66,17 +86,6 @@ async def run( allow_template_commands = settings.allow_template_commands or options.allow_template_commands - if options.print_prompts_json: - prompt_document = await collect_prompt_document_single( - settings, - runtime_settings, - options.add_template, - options.destination, - config_project, - ) - print(prompt_document.to_json_string()) - return - prompt_input_tracker = None if options.prompt_document is not None: prompt_input_tracker = PromptInputTracker(options.prompt_document) diff --git a/src/openplate/commands/project_update.py b/src/openplate/commands/project_update.py index 501dd4f..1dc1660 100644 --- a/src/openplate/commands/project_update.py +++ b/src/openplate/commands/project_update.py @@ -34,16 +34,12 @@ def __init__( destination: str, create_non_template_files: bool, update_non_template_files: bool, - print_prompts_json: bool, - prompt_document: Optional[PromptDocument], ): if destination is None: raise TypeError self.destination = destination self.create_non_template_files = create_non_template_files self.update_non_template_files = update_non_template_files - self.print_prompts_json = print_prompts_json or False - self.prompt_document = prompt_document async def run( @@ -51,29 +47,14 @@ async def run( runtime_settings: OpenPlateRuntimeSettings, options ): - if not options.print_prompts_json: - print(f"Running update on folder: {options.destination}") - logging.debug(f"create_non_template_files: {options.create_non_template_files}, update_non_template_files: {options.update_non_template_files}") + print(f"Running update on folder: {options.destination}") + logging.debug(f"create_non_template_files: {options.create_non_template_files}, update_non_template_files: {options.update_non_template_files}") config_project = project_config.from_file( settings, os.path.join(options.destination, project_config.project_config_file_name) ) - if options.print_prompts_json: - prompt_document = await collect_prompt_document_all( - settings, - runtime_settings, - options.destination, - config_project, - ) - print(prompt_document.to_json_string()) - return - - prompt_input_tracker = None - if options.prompt_document is not None: - prompt_input_tracker = PromptInputTracker(options.prompt_document) - (config_updated, found_changes, sha) = await source_template_recursive_walk_all( settings, runtime_settings, @@ -90,12 +71,10 @@ async def run( options.create_non_template_files, options.update_non_template_files, False, - options.prompt_document is not None, - prompt_input_tracker, + False, + None, ) - log_ignored_prompt_templates(prompt_input_tracker) - # if config_updated: # Always attempt to fix config file (to update name references which should be urls for example) diff --git a/src/openplate/project_template_identity.py b/src/openplate/project_template_identity.py index 0a63311..f92a60b 100644 --- a/src/openplate/project_template_identity.py +++ b/src/openplate/project_template_identity.py @@ -16,8 +16,40 @@ # SPDX-License-Identifier: Apache-2.0 # # This product includes software developed at Comcast (https://www.comcast.com/).# +import hashlib +import json + from openplate.cfg.open_plate_settings import OpenPlateSettings from openplate.sources.name_converter import convert_name +from openplate.walk.recursive_walker import norm_relative_path + + +def normalize_prompt_dest_folder(dest_folder): + if dest_folder is None: + return "." + + stripped_dest_folder = str(dest_folder).strip().replace("\\", "/") + if not stripped_dest_folder: + return "." + + normalized_dest_folder = norm_relative_path(stripped_dest_folder) + return normalized_dest_folder or "." + + +def canonical_prompt_node_identity(template_reference: str, dest_folder): + return json.dumps({ + "template": template_reference, + "dest_folder": normalize_prompt_dest_folder(dest_folder), + }, sort_keys=True, separators=(",", ":")) + + +def full_prompt_node_id(template_reference: str, dest_folder) -> str: + canonical_identity = canonical_prompt_node_identity(template_reference, dest_folder) + return hashlib.sha256(canonical_identity.encode("utf-8")).hexdigest() + + +def short_prompt_node_id(full_node_id: str) -> str: + return full_node_id[:7] def prompt_template_reference(config_project_template): @@ -25,7 +57,14 @@ def prompt_template_reference(config_project_template): def prompt_dest_folder(config_project_template): - return config_project_template.raw_dest_folder + return normalize_prompt_dest_folder(config_project_template.dest_folder) + + +def prompt_node_id(config_project_template) -> str: + return full_prompt_node_id( + prompt_template_reference(config_project_template), + prompt_dest_folder(config_project_template), + ) def prompt_condition(config_project_template): diff --git a/src/openplate/prompts/prompt_document.py b/src/openplate/prompts/prompt_document.py index c05d486..7ff049c 100644 --- a/src/openplate/prompts/prompt_document.py +++ b/src/openplate/prompts/prompt_document.py @@ -17,9 +17,15 @@ # # This product includes software developed at Comcast (https://www.comcast.com/).# import json +import re from dataclasses import dataclass from typing import Optional +from openplate.project_template_identity import full_prompt_node_id, normalize_prompt_dest_folder, short_prompt_node_id + + +_NODE_ID_PATTERN = re.compile(r"^(?:[0-9a-f]{7}|[0-9a-f]{64})$") + def _require_optional_string(value, field_name: str): if value is not None and not isinstance(value, str): @@ -31,15 +37,8 @@ def _require_optional_bool(value, field_name: str): raise TypeError(f"Prompt parameter '{field_name}' must be a boolean or null") -@dataclass(frozen=True) -class PromptTemplateKey: - template: str - dest_folder: Optional[str] - - @dataclass class PromptParameterValue: - value: Optional[str] default: Optional[str] existing: Optional[str] description: Optional[str] @@ -51,8 +50,6 @@ class PromptParameterValue: def from_json_data(cls, data): if not isinstance(data, dict): raise TypeError("Prompt parameter entry must be an object") - if "value" not in data: - raise ValueError("Prompt parameter entry must include a 'value' field") choices = data.get("choices") if choices is not None and not isinstance(choices, list): @@ -62,7 +59,6 @@ def from_json_data(cls, data): if not isinstance(choice, str): raise TypeError("Prompt parameter 'choices' entries must be strings") - _require_optional_string(data.get("value"), "value") _require_optional_string(data.get("default"), "default") _require_optional_string(data.get("existing"), "existing") _require_optional_string(data.get("description"), "description") @@ -70,7 +66,6 @@ def from_json_data(cls, data): _require_optional_bool(data.get("required"), "required") return cls( - data.get("value"), data.get("default"), data.get("existing"), data.get("description"), @@ -81,7 +76,6 @@ def from_json_data(cls, data): def to_json_data(self): return { - "value": self.value, "default": self.default, "existing": self.existing, "description": self.description, @@ -92,49 +86,93 @@ def to_json_data(self): @dataclass -class PromptTemplateNode: +class PromptSiblingTemplateInfo: template: str - dest_folder: Optional[str] - parameters: Optional[dict[str, PromptParameterValue]] + dest_folder: str condition: Optional[str] = None - @property - def key(self) -> PromptTemplateKey: - return PromptTemplateKey(self.template, self.dest_folder) + @classmethod + def from_json_data(cls, data): + if not isinstance(data, dict): + raise TypeError("Prompt sibling entry must be an object") + + template = data.get("template") + if not isinstance(template, str) or not template.strip(): + raise ValueError("Prompt sibling entry must include a non-empty 'template' field") + + dest_folder = data.get("dest_folder") + if not isinstance(dest_folder, str) or not dest_folder.strip(): + raise ValueError("Prompt sibling entry must include a non-empty 'dest_folder' field") + + condition = data.get("condition") + if condition is not None and not isinstance(condition, str): + raise TypeError("Prompt sibling 'condition' must be a string when provided") + + return cls(template.strip(), normalize_prompt_dest_folder(dest_folder), condition) + + def to_json_data(self): + result = { + "template": self.template, + "dest_folder": self.dest_folder, + } + if self.condition is not None: + result["condition"] = self.condition + return result + + +@dataclass +class PromptNodeInfo: + template: str + dest_folder: str + parameters: Optional[dict[str, PromptParameterValue]] + require_sibling_templates: Optional[list[PromptSiblingTemplateInfo]] = None + condition: Optional[str] = None @classmethod def from_json_data(cls, data): if not isinstance(data, dict): - raise TypeError("Prompt template entry must be an object") + raise TypeError("Prompt node 'info' must be an object") template = data.get("template") if not isinstance(template, str) or not template.strip(): - raise ValueError("Prompt template entry must include a non-empty 'template' field") + raise ValueError("Prompt node 'info' must include a non-empty 'template' field") - if "dest_folder" not in data: - raise ValueError("Prompt template entry must include a 'dest_folder' field") dest_folder = data.get("dest_folder") - if dest_folder is not None and not isinstance(dest_folder, str): - raise TypeError("Prompt template 'dest_folder' must be a string or null") + if not isinstance(dest_folder, str) or not dest_folder.strip(): + raise ValueError("Prompt node 'info' must include a non-empty 'dest_folder' field") - if "parameters" not in data: - raise ValueError("Prompt template entry must include a 'parameters' field") raw_parameters = data.get("parameters") parameters = None if raw_parameters is not None: if not isinstance(raw_parameters, dict): - raise TypeError("Prompt template 'parameters' must be an object or null") + raise TypeError("Prompt node 'info.parameters' must be an object or null") parameters = {} for name, parameter_data in raw_parameters.items(): if not isinstance(name, str) or not name: raise ValueError("Prompt parameter names must be non-empty strings") parameters[name] = PromptParameterValue.from_json_data(parameter_data) + raw_required_siblings = data.get("require_sibling_templates") + require_sibling_templates = None + if raw_required_siblings is not None: + if not isinstance(raw_required_siblings, list): + raise TypeError("Prompt node 'info.require_sibling_templates' must be a list when provided") + require_sibling_templates = [ + PromptSiblingTemplateInfo.from_json_data(entry) + for entry in raw_required_siblings + ] + condition = data.get("condition") if condition is not None and not isinstance(condition, str): - raise TypeError("Prompt template 'condition' must be a string when provided") + raise TypeError("Prompt node 'info.condition' must be a string when provided") - return cls(template.strip(), dest_folder, parameters, condition) + return cls( + template.strip(), + normalize_prompt_dest_folder(dest_folder), + parameters, + require_sibling_templates, + condition, + ) def to_json_data(self): result = { @@ -142,19 +180,91 @@ def to_json_data(self): "dest_folder": self.dest_folder, "parameters": None, } - if self.condition is not None: - result["condition"] = self.condition if self.parameters is not None: result["parameters"] = { name: parameter.to_json_data() for name, parameter in self.parameters.items() } + if self.require_sibling_templates is not None: + result["require_sibling_templates"] = [ + sibling.to_json_data() + for sibling in self.require_sibling_templates + ] + if self.condition is not None: + result["condition"] = self.condition + return result + + +@dataclass +class PromptTemplateNode: + node_id: str + answers: dict[str, Optional[str]] + info: Optional[PromptNodeInfo] = None + + @classmethod + def from_json_data(cls, data): + if not isinstance(data, dict): + raise TypeError("Prompt node entry must be an object") + + node_id = data.get("node-id") + if not isinstance(node_id, str) or not _NODE_ID_PATTERN.fullmatch(node_id): + raise ValueError("Prompt node entry must include a valid 'node-id' field") + + if "answers" not in data: + raise ValueError("Prompt node entry must include an 'answers' field") + raw_answers = data.get("answers") + if not isinstance(raw_answers, dict): + raise TypeError("Prompt node 'answers' must be an object") + + answers = {} + for name, value in raw_answers.items(): + if not isinstance(name, str) or not name: + raise ValueError("Prompt answer names must be non-empty strings") + _require_optional_string(value, name) + answers[name] = value + + raw_info = data.get("info") + info = None + if raw_info is not None: + info = PromptNodeInfo.from_json_data(raw_info) + + return cls(node_id, answers, info) + + @property + def template(self) -> Optional[str]: + if self.info is None: + return None + return self.info.template + + @property + def dest_folder(self) -> Optional[str]: + if self.info is None: + return None + return self.info.dest_folder + + @property + def parameters(self) -> Optional[dict[str, PromptParameterValue]]: + if self.info is None: + return None + return self.info.parameters + + def to_json_data(self, verbose: bool): + result = { + "node-id": self.node_id, + "answers": self.answers, + } + if verbose and self.info is not None: + result["info"] = self.info.to_json_data() return result @dataclass class PromptDocument: - templates: list[PromptTemplateNode] + nodes: list[PromptTemplateNode] + + @property + def templates(self): + return self.nodes @classmethod def from_json_string(cls, json_string: str): @@ -162,108 +272,162 @@ def from_json_string(cls, json_string: str): if not isinstance(raw_data, list): raise TypeError("Prompt document must be a JSON array") - templates = [] - seen_keys = set() + nodes = [] + seen_node_ids = set() for entry in raw_data: node = PromptTemplateNode.from_json_data(entry) - if node.key in seen_keys: - raise ValueError( - f"Duplicate prompt template entry: template={node.template!r}, dest_folder={node.dest_folder!r}" - ) - seen_keys.add(node.key) - templates.append(node) + if node.node_id in seen_node_ids: + raise ValueError(f"Duplicate prompt node entry: node-id={node.node_id!r}") + seen_node_ids.add(node.node_id) + nodes.append(node) - return cls(templates) + return cls(nodes) - def to_json_string(self) -> str: - return json.dumps([node.to_json_data() for node in self.templates], indent=2) + def to_json_string(self, verbose: bool = False) -> str: + return json.dumps([node.to_json_data(verbose) for node in self.nodes], indent=2) class PromptDocumentBuilder: def __init__(self): - self._templates = [] - self._seen_keys = set() + self._nodes = [] + self._nodes_by_full_id = {} + self._short_ids = {} def add_template( self, template: str, dest_folder: Optional[str], parameters: Optional[dict[str, PromptParameterValue]], - condition: Optional[str] = None, + require_sibling_templates: Optional[list[PromptSiblingTemplateInfo]] = None, ): - key = PromptTemplateKey(template, dest_folder) - if key in self._seen_keys: - return False - - self._seen_keys.add(key) - self._templates.append(PromptTemplateNode(template, dest_folder, parameters, condition)) - return True + normalized_dest_folder = normalize_prompt_dest_folder(dest_folder) + full_node_id = full_prompt_node_id(template, normalized_dest_folder) + existing_node = self._nodes_by_full_id.get(full_node_id) + if existing_node is None: + preferred_short_id = short_prompt_node_id(full_node_id) + node_id = preferred_short_id + if preferred_short_id in self._short_ids and self._short_ids[preferred_short_id] != full_node_id: + node_id = full_node_id + else: + self._short_ids[preferred_short_id] = full_node_id + + info = PromptNodeInfo( + template=template, + dest_folder=normalized_dest_folder, + parameters=parameters, + require_sibling_templates=require_sibling_templates, + ) + answers = {} + if parameters is not None: + answers = {name: None for name in parameters.keys()} + + existing_node = PromptTemplateNode(node_id, answers, info) + self._nodes.append(existing_node) + self._nodes_by_full_id[full_node_id] = existing_node + return True + + if parameters is not None: + existing_node.answers = {name: None for name in parameters.keys()} + if existing_node.info is not None: + existing_node.info.parameters = parameters + if existing_node.info is not None and require_sibling_templates is not None: + existing_node.info.require_sibling_templates = require_sibling_templates + + return False def build(self) -> PromptDocument: - return PromptDocument(list(self._templates)) + return PromptDocument(list(self._nodes)) class PromptInputTracker: def __init__(self, document: Optional[PromptDocument]): self._document = document - self._by_key = {} - self._used_template_keys = set() + self._by_node_id = {} + self._by_info_key = {} + self._used_node_ids = set() self._used_parameter_names = {} + self._resolved_runtime_nodes = {} if document is None: return - for node in document.templates: - self._by_key[node.key] = node - self._used_parameter_names[node.key] = set() + for node in document.nodes: + self._by_node_id[node.node_id] = node + self._used_parameter_names[node.node_id] = set() + if node.info is not None: + self._by_info_key[(node.info.template, node.info.dest_folder)] = node.node_id @classmethod def from_json_string(cls, json_string: str): return cls(PromptDocument.from_json_string(json_string)) - def get_template(self, template: str, dest_folder: Optional[str]) -> Optional[PromptTemplateNode]: - key = PromptTemplateKey(template, dest_folder) - node = self._by_key.get(key) + def _resolve_supplied_node_id(self, runtime_node_id: str) -> Optional[str]: + cached_node_id = self._resolved_runtime_nodes.get(runtime_node_id) + if cached_node_id is not None: + return cached_node_id + + if runtime_node_id in self._by_node_id: + self._resolved_runtime_nodes[runtime_node_id] = runtime_node_id + return runtime_node_id + + runtime_short_node_id = short_prompt_node_id(runtime_node_id) + if runtime_short_node_id in self._by_node_id: + self._resolved_runtime_nodes[runtime_node_id] = runtime_short_node_id + return runtime_short_node_id + + return None + + def get_template(self, template_or_node_id: str, dest_folder: Optional[str] = None) -> Optional[PromptTemplateNode]: + if dest_folder is None: + node_id = self._resolve_supplied_node_id(template_or_node_id) or template_or_node_id + else: + node_id = self._by_info_key.get((template_or_node_id, normalize_prompt_dest_folder(dest_folder))) + + node = self._by_node_id.get(node_id) if node is not None: - self._used_template_keys.add(key) + self._used_node_ids.add(node_id) return node - def mark_template_used(self, template: str, dest_folder: Optional[str]): - key = PromptTemplateKey(template, dest_folder) - if key in self._by_key: - self._used_template_keys.add(key) + def mark_template_used(self, template_or_node_id: str, dest_folder: Optional[str] = None): + node = self.get_template(template_or_node_id, dest_folder) + if node is not None: + self._used_node_ids.add(node.node_id) + + def get_parameter_value(self, template_or_node_id: str, dest_folder_or_name: Optional[str], name: Optional[str] = None): + if name is None: + node = self.get_template(template_or_node_id) + parameter_name = dest_folder_or_name + else: + node = self.get_template(template_or_node_id, dest_folder_or_name) + parameter_name = name - def get_parameter_value(self, template: str, dest_folder: Optional[str], name: str): - node = self.get_template(template, dest_folder) - if node is None or node.parameters is None: + if node is None or parameter_name is None: return None, False - parameter = node.parameters.get(name) - if parameter is None: + if parameter_name not in node.answers: return None, False - self._used_parameter_names[node.key].add(name) - return parameter.value, True + self._used_parameter_names[node.node_id].add(parameter_name) + return node.answers.get(parameter_name), True def ignored_templates(self) -> list[PromptTemplateNode]: if self._document is None: return [] return [ - node for node in self._document.templates - if node.key not in self._used_template_keys + node for node in self._document.nodes + if node.node_id not in self._used_node_ids ] - def unused_parameters(self, template: str, dest_folder: Optional[str]) -> list[str]: - key = PromptTemplateKey(template, dest_folder) - node = self._by_key.get(key) - if node is None or node.parameters is None: + def unused_parameters(self, template_or_node_id: str, dest_folder: Optional[str] = None) -> list[str]: + node = self.get_template(template_or_node_id, dest_folder) + if node is None: return [] - used_names = self._used_parameter_names.get(key, set()) + used_names = self._used_parameter_names.get(node.node_id, set()) unused = [] - for name, parameter in node.parameters.items(): - if parameter.value is None: + for answer_name, answer_value in node.answers.items(): + if answer_value is None: continue - if name not in used_names: - unused.append(name) + if answer_name not in used_names: + unused.append(answer_name) return unused \ No newline at end of file diff --git a/src/openplate/prompts/prompt_document_cli.py b/src/openplate/prompts/prompt_document_cli.py index 4cf670e..9df56e9 100644 --- a/src/openplate/prompts/prompt_document_cli.py +++ b/src/openplate/prompts/prompt_document_cli.py @@ -21,14 +21,7 @@ from openplate.prompts.prompt_document import PromptDocument -def add_prompt_document_arguments(parser): - parser.add_argument( - "--print-prompts-json", - required=False, - default=False, - help="Print the prompt document as JSON without modifying project state.", - action="store_true" - ) +def add_prompt_document_input_arguments(parser): parser.add_argument( "--prompts-json-file", required=False, @@ -49,9 +42,6 @@ def load_prompt_document(result) -> PromptDocument | None: if prompt_input_flags > 1: raise ValueError("Specify only one prompts JSON input source, either --prompts-json-file or --prompts-json-stdin") - if getattr(result, "print_prompts_json", False) and prompt_input_flags > 0: - raise ValueError("--print-prompts-json cannot be combined with --prompts-json-file or --prompts-json-stdin") - if getattr(result, "prompts_json_file", None): with open(result.prompts_json_file, encoding="utf-8") as prompts_json_file: return PromptDocument.from_json_string(prompts_json_file.read()) diff --git a/src/openplate/prompts/prompt_document_collector.py b/src/openplate/prompts/prompt_document_collector.py index bdeaefb..f78044f 100644 --- a/src/openplate/prompts/prompt_document_collector.py +++ b/src/openplate/prompts/prompt_document_collector.py @@ -24,8 +24,8 @@ from openplate.cfg.open_plate_settings import OpenPlateRuntimeSettings, OpenPlateSettings from openplate.cfg.project_config import ProjectTemplateConfig from openplate.project_metadata_resolver import resolve_project_metadata -from openplate.project_template_identity import prompt_condition, prompt_dest_folder, prompt_template_reference, source_cache_key -from openplate.prompts.prompt_document import PromptDocument, PromptDocumentBuilder +from openplate.project_template_identity import prompt_dest_folder, prompt_template_reference, source_cache_key +from openplate.prompts.prompt_document import PromptDocument, PromptDocumentBuilder, PromptSiblingTemplateInfo from openplate.prompts.prompt_parameter_resolver import describe_prompt_parameters from openplate.sibling_template_resolver import copy_template_with_raw_identity, find_matching_template, render_sibling_template_config from openplate.sources.source_cache import CommandTemplateSourceCache, close_command_template_source_cache @@ -56,14 +56,9 @@ async def _collect_prompt_document_template( os.path.join(source.folder_path(), template_config.template_config_file_name) ) except Exception as ex: - logging.debug("Unable to fully inspect template for prompt export: %s", ex) - prompt_document_builder.add_template( - prompt_template_reference(config_project_template), - prompt_dest_folder(config_project_template), - None, - prompt_condition(config_project_template), - ) - return + raise RuntimeError( + f"Unable to fully inspect template for prompt export: {config_project_template.get_template_source_name()}" + ) from ex if config_project_template.dest_folder is None: config_project_template.dest_folder = config_template.default_dest_folder or "" @@ -94,24 +89,21 @@ async def _collect_prompt_document_template( config_project_template.get_template_source_name(), ex, ) + raise RuntimeError( + f"Unable to enumerate prompt metadata for {config_project_template.get_template_source_name()}" + ) from ex + + sibling_declarations = [] + + if config_template.require_sibling_templates is None: prompt_document_builder.add_template( prompt_template_reference(config_project_template), prompt_dest_folder(config_project_template), + parameters, None, - prompt_condition(config_project_template), ) return - prompt_document_builder.add_template( - prompt_template_reference(config_project_template), - prompt_dest_folder(config_project_template), - parameters, - prompt_condition(config_project_template), - ) - - if config_template.require_sibling_templates is None: - return - for sibling_template in config_template.require_sibling_templates: raw_template_reference = sibling_template.template_url raw_dest_folder = sibling_template.dest_folder @@ -125,14 +117,9 @@ async def _collect_prompt_document_template( source, ) except Exception as ex: - logging.debug("Unable to resolve sibling declaration for prompt export: %s", ex) - prompt_document_builder.add_template( - raw_template_reference, - raw_dest_folder, - None, - raw_condition, - ) - continue + raise RuntimeError( + f"Unable to resolve sibling declaration for prompt export: {raw_template_reference}" + ) from ex matching_template = find_matching_template(config_project, rendered_sibling_template) if matching_template is not None: @@ -145,17 +132,37 @@ async def _collect_prompt_document_template( else: next_template = rendered_sibling_template - await _collect_prompt_document_template( - settings, - runtime_settings, - next_template, - project_folder, - config_project, - prompt_document_builder, - source_cache, - visited_template_keys, + sibling_declarations.append( + PromptSiblingTemplateInfo( + raw_template_reference, + prompt_dest_folder(next_template), + raw_condition, + ) ) + try: + await _collect_prompt_document_template( + settings, + runtime_settings, + next_template, + project_folder, + config_project, + prompt_document_builder, + source_cache, + visited_template_keys, + ) + except RuntimeError as ex: + raise RuntimeError( + f"Unable to resolve sibling declaration for prompt export: {raw_template_reference}" + ) from ex + + prompt_document_builder.add_template( + prompt_template_reference(config_project_template), + prompt_dest_folder(config_project_template), + parameters, + sibling_declarations, + ) + async def collect_prompt_document_single( settings: OpenPlateSettings, diff --git a/src/openplate/prompts/prompt_input_logging.py b/src/openplate/prompts/prompt_input_logging.py index 75ec576..2f95369 100644 --- a/src/openplate/prompts/prompt_input_logging.py +++ b/src/openplate/prompts/prompt_input_logging.py @@ -28,7 +28,8 @@ def log_ignored_prompt_templates(prompt_input_tracker: Optional[PromptInputTrack for ignored_template in prompt_input_tracker.ignored_templates(): logging.warning( - "Ignoring supplied prompt template because it was not processed: template=%r dest_folder=%r", + "Ignoring supplied prompt template because it was not processed: node-id=%r template=%r dest_folder=%r", + ignored_template.node_id, ignored_template.template, ignored_template.dest_folder, ) \ No newline at end of file diff --git a/src/openplate/prompts/prompt_parameter_resolver.py b/src/openplate/prompts/prompt_parameter_resolver.py index 88ffc10..a1232af 100644 --- a/src/openplate/prompts/prompt_parameter_resolver.py +++ b/src/openplate/prompts/prompt_parameter_resolver.py @@ -25,7 +25,7 @@ from openplate.cfg import project_config, template_config from openplate.cfg.open_plate_settings import OpenPlateRuntimeSettings, OpenPlateSettings from openplate.cfg.template_config import TemplateConfigParameter -from openplate.project_template_identity import prompt_dest_folder, prompt_template_reference +from openplate.project_template_identity import prompt_dest_folder, prompt_node_id, prompt_template_reference from openplate.prompts.prompt_document import PromptInputTracker, PromptParameterValue @@ -182,7 +182,6 @@ def describe_prompt_parameters( parameter, ) result[parameter.name] = PromptParameterValue( - None, default_value, existing_value, parameter.description, @@ -206,8 +205,7 @@ def try_resolve_parameter_without_prompt( if prompt_input_tracker is not None: supplied_value, has_supplied_value = prompt_input_tracker.get_parameter_value( - prompt_template_reference(config_project_template), - prompt_dest_folder(config_project_template), + prompt_node_id(config_project_template), parameter.name, ) if has_supplied_value and supplied_value is not None: @@ -229,8 +227,7 @@ def mark_template_used( return prompt_input_tracker.mark_template_used( - prompt_template_reference(config_project_template), - prompt_dest_folder(config_project_template), + prompt_node_id(config_project_template), ) @@ -243,9 +240,11 @@ def log_unused_prompt_parameters( template_reference = prompt_template_reference(config_project_template) dest_folder = prompt_dest_folder(config_project_template) - for unused_name in prompt_input_tracker.unused_parameters(template_reference, dest_folder): + node_id = prompt_node_id(config_project_template) + for unused_name in prompt_input_tracker.unused_parameters(node_id): logging.warning( - "Ignoring unused supplied prompt parameter for template=%r dest_folder=%r parameter=%r", + "Ignoring unused supplied prompt parameter for node-id=%r template=%r dest_folder=%r parameter=%r", + node_id, template_reference, dest_folder, unused_name, diff --git a/tests/test_project_init_source_urls.py b/tests/test_project_init_source_urls.py index 50db724..838faa6 100644 --- a/tests/test_project_init_source_urls.py +++ b/tests/test_project_init_source_urls.py @@ -98,16 +98,26 @@ def test_create_arg_parser_top_level_help_hides_project_command(): assert "==SUPPRESS==" not in help_text -def test_create_arg_parser_accepts_prompts_json_flags_for_top_level_init(): - args = ["openplate", "init", "https://example.com/template.git#main", "--print-prompts-json"] +def test_create_arg_parser_accepts_prompts_json_input_flags_for_top_level_init(): + args = ["openplate", "init", "https://example.com/template.git#main", "--prompts-json-stdin"] parser = create_arg_parser(args) result = parser.parse_args(args[1:]) assert result.command == "project-init" - assert result.print_prompts_json is True assert result.prompts_json_file is None - assert result.prompts_json_stdin is False + assert result.prompts_json_stdin is True + + +def test_create_arg_parser_accepts_print_init_json_command(): + args = ["openplate", "project", "print-init-json", "https://example.com/template.git#main", "--verbose"] + parser = create_arg_parser(args) + + result = parser.parse_args(args[1:]) + + assert result.command == "project-print-init-json" + assert result.source == "https://example.com/template.git#main" + assert result.verbose is True def test_resolve_project_init_source_reference_rejects_conflicting_inputs(): @@ -120,29 +130,31 @@ def test_resolve_project_init_source_reference_rejects_conflicting_inputs(): def test_load_prompt_document_rejects_multiple_input_sources(): - result = create_arg_parser(["openplate", "project", "update"]).parse_args([ - "project", "update", "--prompts-json-file", "prompts.json", "--prompts-json-stdin" + result = create_arg_parser(["openplate", "project", "init"]).parse_args([ + "project", "init", "https://example.com/template.git#main", "--prompts-json-file", "prompts.json", "--prompts-json-stdin" ]) with pytest.raises(ValueError, match="Specify only one prompts JSON input source"): load_prompt_document(result) -def test_load_prompt_document_rejects_print_mode_with_input_file(tmp_path): - prompts_json = tmp_path / "prompts.json" - prompts_json.write_text("[]", encoding="utf-8") +def test_create_arg_parser_rejects_removed_update_prompt_json_flags(): + parser = create_arg_parser(["openplate", "project", "update"]) - result = create_arg_parser(["openplate", "project", "update"]).parse_args([ - "project", "update", "--print-prompts-json", "--prompts-json-file", str(prompts_json) - ]) + with pytest.raises(SystemExit): + parser.parse_args(["project", "update", "--prompts-json-file", "prompts.json"]) - with pytest.raises(ValueError, match="cannot be combined"): - load_prompt_document(result) + +def test_create_arg_parser_rejects_removed_init_print_flag(): + parser = create_arg_parser(["openplate", "project", "init"]) + + with pytest.raises(SystemExit): + parser.parse_args(["project", "init", "https://example.com/template.git#main", "--print-prompts-json"]) def test_load_prompt_document_reads_json_from_stdin(monkeypatch): - result = create_arg_parser(["openplate", "project", "update"]).parse_args([ - "project", "update", "--prompts-json-stdin" + result = create_arg_parser(["openplate", "project", "init"]).parse_args([ + "project", "init", "https://example.com/template.git#main", "--prompts-json-stdin" ]) monkeypatch.setattr("sys.stdin", StringIO("[]")) @@ -200,14 +212,13 @@ async def fake_run(*_args, **_kwargs): assert "deprecated" in captured.err -def test_async_main_passes_prompt_document_to_project_update(monkeypatch, tmp_path): +def test_async_main_passes_prompt_document_to_project_init(monkeypatch, tmp_path): captured_options = {} async def fake_run(_settings, _runtime_settings, options): - captured_options["print_prompts_json"] = options.print_prompts_json captured_options["prompt_document"] = options.prompt_document - monkeypatch.setattr("openplate.commands.project_update.run", fake_run) + monkeypatch.setattr("openplate.commands.project_init.run", fake_run) prompts_json = tmp_path / "prompts.json" prompts_json.write_text("[]", encoding="utf-8") @@ -219,26 +230,27 @@ async def fake_run(_settings, _runtime_settings, options): "project", "--project-folder", str(tmp_path), - "update", + "init", + "https://example.com/template.git#main", "--prompts-json-file", str(prompts_json), ] asyncio.run(async_main(args)) - assert captured_options["print_prompts_json"] is False assert captured_options["prompt_document"] is not None assert captured_options["prompt_document"].templates == [] -def test_async_main_passes_print_prompts_json_to_project_init(monkeypatch, tmp_path): +def test_async_main_dispatches_print_init_json(monkeypatch, tmp_path): captured_options = {} - async def fake_run(_settings, _runtime_settings, options): - captured_options["print_prompts_json"] = options.print_prompts_json - captured_options["prompt_document"] = options.prompt_document + async def fake_print(_settings, _runtime_settings, template, destination, verbose): + captured_options["template"] = template + captured_options["destination"] = destination + captured_options["verbose"] = verbose - monkeypatch.setattr("openplate.commands.project_init.run", fake_run) + monkeypatch.setattr("openplate.commands.project_init.print_prompt_document", fake_print) args = [ "openplate", @@ -247,15 +259,16 @@ async def fake_run(_settings, _runtime_settings, options): "project", "--project-folder", str(tmp_path), - "init", + "print-init-json", "https://example.com/template.git#main", - "--print-prompts-json", + "--verbose", ] asyncio.run(async_main(args)) - assert captured_options["print_prompts_json"] is True - assert captured_options["prompt_document"] is None + assert captured_options["template"].src_url == "https://example.com/template.git#main" + assert captured_options["destination"] == str(tmp_path.resolve()) + assert captured_options["verbose"] is True def test_git_template_reference_parses_query_path_and_ref(): diff --git a/tests/test_prompt_document.py b/tests/test_prompt_document.py index 92b5ec6..c36d25a 100644 --- a/tests/test_prompt_document.py +++ b/tests/test_prompt_document.py @@ -24,66 +24,63 @@ pytestmark = pytest.mark.unit -def test_prompt_document_rejects_duplicate_template_entries(): +def test_prompt_document_rejects_duplicate_node_ids(): json_string = """ [ - {"template": "repo#main", "dest_folder": ".", "parameters": {}}, - {"template": "repo#main", "dest_folder": ".", "parameters": {}} + {"node-id": "123abcd", "answers": {}}, + {"node-id": "123abcd", "answers": {}} ] """ - with pytest.raises(ValueError, match="Duplicate prompt template entry"): + with pytest.raises(ValueError, match="Duplicate prompt node entry"): PromptDocument.from_json_string(json_string) -def test_prompt_document_rejects_missing_parameter_value_field(): +def test_prompt_document_rejects_invalid_node_id_format(): json_string = """ [ { - "template": "repo#main", - "dest_folder": ".", - "parameters": { - "service_name": { - "default": "demo" - } - } + "node-id": "INVALID", + "answers": {} } ] """ - with pytest.raises(ValueError, match="must include a 'value' field"): + with pytest.raises(ValueError, match="valid 'node-id'"): PromptDocument.from_json_string(json_string) -def test_prompt_document_rejects_non_string_parameter_value(): +def test_prompt_document_rejects_non_string_answer_value(): json_string = """ [ { - "template": "repo#main", - "dest_folder": ".", - "parameters": { - "service_name": { - "value": 123 - } + "node-id": "123abcd", + "answers": { + "service_name": 123 } } ] """ - with pytest.raises(TypeError, match="'value' must be a string or null"): + with pytest.raises(TypeError, match="must be a string or null"): PromptDocument.from_json_string(json_string) -def test_prompt_document_rejects_non_boolean_hidden_flag(): +def test_prompt_document_rejects_non_boolean_hidden_flag_in_info_metadata(): json_string = """ [ { - "template": "repo#main", - "dest_folder": ".", - "parameters": { - "service_name": { - "value": null, - "hidden": "yes" + "node-id": "123abcd", + "answers": { + "service_name": null + }, + "info": { + "template": "repo#main", + "dest_folder": ".", + "parameters": { + "service_name": { + "hidden": "yes" + } } } } @@ -94,14 +91,20 @@ def test_prompt_document_rejects_non_boolean_hidden_flag(): PromptDocument.from_json_string(json_string) -def test_prompt_document_round_trips_null_parameters(): +def test_prompt_document_round_trips_verbose_info_metadata(): json_string = """ [ { - "template": "repo#main", - "dest_folder": ".", - "condition": "{{ include_api }}", - "parameters": null + "node-id": "123abcd", + "answers": { + "service_name": null + }, + "info": { + "template": "repo#main", + "dest_folder": ".", + "parameters": null, + "condition": "{{ include_api }}" + } } ] """ @@ -109,26 +112,28 @@ def test_prompt_document_round_trips_null_parameters(): document = PromptDocument.from_json_string(json_string) assert document.templates[0].parameters is None - assert '"parameters": null' in document.to_json_string() + assert '"info"' not in document.to_json_string() + assert '"parameters": null' in document.to_json_string(verbose=True) -def test_prompt_input_tracker_treats_null_parameters_as_no_supplied_values(): +def test_prompt_input_tracker_treats_null_answer_as_unresolved(): tracker = PromptInputTracker.from_json_string( """ [ { - "template": "repo#main", - "dest_folder": ".", - "parameters": null + "node-id": "123abcd", + "answers": { + "service_name": null + } } ] """ ) - value, found = tracker.get_parameter_value("repo#main", ".", "service_name") + value, found = tracker.get_parameter_value("123abcd", "service_name") assert value is None - assert found is False + assert found is True def test_prompt_input_tracker_reports_unused_supplied_parameters(): @@ -136,39 +141,37 @@ def test_prompt_input_tracker_reports_unused_supplied_parameters(): """ [ { - "template": "repo#main", - "dest_folder": ".", - "parameters": { - "used": {"value": "x"}, - "unused": {"value": "y"}, - "null_value": {"value": null} + "node-id": "123abcd", + "answers": { + "used": "x", + "unused": "y", + "null_value": null } } ] """ ) - value, found = tracker.get_parameter_value("repo#main", ".", "used") + value, found = tracker.get_parameter_value("123abcd", "used") assert value == "x" assert found is True - assert tracker.unused_parameters("repo#main", ".") == ["unused"] + assert tracker.unused_parameters("123abcd") == ["unused"] -def test_prompt_input_tracker_reports_ignored_templates(): +def test_prompt_input_tracker_reports_ignored_nodes(): tracker = PromptInputTracker.from_json_string( """ [ - {"template": "repo#main", "dest_folder": ".", "parameters": {}}, - {"template": "repo#main", "dest_folder": "src/api", "parameters": {}} + {"node-id": "123abcd", "answers": {}}, + {"node-id": "abcdef0", "answers": {}} ] """ ) - tracker.get_template("repo#main", ".") + tracker.get_template("123abcd") ignored = tracker.ignored_templates() assert len(ignored) == 1 - assert ignored[0].template == "repo#main" - assert ignored[0].dest_folder == "src/api" \ No newline at end of file + assert ignored[0].node_id == "abcdef0" \ No newline at end of file diff --git a/tests/test_prompt_json_cli.py b/tests/test_prompt_json_cli.py index 6873ee0..f5e47b8 100644 --- a/tests/test_prompt_json_cli.py +++ b/tests/test_prompt_json_cli.py @@ -26,8 +26,37 @@ import pytest import yaml +# +# Copyright 2025 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This product includes software developed at Comcast (https://www.comcast.com/).# +import asyncio +import json +import logging +import subprocess +from io import StringIO +from pathlib import Path + +import pytest +import yaml + from openplate.__main__ import async_main from openplate.cfg import project_config +from openplate.project_template_identity import full_prompt_node_id, short_prompt_node_id from openplate.sources.url_source import UrlTemplateSource @@ -52,29 +81,14 @@ def _write_template_repo(repo_path: Path, template_yaml: str): return f"{repo_path.as_uri()}#main" -def _write_project_config(project_path: Path, source_url: str, dest_folder: str = "."): - project_path.mkdir(parents=True, exist_ok=True) - project_config_path = project_path / project_config.project_config_file_name - project_config_path.write_text( - yaml.safe_dump( - { - "templates": [ - { - "src_url": source_url, - "dest_folder": dest_folder, - "parameters": {}, - } - ], - "parameters": {}, - "template_file_cache": {}, - }, - sort_keys=False, - ), - encoding="utf-8", - ) +def _node_id(template: str, dest_folder: str = ".", full: bool = False): + full_id = full_prompt_node_id(template, dest_folder) + if full: + return full_id + return short_prompt_node_id(full_id) -def test_project_init_print_prompts_json_is_read_only_and_includes_conditional_sibling(tmp_path, capsys): +def test_project_print_init_json_compact_is_read_only_and_omits_info(tmp_path, capsys): repo_path = tmp_path / "template" source_url = _write_template_repo( repo_path, @@ -82,15 +96,6 @@ def test_project_init_print_prompts_json_is_read_only_and_includes_conditional_s parameters: - name: service_name description: Service Name - - name: include_api - description: Include API - default: "false" -require_sibling_templates: - - template_url: "{{ template_src_url }}" - dest_folder: "services/{{ project_folder_name }}/api" - condition: "{{ include_api }}" - parameters: - service_name: "{{ service_name }}" """, ) project_path = tmp_path / "project" @@ -103,42 +108,40 @@ def test_project_init_print_prompts_json_is_read_only_and_includes_conditional_s "project", "--project-folder", str(project_path), - "init", + "print-init-json", source_url, - "--dest-folder", - ".", - "--print-prompts-json", ] asyncio.run(async_main(args)) printed = json.loads(capsys.readouterr().out) assert not (project_path / project_config.project_config_file_name).exists() - - root_node = next(node for node in printed if node["template"] == source_url) - assert root_node["dest_folder"] == "." - assert root_node["parameters"]["service_name"]["value"] is None - assert root_node["parameters"]["service_name"]["required"] is True - assert root_node["parameters"]["include_api"]["default"] == "false" - assert root_node["parameters"]["include_api"]["required"] is False - - sibling_node = next(node for node in printed if node["template"] == "{{ template_src_url }}") - assert sibling_node["dest_folder"] == "services/{{ project_folder_name }}/api" - assert sibling_node["condition"] == "{{ include_api }}" + assert printed == [ + { + "node-id": _node_id(source_url, "."), + "answers": { + "service_name": None, + }, + } + ] -def test_project_init_print_prompts_json_uses_null_parameters_when_sibling_config_cannot_load(tmp_path, capsys): +def test_project_print_init_json_verbose_includes_caller_side_sibling_metadata(tmp_path, capsys): repo_path = tmp_path / "template" - sibling_source_url = f"{repo_path.as_uri()}?path=missing#main" source_url = _write_template_repo( repo_path, - "\n".join( - [ - "require_sibling_templates:", - f" - template_url: \"{sibling_source_url}\"", - " dest_folder: \"broken\"", - ] - ), + """ +parameters: + - name: service_name + description: Service Name + - name: include_api + description: Include API + default: "false" +require_sibling_templates: + - template_url: "{{ template_src_url }}" + dest_folder: "services/{{ project_folder_name }}/api" + condition: "{{ include_api }}" +""", ) project_path = tmp_path / "project" project_path.mkdir() @@ -150,21 +153,28 @@ def test_project_init_print_prompts_json_uses_null_parameters_when_sibling_confi "project", "--project-folder", str(project_path), - "init", + "print-init-json", source_url, - "--dest-folder", - ".", - "--print-prompts-json", + "--verbose", ] asyncio.run(async_main(args)) printed = json.loads(capsys.readouterr().out) - sibling_node = next(node for node in printed if node["template"] == sibling_source_url) - assert sibling_node["parameters"] is None + root_node = next(node for node in printed if node["info"]["template"] == source_url) + sibling_node = next(node for node in printed if node["info"]["template"] == "{{ template_src_url }}") + + assert root_node["info"]["require_sibling_templates"] == [ + { + "template": "{{ template_src_url }}", + "dest_folder": f"services/{project_path.name}/api", + "condition": "{{ include_api }}", + } + ] + assert "condition" not in sibling_node["info"] -def test_project_init_print_prompts_json_deduplicates_duplicate_discovered_templates(tmp_path, capsys): +def test_project_print_init_json_deduplicates_duplicate_discovered_templates(tmp_path, capsys): repo_path = tmp_path / "template" source_url = _write_template_repo( repo_path, @@ -186,21 +196,18 @@ def test_project_init_print_prompts_json_deduplicates_duplicate_discovered_templ "project", "--project-folder", str(project_path), - "init", + "print-init-json", source_url, - "--dest-folder", - ".", - "--print-prompts-json", ] asyncio.run(async_main(args)) printed = json.loads(capsys.readouterr().out) - shared_nodes = [node for node in printed if node["template"] == "{{ template_src_url }}" and node["dest_folder"] == "shared"] - assert len(shared_nodes) == 1 + assert len(printed) == 2 + assert len({node["node-id"] for node in printed}) == 2 -def test_project_init_print_prompts_json_excludes_hidden_parameters_without_ask_hidden(tmp_path, capsys): +def test_project_print_init_json_excludes_hidden_parameters_without_ask_hidden(tmp_path, capsys): repo_path = tmp_path / "template" source_url = _write_template_repo( repo_path, @@ -224,23 +231,17 @@ def test_project_init_print_prompts_json_excludes_hidden_parameters_without_ask_ "project", "--project-folder", str(project_path), - "init", + "print-init-json", source_url, - "--dest-folder", - ".", - "--print-prompts-json", ] asyncio.run(async_main(args)) printed = json.loads(capsys.readouterr().out) - root_node = next(node for node in printed if node["template"] == source_url) + assert printed[0]["answers"] == {"service_name": None} - assert "service_name" in root_node["parameters"] - assert "hidden_name" not in root_node["parameters"] - -def test_project_init_print_prompts_json_includes_hidden_parameters_with_ask_hidden(tmp_path, capsys): +def test_project_print_init_json_includes_hidden_parameters_with_ask_hidden(tmp_path, capsys): repo_path = tmp_path / "template" source_url = _write_template_repo( repo_path, @@ -265,143 +266,70 @@ def test_project_init_print_prompts_json_includes_hidden_parameters_with_ask_hid "--project-folder", str(project_path), "--ask-hidden", - "init", + "print-init-json", source_url, - "--dest-folder", - ".", - "--print-prompts-json", + "--verbose", ] asyncio.run(async_main(args)) printed = json.loads(capsys.readouterr().out) - root_node = next(node for node in printed if node["template"] == source_url) - - assert root_node["parameters"]["hidden_name"]["hidden"] is True + assert printed[0]["answers"]["hidden_name"] is None + assert printed[0]["info"]["parameters"]["hidden_name"]["hidden"] is True -def test_project_update_print_prompts_json_preserves_raw_sibling_identity_for_existing_template(tmp_path, capsys, monkeypatch): +def test_project_print_init_json_fails_when_sibling_metadata_cannot_be_resolved(tmp_path): repo_path = tmp_path / "template" + sibling_source_url = f"{repo_path.as_uri()}?path=missing#main" source_url = _write_template_repo( repo_path, - """ -parameters: - - name: include_api - description: Include API - default: "true" -require_sibling_templates: - - template_url: "{{ template_src_url }}" - dest_folder: "services/{{ project_folder_name }}/api" - condition: "{{ include_api }}" -""", - ) - project_path = tmp_path / "project" - project_path.mkdir() - - prompts_path = tmp_path / "prompts.json" - prompts_path.write_text( - json.dumps( + "\n".join( [ - { - "template": source_url, - "dest_folder": ".", - "parameters": { - "include_api": {"value": "true"}, - }, - } + "require_sibling_templates:", + f" - template_url: \"{sibling_source_url}\"", + " dest_folder: \"broken\"", ] ), - encoding="utf-8", ) + project_path = tmp_path / "project" + project_path.mkdir() - async def fake_walk_init(*_args, **_kwargs): - return [] - - async def fake_walk_update(*_args, **_kwargs): - return None - - monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_init", fake_walk_init) - monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_update", fake_walk_update) - - init_args = [ + args = [ "openplate", "-c", str(tmp_path / "missing-config.yaml"), "project", "--project-folder", str(project_path), - "init", + "print-init-json", source_url, - "--dest-folder", - ".", - "--prompts-json-file", - str(prompts_path), - ] - asyncio.run(async_main(init_args)) - capsys.readouterr() - - update_args = [ - "openplate", - "-c", - str(tmp_path / "missing-config.yaml"), - "project", - "--project-folder", - str(project_path), - "update", - "--print-prompts-json", ] - asyncio.run(async_main(update_args)) - printed = json.loads(capsys.readouterr().out) - sibling_nodes = [ - node for node in printed - if node["template"] == "{{ template_src_url }}" - and node["dest_folder"] == "services/{{ project_folder_name }}/api" - ] - - assert len(sibling_nodes) == 1 - assert sibling_nodes[0]["condition"] == "{{ include_api }}" + with pytest.raises(RuntimeError, match="Unable to resolve sibling declaration"): + asyncio.run(async_main(args)) -def test_project_update_warns_for_ignored_templates_and_unused_parameters(tmp_path, caplog, monkeypatch): +def test_project_print_init_json_reuses_single_source_fetch(tmp_path, monkeypatch, capsys): repo_path = tmp_path / "template" source_url = _write_template_repo( repo_path, """ -parameters: - - name: service_name - description: Service Name +require_sibling_templates: + - template_url: "{{ template_src_url }}" + dest_folder: "services/{{ project_folder_name }}/api" """, ) project_path = tmp_path / "project" - _write_project_config(project_path, source_url) - - async def fake_walk_update(*_args, **_kwargs): - return None + project_path.mkdir() + enter_count = 0 + original_enter = UrlTemplateSource.__enter__ - monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_update", fake_walk_update) + def counting_enter(self): + nonlocal enter_count + enter_count += 1 + return original_enter(self) - prompts_path = tmp_path / "prompts.json" - prompts_path.write_text( - json.dumps( - [ - { - "template": source_url, - "dest_folder": ".", - "parameters": { - "service_name": {"value": "demo"}, - "unused": {"value": "leftover"}, - }, - }, - { - "template": "unused-template#main", - "dest_folder": "ignored", - "parameters": {}, - }, - ] - ), - encoding="utf-8", - ) + monkeypatch.setattr(UrlTemplateSource, "__enter__", counting_enter) args = [ "openplate", @@ -410,19 +338,17 @@ async def fake_walk_update(*_args, **_kwargs): "project", "--project-folder", str(project_path), - "update", - "--prompts-json-file", - str(prompts_path), + "print-init-json", + source_url, ] - with caplog.at_level(logging.WARNING): - asyncio.run(async_main(args)) + asyncio.run(async_main(args)) - assert any("Ignoring unused supplied prompt parameter" in record.message for record in caplog.records) - assert any("Ignoring supplied prompt template because it was not processed" in record.message for record in caplog.records) + json.loads(capsys.readouterr().out) + assert enter_count == 1 -def test_project_init_accepts_blank_string_prompt_value(tmp_path): +def test_project_init_accepts_prompts_json_file_with_node_ids(tmp_path): repo_path = tmp_path / "template" source_url = _write_template_repo( repo_path, @@ -434,16 +360,14 @@ def test_project_init_accepts_blank_string_prompt_value(tmp_path): ) project_path = tmp_path / "project" project_path.mkdir() - prompts_path = tmp_path / "prompts.json" prompts_path.write_text( json.dumps( [ { - "template": source_url, - "dest_folder": ".", - "parameters": { - "service_name": {"value": ""}, + "node-id": _node_id(source_url, "."), + "answers": { + "service_name": "demo", }, } ] @@ -469,7 +393,7 @@ def test_project_init_accepts_blank_string_prompt_value(tmp_path): asyncio.run(async_main(args)) written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) - assert written_config["templates"][0]["parameters"]["service_name"] == "" + assert written_config["templates"][0]["parameters"]["service_name"] == "demo" def test_project_init_accepts_prompts_json_from_stdin(tmp_path, monkeypatch): @@ -491,10 +415,9 @@ def test_project_init_accepts_prompts_json_from_stdin(tmp_path, monkeypatch): json.dumps( [ { - "template": source_url, - "dest_folder": ".", - "parameters": { - "service_name": {"value": "stdin-demo"}, + "node-id": _node_id(source_url, "."), + "answers": { + "service_name": "stdin-demo", }, } ] @@ -522,7 +445,7 @@ def test_project_init_accepts_prompts_json_from_stdin(tmp_path, monkeypatch): assert written_config["templates"][0]["parameters"]["service_name"] == "stdin-demo" -def test_project_init_uses_default_value_in_json_mode_without_prompting(tmp_path): +def test_project_init_accepts_blank_string_prompt_value(tmp_path): repo_path = tmp_path / "template" source_url = _write_template_repo( repo_path, @@ -530,20 +453,19 @@ def test_project_init_uses_default_value_in_json_mode_without_prompting(tmp_path parameters: - name: service_name description: Service Name - default: demo """, ) project_path = tmp_path / "project" project_path.mkdir() - prompts_path = tmp_path / "prompts.json" prompts_path.write_text( json.dumps( [ { - "template": source_url, - "dest_folder": ".", - "parameters": None + "node-id": _node_id(source_url, "."), + "answers": { + "service_name": "", + }, } ] ), @@ -568,68 +490,41 @@ def test_project_init_uses_default_value_in_json_mode_without_prompting(tmp_path asyncio.run(async_main(args)) written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) - assert written_config["templates"][0]["parameters"]["service_name"] == "demo" + assert written_config["templates"][0]["parameters"]["service_name"] == "" -def test_project_init_json_mode_uses_supplied_value_for_existing_sibling_parameter(tmp_path, caplog, monkeypatch): - child_repo_path = tmp_path / "child-template" - child_source_url = _write_template_repo( - child_repo_path, +def test_project_init_ignores_info_metadata_on_import(tmp_path): + repo_path = tmp_path / "template" + source_url = _write_template_repo( + repo_path, """ -parameters: - - name: artifact_name - description: Artifact Name -""", - ) - root_repo_path = tmp_path / "root-template" - root_source_url = _write_template_repo( - root_repo_path, - f""" parameters: - name: service_name description: Service Name -require_sibling_templates: - - template_url: "{child_source_url}" - dest_folder: "child" - parameters: - artifact_name: "{{{{ service_name }}}}" """, ) project_path = tmp_path / "project" project_path.mkdir() - prompts_path = tmp_path / "prompts.json" prompts_path.write_text( json.dumps( [ { - "template": root_source_url, - "dest_folder": ".", - "parameters": { - "service_name": {"value": "demo"}, + "node-id": _node_id(source_url, "."), + "answers": { + "service_name": "demo", }, - }, - { - "template": child_source_url, - "dest_folder": "child", - "parameters": { - "artifact_name": {"value": "override"}, + "info": { + "template": "mutated-template", + "dest_folder": "mutated-folder", + "parameters": {}, }, - }, + } ] ), encoding="utf-8", ) - async def fake_walk_init(*_args, **_kwargs): - return [] - - async def fake_walk_update(*_args, **_kwargs): - return None - - monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_init", fake_walk_init) - monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_update", fake_walk_update) - args = [ "openplate", "-c", @@ -638,57 +533,57 @@ async def fake_walk_update(*_args, **_kwargs): "--project-folder", str(project_path), "init", - root_source_url, + source_url, "--dest-folder", ".", "--prompts-json-file", str(prompts_path), ] - with caplog.at_level(logging.WARNING): - asyncio.run(async_main(args)) + asyncio.run(async_main(args)) written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) - child_template = next( - template - for template in written_config["templates"] - if template["src_url"] == child_source_url and template["dest_folder"] == "child" - ) - - assert child_template["parameters"]["artifact_name"] == "override" - assert not any( - "Ignoring unused supplied prompt parameter" in record.message and "artifact_name" in record.message - for record in caplog.records - ) + assert written_config["templates"][0]["parameters"]["service_name"] == "demo" -def test_project_init_json_mode_ignores_hidden_value_without_ask_hidden(tmp_path, caplog): +def test_project_init_rejects_non_array_prompt_document(tmp_path): repo_path = tmp_path / "template" - source_url = _write_template_repo( - repo_path, - """ -parameters: - - name: service_name - description: Service Name - default: demo - - name: hidden_name - description: Hidden Name - default: secret - hidden: true -""", - ) + source_url = _write_template_repo(repo_path, "version: 1\n") project_path = tmp_path / "project" project_path.mkdir() + prompts_path = tmp_path / "prompts.json" + prompts_path.write_text("{}", encoding="utf-8") + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + source_url, + "--prompts-json-file", + str(prompts_path), + ] + + with pytest.raises(TypeError, match="Prompt document must be a JSON array"): + asyncio.run(async_main(args)) + +def test_project_init_rejects_invalid_answer_value_type(tmp_path): + repo_path = tmp_path / "template" + source_url = _write_template_repo(repo_path, "version: 1\n") + project_path = tmp_path / "project" + project_path.mkdir() prompts_path = tmp_path / "prompts.json" prompts_path.write_text( json.dumps( [ { - "template": source_url, - "dest_folder": ".", - "parameters": { - "hidden_name": {"value": "override"}, + "node-id": _node_id(source_url, "."), + "answers": { + "service_name": 123, }, } ] @@ -705,24 +600,15 @@ def test_project_init_json_mode_ignores_hidden_value_without_ask_hidden(tmp_path str(project_path), "init", source_url, - "--dest-folder", - ".", "--prompts-json-file", str(prompts_path), ] - with caplog.at_level(logging.WARNING): + with pytest.raises(TypeError, match="must be a string or null"): asyncio.run(async_main(args)) - written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) - assert written_config["templates"][0]["parameters"]["hidden_name"] == "secret" - assert any( - "Ignoring unused supplied prompt parameter" in record.message and "hidden_name" in record.message - for record in caplog.records - ) - -def test_project_init_json_mode_uses_hidden_value_with_ask_hidden(tmp_path): +def test_project_init_uses_default_value_when_answer_is_null(tmp_path): repo_path = tmp_path / "template" source_url = _write_template_repo( repo_path, @@ -731,24 +617,18 @@ def test_project_init_json_mode_uses_hidden_value_with_ask_hidden(tmp_path): - name: service_name description: Service Name default: demo - - name: hidden_name - description: Hidden Name - default: secret - hidden: true """, ) project_path = tmp_path / "project" project_path.mkdir() - prompts_path = tmp_path / "prompts.json" prompts_path.write_text( json.dumps( [ { - "template": source_url, - "dest_folder": ".", - "parameters": { - "hidden_name": {"value": "override"}, + "node-id": _node_id(source_url, "."), + "answers": { + "service_name": None, }, } ] @@ -763,11 +643,8 @@ def test_project_init_json_mode_uses_hidden_value_with_ask_hidden(tmp_path): "project", "--project-folder", str(project_path), - "--ask-hidden", "init", source_url, - "--dest-folder", - ".", "--prompts-json-file", str(prompts_path), ] @@ -775,7 +652,7 @@ def test_project_init_json_mode_uses_hidden_value_with_ask_hidden(tmp_path): asyncio.run(async_main(args)) written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) - assert written_config["templates"][0]["parameters"]["hidden_name"] == "override" + assert written_config["templates"][0]["parameters"]["service_name"] == "demo" def test_project_init_fails_when_required_value_remains_unresolved_in_json_mode(tmp_path): @@ -790,16 +667,14 @@ def test_project_init_fails_when_required_value_remains_unresolved_in_json_mode( ) project_path = tmp_path / "project" project_path.mkdir() - prompts_path = tmp_path / "prompts.json" prompts_path.write_text( json.dumps( [ { - "template": source_url, - "dest_folder": ".", - "parameters": { - "service_name": {"value": None}, + "node-id": _node_id(source_url, "."), + "answers": { + "service_name": None, }, } ] @@ -816,8 +691,6 @@ def test_project_init_fails_when_required_value_remains_unresolved_in_json_mode( str(project_path), "init", source_url, - "--dest-folder", - ".", "--prompts-json-file", str(prompts_path), ] @@ -826,20 +699,34 @@ def test_project_init_fails_when_required_value_remains_unresolved_in_json_mode( asyncio.run(async_main(args)) -def test_project_init_fails_template_command_confirmation_in_json_mode(tmp_path): +def test_project_init_json_mode_ignores_hidden_value_without_ask_hidden(tmp_path, caplog): repo_path = tmp_path / "template" source_url = _write_template_repo( repo_path, """ -init_commands: - - command: echo setup +parameters: + - name: hidden_name + description: Hidden Name + default: secret + hidden: true """, ) project_path = tmp_path / "project" project_path.mkdir() - prompts_path = tmp_path / "prompts.json" - prompts_path.write_text("[]", encoding="utf-8") + prompts_path.write_text( + json.dumps( + [ + { + "node-id": _node_id(source_url, "."), + "answers": { + "hidden_name": "override", + }, + } + ] + ), + encoding="utf-8", + ) args = [ "openplate", @@ -850,39 +737,122 @@ def test_project_init_fails_template_command_confirmation_in_json_mode(tmp_path) str(project_path), "init", source_url, - "--dest-folder", - ".", "--prompts-json-file", str(prompts_path), ] - with pytest.raises(SystemExit) as ex: + with caplog.at_level(logging.WARNING): asyncio.run(async_main(args)) - assert ex.value.code == 1 + written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) + assert written_config["templates"][0]["parameters"]["hidden_name"] == "secret" + assert any("Ignoring unused supplied prompt parameter" in record.message for record in caplog.records) -def test_project_init_print_prompts_json_reuses_single_source_fetch(tmp_path, monkeypatch, capsys): +def test_project_init_json_mode_uses_hidden_value_with_ask_hidden(tmp_path): repo_path = tmp_path / "template" source_url = _write_template_repo( repo_path, """ +parameters: + - name: hidden_name + description: Hidden Name + default: secret + hidden: true +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + prompts_path = tmp_path / "prompts.json" + prompts_path.write_text( + json.dumps( + [ + { + "node-id": _node_id(source_url, "."), + "answers": { + "hidden_name": "override", + }, + } + ] + ), + encoding="utf-8", + ) + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "--ask-hidden", + "init", + source_url, + "--prompts-json-file", + str(prompts_path), + ] + + asyncio.run(async_main(args)) + + written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) + assert written_config["templates"][0]["parameters"]["hidden_name"] == "override" + + +def test_project_init_json_mode_uses_supplied_value_for_existing_sibling_parameter(tmp_path, caplog, monkeypatch): + child_repo_path = tmp_path / "child-template" + child_source_url = _write_template_repo( + child_repo_path, + """ +parameters: + - name: artifact_name + description: Artifact Name +""", + ) + root_repo_path = tmp_path / "root-template" + root_source_url = _write_template_repo( + root_repo_path, + f""" +parameters: + - name: service_name + description: Service Name require_sibling_templates: - - template_url: "{{ template_src_url }}" - dest_folder: "services/{{ project_folder_name }}/api" + - template_url: "{child_source_url}" + dest_folder: "child" + parameters: + artifact_name: "{{{{ service_name }}}}" """, ) project_path = tmp_path / "project" project_path.mkdir() - enter_count = 0 - original_enter = UrlTemplateSource.__enter__ + prompts_path = tmp_path / "prompts.json" + prompts_path.write_text( + json.dumps( + [ + { + "node-id": _node_id(root_source_url, "."), + "answers": { + "service_name": "demo", + }, + }, + { + "node-id": _node_id(child_source_url, "child"), + "answers": { + "artifact_name": "override", + }, + }, + ] + ), + encoding="utf-8", + ) - def counting_enter(self): - nonlocal enter_count - enter_count += 1 - return original_enter(self) + async def fake_walk_init(*_args, **_kwargs): + return [] - monkeypatch.setattr(UrlTemplateSource, "__enter__", counting_enter) + async def fake_walk_update(*_args, **_kwargs): + return None + + monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_init", fake_walk_init) + monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_update", fake_walk_update) args = [ "openplate", @@ -892,13 +862,18 @@ def counting_enter(self): "--project-folder", str(project_path), "init", - source_url, - "--dest-folder", - ".", - "--print-prompts-json", + root_source_url, + "--prompts-json-file", + str(prompts_path), ] - asyncio.run(async_main(args)) + with caplog.at_level(logging.WARNING): + asyncio.run(async_main(args)) - capsys.readouterr() - assert enter_count == 1 \ No newline at end of file + written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) + child_templates = [ + template for template in written_config["templates"] + if template["src_url"] == child_source_url and template["dest_folder"] == "child" + ] + assert len(child_templates) == 1 + assert child_templates[0]["parameters"]["artifact_name"] == "override" From 13c6bf4b3b78005204e8a6063fc5a7f8d20f8c21 Mon Sep 17 00:00:00 2001 From: jasonsky-pub <275120179+jasonsky-pub@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:17:07 -0400 Subject: [PATCH 6/8] Add UTF-8 encoding to file operations in update_walker and verify_walker --- src/openplate/walk/update_walker.py | 4 ++-- src/openplate/walk/verify_walker.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/openplate/walk/update_walker.py b/src/openplate/walk/update_walker.py index 2d8be6a..bfead54 100644 --- a/src/openplate/walk/update_walker.py +++ b/src/openplate/walk/update_walker.py @@ -138,7 +138,7 @@ async def handle_file( if not is_replacement: shutil.copyfile(template_path, project_path) else: - with open(template_path) as stream: + with open(template_path, encoding="utf-8") as stream: file_data = stream.read() template_dir = os.path.dirname(template_path) @@ -155,7 +155,7 @@ async def handle_file( self._config_template.override_statement_end ) - with open(project_path, "w") as stream: + with open(project_path, "w", encoding="utf-8") as stream: stream.write(new_data) if should_be_readonly: diff --git a/src/openplate/walk/verify_walker.py b/src/openplate/walk/verify_walker.py index 573c6fe..088c73c 100644 --- a/src/openplate/walk/verify_walker.py +++ b/src/openplate/walk/verify_walker.py @@ -123,7 +123,7 @@ async def handle_file( if not binary_files_equal(template_path, project_path): up_to_date = False else: - with open(template_path) as stream: + with open(template_path, encoding="utf-8") as stream: file_data = stream.read() template_dir = os.path.dirname(template_path) @@ -140,7 +140,7 @@ async def handle_file( self._config_template.override_statement_end ) - with open(project_path) as file: + with open(project_path, encoding="utf-8") as file: existing_contents = file.read() if processed_contents != existing_contents: From 0da7c50e703e0f433a67615797850c4c5f423696 Mon Sep 17 00:00:00 2001 From: jasonsky-pub <275120179+jasonsky-pub@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:59:59 -0400 Subject: [PATCH 7/8] Fix prompt JSON sibling node identity --- src/openplate/project_template_identity.py | 6 +- src/openplate/prompts/prompt_document.py | 6 +- .../prompts/prompt_document_collector.py | 4 +- tests/test_prompt_json_cli.py | 82 +++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/openplate/project_template_identity.py b/src/openplate/project_template_identity.py index f92a60b..18fd8dd 100644 --- a/src/openplate/project_template_identity.py +++ b/src/openplate/project_template_identity.py @@ -60,10 +60,14 @@ def prompt_dest_folder(config_project_template): return normalize_prompt_dest_folder(config_project_template.dest_folder) +def prompt_identity_dest_folder(config_project_template): + return normalize_prompt_dest_folder(config_project_template.raw_dest_folder) + + def prompt_node_id(config_project_template) -> str: return full_prompt_node_id( prompt_template_reference(config_project_template), - prompt_dest_folder(config_project_template), + prompt_identity_dest_folder(config_project_template), ) diff --git a/src/openplate/prompts/prompt_document.py b/src/openplate/prompts/prompt_document.py index 7ff049c..ea307f4 100644 --- a/src/openplate/prompts/prompt_document.py +++ b/src/openplate/prompts/prompt_document.py @@ -299,9 +299,13 @@ def add_template( dest_folder: Optional[str], parameters: Optional[dict[str, PromptParameterValue]], require_sibling_templates: Optional[list[PromptSiblingTemplateInfo]] = None, + identity_dest_folder: Optional[str] = None, ): normalized_dest_folder = normalize_prompt_dest_folder(dest_folder) - full_node_id = full_prompt_node_id(template, normalized_dest_folder) + normalized_identity_dest_folder = normalize_prompt_dest_folder( + identity_dest_folder if identity_dest_folder is not None else dest_folder + ) + full_node_id = full_prompt_node_id(template, normalized_identity_dest_folder) existing_node = self._nodes_by_full_id.get(full_node_id) if existing_node is None: preferred_short_id = short_prompt_node_id(full_node_id) diff --git a/src/openplate/prompts/prompt_document_collector.py b/src/openplate/prompts/prompt_document_collector.py index f78044f..7342f26 100644 --- a/src/openplate/prompts/prompt_document_collector.py +++ b/src/openplate/prompts/prompt_document_collector.py @@ -24,7 +24,7 @@ from openplate.cfg.open_plate_settings import OpenPlateRuntimeSettings, OpenPlateSettings from openplate.cfg.project_config import ProjectTemplateConfig from openplate.project_metadata_resolver import resolve_project_metadata -from openplate.project_template_identity import prompt_dest_folder, prompt_template_reference, source_cache_key +from openplate.project_template_identity import prompt_dest_folder, prompt_identity_dest_folder, prompt_template_reference, source_cache_key from openplate.prompts.prompt_document import PromptDocument, PromptDocumentBuilder, PromptSiblingTemplateInfo from openplate.prompts.prompt_parameter_resolver import describe_prompt_parameters from openplate.sibling_template_resolver import copy_template_with_raw_identity, find_matching_template, render_sibling_template_config @@ -101,6 +101,7 @@ async def _collect_prompt_document_template( prompt_dest_folder(config_project_template), parameters, None, + prompt_identity_dest_folder(config_project_template), ) return @@ -161,6 +162,7 @@ async def _collect_prompt_document_template( prompt_dest_folder(config_project_template), parameters, sibling_declarations, + prompt_identity_dest_folder(config_project_template), ) diff --git a/tests/test_prompt_json_cli.py b/tests/test_prompt_json_cli.py index f5e47b8..40ab29a 100644 --- a/tests/test_prompt_json_cli.py +++ b/tests/test_prompt_json_cli.py @@ -877,3 +877,85 @@ async def fake_walk_update(*_args, **_kwargs): ] assert len(child_templates) == 1 assert child_templates[0]["parameters"]["artifact_name"] == "override" + + +def test_project_init_json_mode_uses_raw_dest_identity_for_dynamic_sibling_dest(tmp_path, caplog, monkeypatch): + child_repo_path = tmp_path / "child-template" + child_source_url = _write_template_repo( + child_repo_path, + """ +parameters: + - name: artifact_name + description: Artifact Name +""", + ) + root_repo_path = tmp_path / "root-template" + root_source_url = _write_template_repo( + root_repo_path, + f""" +parameters: + - name: service_name + description: Service Name +require_sibling_templates: + - template_url: "{child_source_url}" + dest_folder: "child/{{{{ service_name }}}}" + parameters: + artifact_name: "{{{{ service_name }}}}" +""", + ) + project_path = tmp_path / "project" + project_path.mkdir() + prompts_path = tmp_path / "prompts.json" + prompts_path.write_text( + json.dumps( + [ + { + "node-id": _node_id(root_source_url, "."), + "answers": { + "service_name": "demo", + }, + }, + { + "node-id": _node_id(child_source_url, "child/{{ service_name }}"), + "answers": { + "artifact_name": "override", + }, + }, + ] + ), + encoding="utf-8", + ) + + async def fake_walk_init(*_args, **_kwargs): + return [] + + async def fake_walk_update(*_args, **_kwargs): + return None + + monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_init", fake_walk_init) + monkeypatch.setattr("openplate.walk.source_template_recursive_walk.walk_update", fake_walk_update) + + args = [ + "openplate", + "-c", + str(tmp_path / "missing-config.yaml"), + "project", + "--project-folder", + str(project_path), + "init", + root_source_url, + "--prompts-json-file", + str(prompts_path), + ] + + with caplog.at_level(logging.WARNING): + asyncio.run(async_main(args)) + + written_config = yaml.safe_load((project_path / project_config.project_config_file_name).read_text(encoding="utf-8")) + child_templates = [ + template for template in written_config["templates"] + if template["src_url"] == child_source_url and template["dest_folder"] == "child/demo" + ] + assert len(child_templates) == 1 + assert child_templates[0]["parameters"]["artifact_name"] == "override" + assert "Ignoring supplied prompt template because it was not processed" not in caplog.text From 65bcb8f347a0d42d7fbc77f6cbc5daee81b6f5d5 Mon Sep 17 00:00:00 2001 From: jasonsky-pub <275120179+jasonsky-pub@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:14:55 -0400 Subject: [PATCH 8/8] Fix rebased prompt JSON compatibility --- src/openplate/__main__.py | 2 +- src/openplate/commands/project_init.py | 4 +++- src/openplate/commands/project_update.py | 4 ++++ src/openplate/project_config_resolver.py | 22 +++++++++++----------- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/openplate/__main__.py b/src/openplate/__main__.py index f5ff36d..63ce649 100644 --- a/src/openplate/__main__.py +++ b/src/openplate/__main__.py @@ -281,7 +281,7 @@ async def async_main(args): absolute_project_folder, result.overwrite, result.allow_template_commands, - prompt_document, + prompt_document=prompt_document, ) await project_init.run(configuration, runtime_settings, options) elif result.command == "project-print-init-json": diff --git a/src/openplate/commands/project_init.py b/src/openplate/commands/project_init.py index a12d187..97e86e9 100644 --- a/src/openplate/commands/project_init.py +++ b/src/openplate/commands/project_init.py @@ -34,7 +34,8 @@ def __init__( destination: str, overwrite_existing_files: bool, allow_template_commands: bool, - prompt_document: Optional[PromptDocument], + print_prompts_json: bool = False, + prompt_document: Optional[PromptDocument] = None, ): if add_template is None: raise TypeError @@ -44,6 +45,7 @@ def __init__( self.destination = destination self.overwrite_existing_files = overwrite_existing_files or False self.allow_template_commands = allow_template_commands or False + self.print_prompts_json = print_prompts_json or False self.prompt_document = prompt_document diff --git a/src/openplate/commands/project_update.py b/src/openplate/commands/project_update.py index 1dc1660..11fb70a 100644 --- a/src/openplate/commands/project_update.py +++ b/src/openplate/commands/project_update.py @@ -34,12 +34,16 @@ def __init__( destination: str, create_non_template_files: bool, update_non_template_files: bool, + print_prompts_json: bool = False, + prompt_document: Optional[PromptDocument] = None, ): if destination is None: raise TypeError self.destination = destination self.create_non_template_files = create_non_template_files self.update_non_template_files = update_non_template_files + self.print_prompts_json = print_prompts_json or False + self.prompt_document = prompt_document async def run( diff --git a/src/openplate/project_config_resolver.py b/src/openplate/project_config_resolver.py index c5fd869..cd3be27 100644 --- a/src/openplate/project_config_resolver.py +++ b/src/openplate/project_config_resolver.py @@ -107,17 +107,6 @@ def resolve_parameter( logging.debug(f"not prompting for hidden parameter[{parameter.name}]") return False, fallback_value - resolved_answer = try_resolve_parameter_without_prompt( - config_project_template, - parameter, - existing_value, - fallback_value if existing_value is None else None, - fail_on_prompt, - prompt_input_tracker, - ) - if resolved_answer is not None: - return resolved_answer - effective_hidden = resolve_parameter_hidden_state( config_template, config_project, @@ -133,6 +122,17 @@ def resolve_parameter( logging.debug(f"not prompting for hidden parameter[{parameter.name}]") return False, fallback_value + resolved_answer = try_resolve_parameter_without_prompt( + config_project_template, + parameter, + existing_value, + fallback_value if existing_value is None else None, + fail_on_prompt, + prompt_input_tracker, + ) + if resolved_answer is not None: + return resolved_answer + # Auto answer case, already answered and not re-asking: if not runtime_settings.ask_again and key_exists: logging.debug(f"not re-prompting for already answered parameter[{parameter.name}]")