diff --git a/.coverage-config-cli b/.coverage-config-cli new file mode 100644 index 0000000..2f10cdd --- /dev/null +++ b/.coverage-config-cli @@ -0,0 +1,17 @@ +[run] +branch = True +source_pkgs = + moldflow_cli + +[paths] +source = + src/moldflow_cli + */site-packages/moldflow_cli + +[report] +fail_under = 80 +show_missing = True +precision = 2 + +[html] +title = Moldflow CLI Unit Test Coverage diff --git a/.gitignore b/.gitignore index 9e71a9b..e40e275 100644 --- a/.gitignore +++ b/.gitignore @@ -180,3 +180,4 @@ Thumbs.db # Internal Autodesk directories .adsk/ +demo_models diff --git a/CHANGELOG.md b/CHANGELOG.md index dae322a..829e55c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - N/A +## [27.1.0] - 2026-08-14 + +### Added +- Added `transparent_background` property to `ImageExportOptions` — when set to `True` with a `.png` output path, the exported image uses a transparent background instead of the renderer's opaque fill colour. Defaults to `False`; silently ignored for non-PNG formats. +- Added a Moldflow CLI with `list`, `describe`, and `invoke` commands. +- Added an interactive REPL for the Moldflow CLI. +- Added invoke planning and automation support, including dry-run plans, trace output, and batch execution. +- Added machine-readable `describe` schema output and updated CLI documentation and examples. +- Added CLI regression tests covering chaining, argument routing, and edge-case input handling. + +### Changed +- N/A + +### Deprecated +- N/A + +### Removed +- N/A + +### Fixed +- N/A + +### Security +- N/A + ## [27.0.1] - 2026-04-18 ### Added @@ -190,7 +215,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial version aligned with Moldflow Synergy 2026.0.1 - Python 3.10-3.13 compatibility -[Unreleased]: https://github.com/Autodesk/moldflow-api/compare/v27.0.1...HEAD +[Unreleased]: https://github.com/Autodesk/moldflow-api/compare/v27.1.0...HEAD +[27.1.0]: https://github.com/Autodesk/moldflow-api/compare/v27.0.1...v27.1.0 [27.0.1]: https://github.com/Autodesk/moldflow-api/releases/tag/v27.0.1 [27.0.0]: https://github.com/Autodesk/moldflow-api/releases/tag/v27.0.0 [26.0.5]: https://github.com/Autodesk/moldflow-api/releases/tag/v26.0.5 diff --git a/README.md b/README.md index cf28445..2835e6a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,16 @@ Before you begin, ensure you have: python -m pip install moldflow ``` +### Install with CLI support + +To install the package together with the optional command-line interface: + +```sh +python -m pip install "moldflow[cli]" +``` + +After installation a `moldflow` command will be available on your `PATH`. + ## Quick Start ```python @@ -34,6 +44,265 @@ print(f"Moldflow Synergy version: {version}") See the [full documentation](https://autodesk.github.io/moldflow-api) for more in-depth examples. +## Command Line Interface (CLI) + +The optional CLI provides a `moldflow` command for driving Synergy operations from a shell. + +### Basic usage + +```sh +moldflow --help +moldflow list +moldflow list --json --with-describe --max-results 25 +moldflow describe synergy.open_project +moldflow describe synergy.new_project synergy.open_project +moldflow list --filter new_proj --filter "*_diag" +``` + +The top-level help now guides first-time users through the intended workflow: +start with `list` to discover targets, use `describe ` to inspect +usage, then run `invoke ...`. + +`describe` now shows the preferred and minimal `invoke` forms, plus the +corresponding `--params-json` shapes when the target is invokable, so discovery +and execution use the same examples without leaking Python-only `self`/`cls` +receiver details. + +`describe` also accepts multiple targets in one command. In human mode it renders +one block per target; in structured `--json`, `--yaml`, and `--schema` modes it +returns a single object for one target or a list for multiple targets. + +`list` now includes readable properties and read/write properties as well as methods, +so property targets are discoverable from the main index too. In human mode it also +surfaces each target's kind and a sensible next step. When the CLI can map a +wrapper back to a Synergy property or `create_*` factory, the listed target is +shown in the same Synergy-rooted form that `invoke` accepts. + +`list --filter` can be repeated, and repeated filters are additive: a target is +included when it matches any provided filter value. + +In structured mode, `list --json` and `list --yaml` are described as scripting +and agent-oriented outputs. `list --with-describe` embeds the structured +`describe` payload for each listed target, and `--max-results` lets callers cap +the result set after filtering. + +### Interactive REPL + +Start an interactive shell session with tab completion and built-in session +commands: + +```sh +moldflow repl +``` + +Inside the REPL you can run any CLI command without the `moldflow` prefix: + +``` +moldflow> list +moldflow> describe synergy.open_project +moldflow> invoke synergy.new_project name="My Project" path="C:/mf/MyProject.mfproj" +``` + +Built-in session commands: + +| Command | Description | +|------------------|-------------------------------------------| +| `help` | Show available commands | +| `help ` | Show detailed help for a specific command | +| `clear` | Clear the screen | +| `reset` | Reset the Synergy session | +| `exit` / `quit` | Exit the REPL (Ctrl+D also works) | + +Tab completion is available for all commands and for invokable targets when +using `describe` or `invoke`. Pass `--debug` to show full tracebacks on errors: + +```sh +moldflow repl --debug +``` + +### Invoking methods + +You can invoke methods directly: + +```sh +moldflow invoke synergy.new_project name="My Project" path="C:/mf/MyProject.mfproj" +moldflow invoke synergy.import_file file="C:/models/part.iges" show_logs=true +``` + +Non-primitive parameters (such as `ImportOptions`) can be configured using dotted arguments: + +```sh +moldflow invoke synergy.import_file \ + file="C:/models/part.iges" \ + import_options.use_mdl=true \ + import_options.units=Millimeter +``` + +For more complex operations you can chain calls through the object model, for example: + +```sh +moldflow invoke synergy.plot_manager.find_plot_by_name.get_probe_plot_probe_line \ + find_plot_by_name.plot_name="My Plot" \ + get_probe_plot_probe_line.index=0 \ + get_probe_plot_probe_line.start_pt.x=0 \ + get_probe_plot_probe_line.start_pt.y=0 \ + get_probe_plot_probe_line.start_pt.z=0 \ + get_probe_plot_probe_line.end_pt.x=10 \ + get_probe_plot_probe_line.end_pt.y=0 \ + get_probe_plot_probe_line.end_pt.z=0 +``` + +For automation or LLM-based tooling, you can request JSON output with `--json`: + +```sh +moldflow invoke synergy.boundary_conditions.create_ndbc ... --json +``` + +You can provide parameters as JSON using `--params-json` or `--params-json-file` (`-J`). +For chained targets, group parameters by step name. For single-step targets, either +top-level parameters or an optional step-name wrapper object are accepted: + +```sh +moldflow invoke synergy.plot_manager.find_plot_by_name --params-json \ + '{"find_plot_by_name":{"plot_name":"My Plot"}}' +``` + +For wrapper parameters, prefer the direct parameter form in non-JSON mode. For a +real public target such as `synergy.boundary_conditions.create_edge_loads`, that means: + +```sh +moldflow invoke synergy.boundary_conditions.create_edge_loads \ + nodes=N1,N2 \ + force=0,0,-100 +``` + +The JSON form uses the wrapper-native fields shown by `describe`: + +```json +{ + "nodes": {"entity_string": "N1,N2"}, + "force": {"xyz": [0.0, 0.0, -100.0]} +} +``` + +Array-like wrappers follow the same pattern, for example +`{"value": {"values": [1.0, 2.5]}}` when a target has +a `DoubleArray` parameter named `value`, or `{"points": {"xyz": [[0, 0, 0], [1, 0, 0]]}}` +for a `VectorArray` parameter named `points`. + +The direct `param=value` form is the preferred non-JSON syntax. The explicit dotted +form is mostly an escape hatch for tooling or debugging; when you need it, use the +wrapper-native field name shown by `describe`, for example `nodes.entity_string=...` +or `force.xyz=...`. List-backed wrappers now also accept shorthand such as +`levels=1.0,2.5`, and vector-array wrappers accept `points="0,0,0;1,0,0"`. +If shorthand input becomes ambiguous or hard to escape, prefer `--params-json`. + +Advanced fallback only: tagged objects with `__type__` are still accepted for generic +or annotation-free JSON payloads, but they are intentionally not part of the normal +customer-facing path for annotated parameters. If the CLI already has wrapper context, +such as a typed parameter or an existing nested wrapper-valued property, the untagged +wrapper-native JSON form is preferred. + +Advanced invoke modes: + +- `--dry-run`: parse/validate/build a call plan without executing invoke steps. +- `--trace`: emit JSON trace events for planning/runtime deferred binding. +- `--batch-file`: execute multiple invoke calls from a JSON array file. + +For terminal users, `describe`, `--dry-run`, and `--batch-file` now render +human-readable summaries by default. Add `--json` when you want the structured +machine contract on stdout. `--json-file-output` writes the JSON contract to a +file without changing stdout mode, so terminal users can keep the human summary +unless they also ask for `--json`. Human-mode output also confirms where the +structured payload was written. + +`--trace` emits line-delimited JSON to stderr, one object per event, with +`schema_version`, `sequence`, `event`, `target`, and `payload`, plus `step` or +`property` when relevant. +Result and error events are emitted explicitly, and batch runs add `batch_index` +so trace consumers can correlate per-item activity without inferring it from order. + +Batch file shape example: + +```json +[ + {"target": "synergy.open_project", "args": ["path=C:/tmp/a.mfproj"]}, + {"target": "synergy.import_file", "params_json": {"file": "C:/tmp/part.iges"}} +] +``` + +Run: + +```sh +moldflow invoke --batch-file C:/tmp/invoke_batch.json +``` + +To persist machine-readable output, use `--json-file-output`. Add `--json` as well +when you also want the structured payload on stdout. + +Batch output now includes a `summary` block and each `batch_results` entry echoes a +normalized `request` payload plus an `error_type` when validation or business logic fails. + +`workflow_examples` now carries both fuller `preferred_*` examples and leaner +`minimal_*` examples so tooling and humans can choose between a representative +workflow call and the smallest valid call shape. Human template and dry-run +summaries surface the preferred and minimal `params-json` examples too, not just +the command lines. + +Common wrapper types such as `EntList`, `Vector`, `DoubleArray`, `IntegerArray`, +`StringArray`, `VectorArray`, and `Property` are converted to structured JSON +objects describing their contents. Structured object-like JSON responses include +`schema_version` to make automation parsing contracts explicit. + +Input validation and escaping +----------------------------- + +The CLI performs conservative validation to protect against malformed string input: + +- The CLI rejects null bytes and embedded control characters (newlines, tabs, carriage returns) in any string parameter. +- Shell metacharacters are treated as normal literal characters in parameter values. +- For JSON-derived parameters (``--params-json``/``--params-json-file``), shell metacharacter checks are not applied; only null bytes are rejected. +- The CLI does not perform path normalization or otherwise rewrite values; valid values are passed through unchanged to the target call. If a callee requires a normalized path, normalize it before calling the CLI or perform normalization in your script. +- JSON parameter payloads must be objects (mappings) with named arguments. +- Methods that require positional-only parameters are not supported by CLI named-argument routing. +- Duplicate/conflicting argument paths (for example ``param=1`` and ``param.attr=2``) are rejected. + +Recommended usage: + +- Quote or escape values containing spaces or shell characters: + +```sh +moldflow invoke synergy.open_path path="C:\\path with spaces\\file.txt" +``` + +- For complex values or to avoid shell-escaping issues, prefer JSON input (``--params-json`` or ``--params-json-file``) and programmatic consumption of JSON output. + +See the [CLI documentation](https://autodesk.github.io/moldflow-api/cli.html) for more details. + +### Safety & testing notes + +The CLI performs careful introspection and validation to avoid accidental side effects: + +- `list` and `describe` only reflect on the Python API and do **not** start Synergy or any COM objects. +- `invoke` validates required arguments and parses types before constructing wrapper instances, so malformed calls fail fast without launching the Synergy UI. + +Running the CLI tests + +The project includes a suite of unit tests for the CLI that mock the Synergy integration so the real application is never opened. To run the CLI tests locally: + +```sh +python run.py test -m cli +# or directly with pytest: +python -m pytest tests/api/unit_tests -m cli -q +``` + +Test authors: when writing tests that might touch runtime objects or factories, always patch both: + +- `moldflow_cli.context.get_synergy` +- `moldflow_cli.factories.get_synergy` + +This ensures neither the introspection nor the factory helpers attempt to talk to COM during tests. + ## For Development ### 1. Clone the Repository diff --git a/SECURITY.md b/SECURITY.md index fd9b67a..1a9ba6c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,11 +2,14 @@ ## Supported Versions -We release patches for security vulnerabilities for the following versions: - -| Version | Supported | -| ------- | ------------------ | -| 26.0.x | :white_check_mark: | +We release security patches for the latest major release and the immediately +previous 3 major releases. + +To determine whether a release is supported, open the +[repository releases](https://github.com/Autodesk/moldflow-api/releases) page +and look at the release tag/version. Any release that belongs to the current +major version or one of the previous 3 major versions is supported. Older major +versions are no longer supported. ## Reporting a Vulnerability diff --git a/docs/source/_static/switcher.json b/docs/source/_static/switcher.json index 32696ea..e7cbf44 100644 --- a/docs/source/_static/switcher.json +++ b/docs/source/_static/switcher.json @@ -1,9 +1,21 @@ [ + { + "version": "v27.1.0", + "name": "v27.1.0 (latest)", + "url": "../v27.1.0/", + "is_latest": true + }, + { + "version": "v27.0.1", + "name": "v27.0.1", + "url": "../v27.0.1/", + "is_latest": false + }, { "version": "v27.0.0", - "name": "v27.0.0 (latest)", + "name": "v27.0.0", "url": "../v27.0.0/", - "is_latest": true + "is_latest": false }, { "version": "v26.0.5", diff --git a/docs/source/cli.rst b/docs/source/cli.rst new file mode 100644 index 0000000..4764f94 --- /dev/null +++ b/docs/source/cli.rst @@ -0,0 +1,619 @@ +.. _cli: + +Command-line interface (CLI) +============================ + +The Moldflow API package ships with an optional command-line interface that lets you +drive Synergy operations from a shell or automation script. + +Installation +------------ + +To install the library **with** the CLI extras: + +.. code-block:: bash + + python -m pip install "moldflow[cli]" + +Alternatively, from a clone of this repository you can run: + +.. code-block:: bash + + python run.py install + +which will build and install the local wheel together with the CLI dependencies. + +Basic usage +----------- + +Once installed, a top-level ``moldflow`` command is available: + +.. code-block:: bash + + moldflow --help + moldflow list + moldflow list --json --with-describe --max-results 25 + moldflow describe synergy.open_project + +The main commands are: + +* ``list`` – discover invokable targets and the next command to run for each one +* ``describe`` – inspect a target's signature, docs, examples, and structured invoke template +* ``invoke`` – run a Moldflow target with named parameters or JSON input +* ``repl`` – start an interactive shell session with tab completion + +The top-level help guides first-time users through the intended flow: +start with ``list`` to discover targets, use ``describe `` to inspect +usage, then run ``invoke ...``. + +The ``list`` output includes readable properties and read/write properties as well +as methods, so property targets are discoverable from the main index. In human +mode it also shows the target kind and a suggested next step. When the CLI knows +how to reach a wrapper through a Synergy property or ``create_*`` factory, the +listed target is emitted in the same Synergy-rooted form that ``invoke`` accepts. + +``list --filter`` can be repeated, and repeated filters are additive: a target +is included when it matches any provided filter value. + +Interactive REPL +---------------- + +The CLI includes an interactive shell (REPL) that provides a persistent session +with tab completion: + +.. code-block:: bash + + moldflow repl + +Inside the REPL you can type any CLI command without the ``moldflow`` prefix: + +.. code-block:: text + + moldflow> list + moldflow> describe synergy.open_project + moldflow> invoke synergy.new_project name="My Project" path="C:/mf/MyProject.mfproj" + +Built-in session commands: + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Command + - Description + * - ``help`` + - Show available commands + * - ``help `` + - Show detailed help for a specific command + * - ``clear`` + - Clear the terminal screen and redraw the banner + * - ``reset`` + - Reset the Synergy session (tab-completion targets are refreshed automatically) + * - ``exit`` / ``quit`` + - Exit the REPL (Ctrl+D also works) + +Tab completion works for all registered commands as well as for invokable +target names when the current command is ``describe`` or ``invoke``. On +Windows, tab completion requires the ``pyreadline3`` package, which is +installed automatically with ``pip install "moldflow[cli]"``. + +Use ``--debug`` to display full Python tracebacks instead of short error +messages: + +.. code-block:: bash + + moldflow repl --debug + +Invoking simple methods +----------------------- + +For simple methods on the ``Synergy`` wrapper you can call them directly with +``name=value`` arguments: + +.. code-block:: bash + + moldflow invoke synergy.new_project name="My Project" path="C:/mf/MyProject.mfproj" + moldflow invoke synergy.import_file file="C:/models/part.iges" show_logs=true + +Arguments are parsed as: + +* ``true/false/yes/no/on/off`` → booleans +* integer and float literals → numbers +* everything else → strings + +Nested objects and non-primitive parameters +------------------------------------------- + +Some methods take non-primitive parameters (for example ``ImportOptions`` or +``Vector``). These parameters can be configured using dotted paths: + +.. code-block:: bash + + moldflow invoke synergy.import_file \ + file="C:/models/part.iges" \ + import_options.use_mdl=true \ + import_options.units=Millimeter + +The CLI will: + +* Inspect the type annotation of ``import_options`` +* Create an appropriate wrapper instance from the ``Synergy`` object +* Set the specified attributes on that instance before calling the method + +Chained targets +--------------- + +The ``invoke`` command also supports chained targets that navigate through the +object model and call multiple methods in sequence. For example, to find a plot +by name and then query a probe line: + +.. code-block:: bash + + moldflow invoke synergy.plot_manager.find_plot_by_name.get_probe_plot_probe_line \ + find_plot_by_name.plot_name="My Plot" \ + get_probe_plot_probe_line.index=0 \ + get_probe_plot_probe_line.start_pt.x=0 \ + get_probe_plot_probe_line.start_pt.y=0 \ + get_probe_plot_probe_line.start_pt.z=0 \ + get_probe_plot_probe_line.end_pt.x=10 \ + get_probe_plot_probe_line.end_pt.y=0 \ + get_probe_plot_probe_line.end_pt.z=0 + +In this example: + +* ``synergy`` resolves to the main :class:`moldflow.synergy.Synergy` instance +* ``plot_manager`` resolves to ``Synergy.plot_manager`` +* ``find_plot_by_name`` is called first, using arguments prefixed with + ``find_plot_by_name.`` +* The returned :class:`moldflow.plot.Plot` instance is then used to call + ``get_probe_plot_probe_line``, using arguments prefixed with + ``get_probe_plot_probe_line.`` + +For multi-step targets, each method in the chain has its own argument namespace. +The general pattern is:: + + method_name.parameter[.attribute] = value + +For humans and agents, grouped JSON is often easier to read and generate for +multi-step targets with nested objects. The same chained probe-line call can be +written as: + +.. code-block:: bash + + moldflow invoke synergy.plot_manager.find_plot_by_name.get_probe_plot_probe_line \ + --params-json '{"find_plot_by_name":{"plot_name":"My Plot"},"get_probe_plot_probe_line":{"index":0,"start_pt":{"x":0,"y":0,"z":0},"end_pt":{"x":10,"y":0,"z":0}}}' + +Structured output for `list` +--------------------------- + +The ``list`` command can emit machine-readable lists via ``--json`` or +``--yaml``. These structured modes are intended for scripting and agent use. +When used, the command outputs an array of objects with the keys: + +- ``target``: the CLI dotted target (e.g. ``synergy.new_project``) +- ``owner_class``: the originating class name (e.g. ``Synergy``) +- ``kind``: ``method``, ``property``, or ``settable_property`` +- ``suggested_command``: the best first CLI command for a human user +- ``commands``: only the applicable follow-up commands for that target, such as + ``describe`` or ``invoke`` + +You can also request: + +- ``--with-describe`` to embed the structured ``describe`` payload for each row +- ``--max-results`` to limit the number of rows returned after filtering + +Examples: + +.. code-block:: bash + + moldflow list --json + moldflow list --filter NEW_PROJ --yaml + +JSON output +----------- + +For automation and agent-based tools it is often useful to receive structured +machine-readable output instead of human-oriented strings. The ``invoke`` +command therefore supports a ``--json`` flag: + +.. code-block:: bash + + moldflow invoke synergy.boundary_conditions.create_ndbc \ + some_param=... \ + --json + +When ``--json`` is used, the CLI will attempt to convert common wrapper types +into structured JSON: + +* **EntList** and similar objects with ``convert_to_string()`` and ``size``: + + .. code-block:: json + + { + "type": "EntList", + "size": 42, + "string": "1:part:face1, 2:part:face2, ..." + } + +* **Vector**-like objects with ``x``, ``y``, ``z`` attributes: + + .. code-block:: json + + { + "type": "Vector", + "x": 0.0, + "y": 1.0, + "z": 2.0 + } + +* **DoubleArray / IntegerArray / StringArray** (objects with ``to_list`` and + ``size``): + + .. code-block:: json + + { + "type": "DoubleArray", + "size": 5, + "values": [1.0, 2.0, 3.0, 4.0, 5.0] + } + +* **VectorArray**-like objects with ``size`` and ``x(i)``, ``y(i)``, ``z(i)``: + + .. code-block:: json + + { + "type": "VectorArray", + "size": 3, + "values": [ + {"x": 0.0, "y": 0.0, "z": 0.0}, + {"x": 1.0, "y": 0.0, "z": 0.0}, + {"x": 0.0, "y": 1.0, "z": 0.0} + ] + } + +* **Property**-like objects with ``id``, ``name`` and ``type``: + + .. code-block:: json + + { + "type": "Property", + "id": 123, + "name": "Viscosity", + "prop_type": 7 + } + +If a result does not match any known pattern, the CLI falls back to returning a +JSON string containing ``repr(obj)``. + +JSON input (for invoke) +----------------------- + +The ``invoke`` command accepts structured parameter input in addition to the +traditional ``key=value`` syntax. This is useful to avoid complex shell quoting +or to provide nested objects conveniently. + +There are three supported JSON input mechanisms (listed in precedence order): + +- ``--params-json``: pass a JSON literal as a string on the command line. Example: + + .. code-block:: bash + + moldflow invoke synergy.new_project --params-json '{"name":"test","path":"C:\\\\Projects\\\\MyProject"}' + +- ``--params-json-file `` (alias ``-J``): read parameters from a JSON file. + The CLI is lenient with byte-order-marks (BOM) and will decode UTF-8 files with + or without a BOM transparently. + + .. code-block:: bash + + moldflow invoke synergy.new_project --params-json-file C:/tmp/params.json + +- Single-positional-JSON shorthand: if the invoke command receives exactly one + positional argument that begins with ``{`` or ``[``, it will be parsed as JSON + and treated equivalently to ``--params-json``. This is a convenience for + simple scripts but is less explicit than the ``--params-json`` option. + +Notes: + +- Only one JSON input source may be provided. Supplying both a JSON literal and a + JSON file will cause the CLI to error. +- When JSON input is used, positional ``key=value`` arguments are not allowed. +- Top-level JSON payloads must be objects (mappings). Arrays/scalars are rejected. +- For multi-step targets, JSON input should group parameters by step name, for + example ``{"find_plot_by_name": {"plot_name": "My Plot"}}``. For single-step + targets, top-level keys map directly to parameters (nested dicts are preserved + and passed through as values). Single-step targets also accept an optional + wrapper object keyed by the step name (case-insensitive), e.g. + ``{"FIND_PLOT_BY_NAME": {"plot_name": "My Plot"}}``. +- For more complex multi-step calls, include one object per step. For example, + ``synergy.plot_manager.find_plot_by_name.get_probe_plot_probe_line`` can be + invoked with + ``{"find_plot_by_name":{"plot_name":"My Plot"},"get_probe_plot_probe_line":{"index":0,"start_pt":{"x":0,"y":0,"z":0},"end_pt":{"x":10,"y":0,"z":0}}}``. +- Methods with positional-only parameters are not supported by CLI named-argument routing. +- Within a step, duplicate argument paths (for example ``param=1`` and ``param=2``) + or conflicting paths (for example ``param=1`` and ``param.attr=2``) are rejected. +- JSON-derived values are passed through as native Python types (lists, dicts, + numbers, booleans). +- For wrapper parameters with CLI adapters, prefer the canonical typed JSON fields + shown by ``describe``. For example, a target such as + ``synergy.boundary_conditions.create_edge_loads`` can be written as + ``{"nodes": {"entity_string": "N1,N2"}, + "force": {"xyz": [0.0, 0.0, -100.0]}}``. + Array-backed wrappers follow the same pattern, for example + ``{"value": {"values": [1.0, 2.5]}}`` when a target + has a ``DoubleArray`` parameter named ``value``, or + ``{"points": {"xyz": [[0, 0, 0], [1, 0, 0]]}}`` for a + ``VectorArray`` parameter named ``points``. +- Advanced fallback only: ``__type__`` remains available for generic or annotation-free + JSON payloads, but it is intentionally not part of the normal user-facing flow for + annotated parameters. If the CLI already has wrapper context, such as a typed parameter + or an existing nested wrapper-valued property, prefer the untagged wrapper-native form. +- In non-JSON mode, prefer direct parameter assignment for adapter-backed wrappers, + for example ``nodes=N1,N2`` or ``force=0,0,-100``. The explicit dotted form is mainly + an escape hatch for tooling or debugging; when needed, use the reflected canonical + field name such as ``nodes.entity_string=...`` or ``force.xyz=...``. List-backed + wrappers also accept shorthand such as ``levels=1.0,2.5``, and vector-array wrappers + accept quoted series such as ``points="0,0,0;1,0,0"``. + +JSON output (invoke) +-------------------- + +The invoke command supports emitting structured JSON results for automation and +LLM orchestration. The following JSON-output options are provided: + +- ``--json`` is the canonical option for JSON stdout. +- ``--json-output`` is a legacy compatibility alias for ``--json``. +- ``--json-file-output ``: write JSON to the specified file without changing + stdout mode. Combine it with ``--json`` when you also want JSON on stdout. +- JSON output now uses a stable invoke envelope with: + - ``schema_version`` + - ``ok``: business-level success indicator (``False`` when target returns ``False``) + - ``result_type``: Python type name of the raw return value + - ``result``: serialized return payload + - ``diagnostics`` (optional): extra failure context + +Example: + +.. code-block:: bash + + moldflow invoke synergy.cli_return_entlist --json --json-file-output out.json + +Planning and automation modes +----------------------------- + +The ``invoke`` command includes planning/debugging modes that are useful in CI and +agent workflows: + +- ``--dry-run``: parse, validate, and build a resolved call plan without executing invoke steps. +- ``--trace``: emit trace events (JSON) for planning and runtime deferred-step binding. +- ``--batch-file``: execute multiple invoke calls from a JSON array file. +- ``--fail-on-false`` / ``--no-fail-on-false``: control whether a target returning + ``False`` should produce a non-zero process exit code. Enabled by default. + +For terminal use, ``--dry-run`` and ``--batch-file`` render +human-readable summaries by default. Add ``--json`` when you want the structured +JSON contract on stdout. ``--json-file-output`` writes the JSON payload to a +file without changing stdout mode, so it can be combined with the default human +summary or with ``--json``. Human-mode output also confirms where the structured +payload was written. + +Dry-run example: + +.. code-block:: bash + + moldflow invoke synergy.open_project path="C:/tmp/project.mfproj" --dry-run + +For wrapper/object parameters, template output includes more than just a structural +shape. When the CLI recognizes a wrapper family it emits canonical JSON fields, +preferred non-JSON syntax, and concrete examples under ``input_hints``. Typical +examples include output for a target such as +``synergy.boundary_conditions.create_edge_loads``: + +.. code-block:: json + + { + "friendly_json_input": { + "preferred_field": "entity_string" + }, + "non_json_input": { + "preferred_syntax": "nodes=", + "explicit_field_syntax": "nodes.entity_string=" + }, + "examples": { + "preferred_params_json": { + "nodes": { + "entity_string": "N1,N2" + } + }, + "preferred_non_json": "nodes=N1,N2", + "explicit_field_non_json": "nodes.entity_string=N1,N2" + } + } + +Vector-like and list-backed wrappers expose similar hints, for example ``force=0,0,-100`` +for a ``Vector`` parameter or ``values`` for wrappers such as ``DoubleArray``. Vector-array +wrappers expose the same pattern with triplet series such as ``points=0,0,0;1,0,0``. +If shorthand input becomes ambiguous or awkward to escape, switch to +``--params-json``. + +Advanced fallback details, including tagged ``__type__`` shapes for generic or +annotation-free payloads, are grouped separately under ``input_hints.advanced_fallbacks``. +For multi-step JSON targets, if a payload fails step routing, group parameters by step +name as shown in the template output, for example +``{"find_plot_by_name": {"plot_name": "My Plot"}}``. + +Trace example: + +.. code-block:: bash + + moldflow invoke synergy.plot_manager.find_plot_by_name \ + find_plot_by_name.plot_name="My Plot" \ + --trace + +Each trace line is a JSON object written to stderr with ``schema_version``, +``sequence``, ``event``, ``target``, and ``payload``. Events that refer to a +concrete invoke step or a property assignment also include ``step`` or +``property``. Explicit ``result`` and ``error`` events are emitted, and batch +runs include ``batch_index`` for per-item correlation. + +Batch mode (invoke --batch-file) +-------------------------------- + +Batch mode reads a JSON array of call objects and executes them in order. + +Each batch item supports: + +- ``target`` (required, string): invoke target +- ``args`` (optional, list of strings): positional ``key=value`` arguments +- ``params_json`` (optional): JSON object (or JSON string) for structured params +- ``params_json_file`` (optional, string): path to a JSON params file + +Example batch file: + +.. code-block:: json + + [ + { + "target": "synergy.open_project", + "args": ["path=C:/tmp/a.mfproj"] + }, + { + "target": "synergy.import_file", + "params_json": { + "file": "C:/tmp/part.iges", + "import_options": {"use_mdl": true} + } + }, + { + "target": "synergy.plot_manager.find_plot_by_name", + "params_json_file": "C:/tmp/find_plot_args.json" + } + ] + +Run batch mode: + +.. code-block:: bash + + moldflow invoke --batch-file C:/tmp/invoke_batch.json + +Batch output is structured JSON: + +- ``schema_version`` +- ``summary`` with ``total``, ``succeeded``, and ``failed`` counts +- ``batch_results`` (array) + - ``index``: item index in batch file + - ``target``: item target (when available) + - ``request``: normalized request preview for debugging and replay + - ``ok``: success flag + - ``result`` (on success) or ``error`` (on failure) + - ``result_type``: Python type name of the target return value + - ``diagnostics`` (optional): additional context for business-level failures + - ``error_type`` (optional): machine-readable failure category such as + ``batch_item_validation``, ``invoke_validation``, or ``business_failure`` + +For planning-only batch execution, combine with ``--dry-run``: + +.. code-block:: bash + + moldflow invoke --batch-file C:/tmp/invoke_batch.json --dry-run + +Any structured invoke mode can be written to file via ``--json-file-output``: + +.. code-block:: bash + + moldflow invoke --batch-file C:/tmp/invoke_batch.json --json-file-output C:/tmp/batch_out.json + +Add ``--json`` too if you want the same structured payload on stdout. + +Structured output for `describe` +-------------------------------- + +The ``describe`` command can emit structured metadata about a target using the +``--json`` or ``--yaml`` flags. These are useful for automation, editor +integrations, or other tooling that needs to parse signatures and parameter +metadata. + +Examples: + +.. code-block:: bash + + # JSON output + moldflow describe plot.get_probe_plot_probe_line --json + + # YAML output (requires PyYAML) + moldflow describe plot.get_probe_plot_probe_line --yaml + +Output shape: + +- ``schema_version``: contract version for machine parsing +- ``target``: the dotted target string +- ``signature``: the human-readable signature line +- ``doc``: the docstring or null +- ``params``: a list of parameter metadata objects with ``name``, ``kind``, + ``annotation``, and ``default`` (canonicalized text/default repr) +- ``params_json_template``: preferred JSON payload shape for ``invoke --params-json`` +- ``invoke_examples``: recommended CLI command and preferred JSON examples when the + target can also be invoked through the CLI + +If ``--yaml`` is requested but PyYAML is not installed the CLI will exit with a helpful message indicating the missing dependency. + +Template metadata includes per-parameter ``required`` and ``nullable`` fields so +tooling can distinguish required non-null inputs from nullable inputs that can be omitted. + +Input validation and escaping +----------------------------- + +The CLI performs lightweight input validation for malformed parameter values: + +- The CLI rejects null bytes in any string parameter. +- For raw CLI ``key=value`` arguments, shell metacharacters are treated as normal literal characters in values. +- For JSON-derived parameters (``--params-json``/``--params-json-file``), shell metacharacter checks are not applied; only null bytes are rejected. +- The CLI does not perform path normalization or otherwise rewrite values; valid values are passed through unchanged to the target call. + +Recommendations: + +- Prefer quoting or escaping values that contain spaces: + +.. code-block:: bash + + moldflow invoke synergy.open_path path="C:\\path with spaces\\file.txt" + +- For complex structured inputs or to avoid shell-escaping issues entirely, use JSON input mechanisms (``--params-json`` or ``--params-json-file``) and consume structured output with ``--json``. +- When writing scripts that call the CLI, always use proper shell quoting (or pass arguments programmatically via subprocess APIs) to avoid accidental interpretation by the shell. + +Error messages produced when validation fails indicate the parameter/path and a short reason (for example "contains a null byte", "duplicate argument path", or "unknown parameter"). + +Safety and side effects +----------------------- + +* ``list`` and ``describe`` operate purely via reflection and do **not** + instantiate Synergy or any COM objects. +* ``invoke`` validates arguments first (including required parameters) using + introspection before constructing real objects, so invalid calls will fail + early without opening the Synergy UI. + + +Testing and mocking +------------------- + +The CLI is covered by unit tests under `tests/api/unit_tests/` that mock the Synergy integration so the real application never opens during CI or local runs. + +Guidance for writing and running CLI tests: + +- Always patch both `moldflow_cli.context.get_synergy` and `moldflow_cli.factories.get_synergy` when a test could instantiate runtime objects or call factory methods. +- When the test exercises the introspection path (e.g., `describe` or `list`) and the runtime invocation path (`invoke`), ensure the class-level shape visible to reflection (the `moldflow.Synergy` class) and the instance returned by `get_synergy()` expose the same callables. In tests this is commonly achieved by temporarily adding the needed method to `moldflow.Synergy` and restoring it after the test. +- Use the project's runner to run tests and coverage: + +.. code-block:: bash + + # run CLI tests only + python run.py test -m cli + + # or run a single test file with pytest + python -m pytest tests/api/unit_tests -m cli -q + +JSON output in tests +-------------------- + +When asserting `--json` output in tests, prefer parsing with `json.loads()` and asserting on keys/structure rather than exact string matches; the CLI will attempt to produce structured JSON for known wrapper types, but some fallbacks may emit reprs depending on runtime environment. + diff --git a/docs/source/index.rst b/docs/source/index.rst index 1c622ef..5be6358 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -10,4 +10,5 @@ :caption: Moldflow Synergy moldflow + cli enums diff --git a/pytest.ini b/pytest.ini index bad266a..0625463 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,3 +3,4 @@ markers = unit: Unit tests that run quickly and test small pieces of functionality integration: Integration tests that check multiple components together core: Core functionality tests + cli: Command-line interface tests diff --git a/requirements.txt b/requirements.txt index b114fd6..552f9fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ black==25.1.0 build==1.2.2.post1 -coverage==7.6.12 +coverage==7.15.4 docopt==0.6.2 packaging==24.2 pathspec==0.12.1 diff --git a/run.py b/run.py index 61d005d..a8e54b6 100644 --- a/run.py +++ b/run.py @@ -8,6 +8,7 @@ run.py clean-up run.py build [-P | --publish] [-i | --install] run.py build-docs [-t | --target=] [-s | --skip-build] [-l | --local] + run.py cli-smoke [-s | --skip-build] run.py format [--check] run.py install [-s | --skip-build] run.py install-package-requirements @@ -22,6 +23,7 @@ clean-up Clean up build artifacts. build Build and optionally publish the moldflow-api package. build-docs Build the documentation. + cli-smoke Create a CLI smoke-test venv and verify basic CLI commands. format Format all Python files in the repository using black. install Install the moldflow-api package. install-package-requirements Install package dependencies. @@ -62,6 +64,7 @@ import subprocess import shutil import glob +from pathlib import Path from urllib.parse import urlparse import docopt from github import Github @@ -92,6 +95,7 @@ DOCS_HTML_DIR = os.path.join(DOCS_BUILD_DIR, 'html') COVERAGE_HTML_DIR = os.path.join(ROOT_DIR, 'htmlcov') DIST_DIR = os.path.join(ROOT_DIR, 'dist') +CLI_SMOKE_VENV_DIR = os.path.join(ROOT_DIR, '.cli-smoke-venv') # Files PYLINT_CONFIG_FILE = os.path.join(ROOT_DIR, '.pylint.toml') @@ -99,6 +103,7 @@ SETUP_CONFIG_IN_FILE = os.path.join(ROOT_DIR, 'setup.cfg.in') COVERAGE_FILE = os.path.join(ROOT_DIR, '.coverage') COVERAGE_CONFIG_FILE = os.path.join(ROOT_DIR, '.coverage-config') +COVERAGE_CONFIG_CLI_FILE = os.path.join(ROOT_DIR, '.coverage-config-cli') COVERAGE_XML_FILE_NAME = 'coverage.xml' VERSION_FILE = os.path.join(ROOT_DIR, VERSION_JSON) DIST_FILES = os.path.join(ROOT_DIR, 'dist', '*') @@ -125,6 +130,11 @@ def run_command(args, cwd=os.getcwd(), extra_env=None): raise subprocess.CalledProcessError(proc.returncode, ' '.join(args)) +def python_module_command(*args): + """Build argv for ``python -m ...`` invocations without shell-style splitting.""" + return [sys.executable, '-m', *[str(arg) for arg in args]] + + def build_package(install=True): """Build package""" @@ -132,6 +142,10 @@ def build_package(install=True): build_mo() + # NOTE: PO sources are maintained under the package-local locale directory + # (src/moldflow/locale). build_mo() will compile .po -> .mo in place so the + # package build (wheel/sdist) can include the generated catalogs. + with open(SETUP_CONFIG_IN_FILE, 'r', encoding=ENCODING) as f: template = f.read() @@ -141,7 +155,7 @@ def build_package(install=True): f.write(output) try: - run_command([sys.executable] + '-m build'.split(' '), ROOT_DIR) + run_command(python_module_command('build'), ROOT_DIR) except Exception as err: logging.error( "Failed to build package: '%s'.\n" @@ -293,13 +307,124 @@ def install_package(target_path=None, build=False): logging.info('Attempting to install moldflow-api') - wheel_path = os.path.join(ROOT_DIR, 'dist', f'moldflow-{VERSION}-py3-none-any.whl') + dist_dir = os.path.join(ROOT_DIR, 'dist') + + # Install the locally built wheel (explicit path) including the optional CLI extra. + # Using an explicit wheel avoids resolving metadata from external indexes. + wheel_files = [] + if os.path.isdir(dist_dir): + wheel_files = [ + os.path.join(dist_dir, name) + for name in os.listdir(dist_dir) + if name.endswith('.whl') and name.startswith(f"moldflow-{VERSION}") + ] + wheel_path = max(wheel_files, key=os.path.getmtime) if wheel_files else None + + package_spec = f"moldflow[cli]=={VERSION}" + if wheel_path: + package_spec = wheel_package_spec(wheel_path) + + args = [ + sys.executable, + '-m', + 'pip', + 'install', + '--force-reinstall', + '--upgrade', + '--no-cache-dir', + package_spec, + '--find-links', + dist_dir, + ] - pip_args = f'install --force-reinstall --upgrade {wheel_path}' if target_path: - pip_args = f'{pip_args} --target={target_path}' + args.append(f'--target={target_path}') + + run_command(args, ROOT_DIR) + + +def wheel_package_spec(wheel_path: str) -> str: + """Return a PEP 508 direct reference for installing the local wheel with CLI extras.""" + + if not wheel_path: + raise ValueError('wheel_path must be a non-empty string.') + if Path(wheel_path).suffix.lower() != '.whl': + raise ValueError(f'wheel_path must point to a wheel file: {wheel_path}') + + return f"moldflow[cli] @ {Path(wheel_path).resolve().as_uri()}" + + +def _latest_dist_wheel() -> str: + """Return the newest built moldflow wheel from dist.""" + + wheel_files = glob.glob(os.path.join(DIST_DIR, 'moldflow-*.whl')) + if not wheel_files: + raise RuntimeError( + f'No moldflow wheel found in {DIST_DIR}. Run `python run.py build` first.' + ) + return max(wheel_files, key=os.path.getmtime) + + +def _venv_python_executable(venv_dir: str) -> str: + """Return the Python executable path for a virtual environment.""" + + if not venv_dir: + raise ValueError('venv_dir must be a non-empty string.') - run_command([sys.executable] + f'-m pip {pip_args}'.split(' '), ROOT_DIR) + scripts_dir = 'Scripts' if WINDOWS else 'bin' + executable_name = 'python.exe' if WINDOWS else 'python' + return os.path.join(venv_dir, scripts_dir, executable_name) + + +def _remove_directory_if_present(path: str) -> None: + """Remove a directory when present, tolerating only concurrent deletion.""" + + if not path: + raise ValueError('path must be a non-empty string.') + if not os.path.exists(path): + return + if not os.path.isdir(path): + raise NotADirectoryError(f'Expected a directory path for cleanup, got: {path}') + + try: + shutil.rmtree(path) + except FileNotFoundError: + pass + + +def cli_smoke(skip_build=False): + """Create an isolated CLI smoke-test environment and verify core CLI commands.""" + + if not skip_build: + build_package(install=False) + + wheel_path = _latest_dist_wheel() + + _remove_directory_if_present(CLI_SMOKE_VENV_DIR) + + run_command(python_module_command('venv', CLI_SMOKE_VENV_DIR), ROOT_DIR) + + venv_python = _venv_python_executable(CLI_SMOKE_VENV_DIR) + completed = False + try: + if not os.path.isfile(venv_python): + raise RuntimeError( + 'Virtual environment was created, but Python executable was not found: ' + f'{venv_python}' + ) + + run_command([venv_python, '-m', 'pip', 'install', '--upgrade', 'pip'], ROOT_DIR) + run_command([venv_python, '-m', 'pip', 'install', wheel_package_spec(wheel_path)], ROOT_DIR) + run_command([venv_python, '-m', 'moldflow_cli', '--help'], ROOT_DIR) + run_command([venv_python, '-m', 'moldflow_cli', 'invoke', '--help'], ROOT_DIR) + run_command([venv_python, '-m', 'moldflow_cli', 'list', '--json'], ROOT_DIR) + completed = True + finally: + if not completed: + try: + _remove_directory_if_present(CLI_SMOKE_VENV_DIR) + except NotADirectoryError as cleanup_error: + logging.warning('Failed to clean up CLI smoke venv: %s', cleanup_error) def build_mo(): @@ -467,13 +592,16 @@ def format_code(check_only=False): logging.info('Attempting to format python files using black in repo') - check_args = '--check ' if check_only else '' - - python_files = ' '.join(PYTHON_FILES) - - formatter_args = f'--line-length=100 -S -C {python_files}' + formatter_cmd = python_module_command( + 'black', + *(['--check'] if check_only else []), + '--line-length=100', + '-S', + '-C', + *PYTHON_FILES, + ) - run_command([sys.executable] + f'-m black {check_args}{formatter_args}'.split(' '), ROOT_DIR) + run_command(formatter_cmd, ROOT_DIR) def lint(skip_build): @@ -486,11 +614,11 @@ def lint(skip_build): logging.info('Attempting to lint python files in repo') - python_files = ' '.join(PYTHON_FILES) - - pylint_args = f'--rcfile {PYLINT_CONFIG_FILE} --verbose {python_files}' + pylint_cmd = python_module_command( + 'pylint', '--rcfile', PYLINT_CONFIG_FILE, '--verbose', *PYTHON_FILES + ) - run_command([sys.executable] + f'-m pylint {pylint_args}'.split(' '), ROOT_DIR) + run_command(pylint_cmd, ROOT_DIR) class Report: @@ -503,12 +631,20 @@ class Report: """ coverage_config_file_arg = f"--rcfile={COVERAGE_CONFIG_FILE}" + coverage_cli_config_file_arg = f"--rcfile={COVERAGE_CONFIG_CLI_FILE}" + + @staticmethod + def default(): + """Generate default package coverage report.""" + run_command( + python_module_command('coverage', 'report', Report.coverage_config_file_arg), ROOT_DIR + ) @staticmethod def cli(): """Generate CLI report""" run_command( - [sys.executable] + f'-m coverage report {Report.coverage_config_file_arg}'.split(' '), + python_module_command('coverage', 'report', Report.coverage_cli_config_file_arg), ROOT_DIR, ) @@ -516,18 +652,15 @@ def cli(): def html(): """Generate HTML report""" run_command( - [sys.executable] + f'-m coverage html {Report.coverage_config_file_arg}'.split(' '), - ROOT_DIR, + python_module_command('coverage', 'html', Report.coverage_config_file_arg), ROOT_DIR ) @staticmethod def xml(): """Generate XML report""" - coverage_xml_file_arg = f"-o {COVERAGE_XML_FILE_NAME}" run_command( - [sys.executable] - + f'-m coverage xml {coverage_xml_file_arg} {Report.coverage_config_file_arg}'.split( - ' ' + python_module_command( + 'coverage', 'xml', '-o', COVERAGE_XML_FILE_NAME, Report.coverage_config_file_arg ), ROOT_DIR, ) @@ -547,19 +680,21 @@ class Test: @staticmethod def _run_marker(marker, tests, quiet=False): - coverage_config_file_arg = f"--rcfile={COVERAGE_CONFIG_FILE}" - - verbosity = '-v' if quiet else '-rA -vv' - pytest_options = f'{verbosity} --override-ini=console_output_style=count' + coverage_config = COVERAGE_CONFIG_CLI_FILE if marker == 'cli' else COVERAGE_CONFIG_FILE + coverage_config_file_arg = f"--rcfile={coverage_config}" - test_targets = " ".join(tests) if tests else ROOT_DIR - marker_option = f"-m {marker}" if marker else "" + pytest_args = ['-v'] if quiet else ['-rA', '-vv'] + pytest_args.append('--override-ini=console_output_style=count') + if marker: + pytest_args.extend(['-m', marker]) + pytest_args.extend(tests if tests else [ROOT_DIR]) - pytest_args = f"{pytest_options} {marker_option} {test_targets}".strip() - - coverage_args = f'coverage run -p {coverage_config_file_arg} -m pytest {pytest_args}' - - run_command([sys.executable] + f'-m {coverage_args}'.split(' '), ROOT_DIR) + run_command( + python_module_command( + 'coverage', 'run', '-p', coverage_config_file_arg, '-m', 'pytest', *pytest_args + ), + ROOT_DIR, + ) @staticmethod def core_tests(tests, quiet=False): @@ -634,10 +769,13 @@ def run_tests( Test.custom_tests(marker, tests, quiet) # Coverage Combine - run_command([sys.executable] + '-m coverage combine'.split(' '), ROOT_DIR) + run_command(python_module_command('coverage', 'combine'), ROOT_DIR) # Coverage - run_command([sys.executable] + '-m run report --cli'.split(' '), ROOT_DIR) + if marker == 'cli' and not any([unit, integration, core, all_tests]): + run_command(python_module_command('run', 'report', '--cli'), ROOT_DIR) + else: + run_command(python_module_command('run', 'report'), ROOT_DIR) # Keeping files if not keep_files: @@ -750,6 +888,8 @@ def main(): if cli_arg: Report.cli() + else: + Report.default() if html_arg: Report.html() if xml_arg: @@ -768,6 +908,11 @@ def main(): build_docs(target=target, skip_build=skip_build, local=local) + elif args.get('cli-smoke'): + skip_build = args.get('--skip-build') or args.get('-s') + + cli_smoke(skip_build=skip_build) + elif args.get('install-package-requirements'): install_package(target_path=os.path.join(ROOT_DIR, SITE_PACKAGES)) diff --git a/scripts/check_localization.py b/scripts/check_localization.py index 18eab31..e42332b 100644 --- a/scripts/check_localization.py +++ b/scripts/check_localization.py @@ -22,6 +22,7 @@ Usage: python scripts/check_localization.py [--path PATH] [--locale-path PATH] + python scripts/check_localization.py --path src/moldflow_cli python scripts/check_localization.py --check-only # Only check, no fixes python scripts/check_localization.py --no-autofix # Skip adding missing strings python scripts/check_localization.py --no-fix-gaps # Skip fixing translation gaps @@ -41,6 +42,7 @@ # Strings that are acceptable to remain identical across locales ALLOW_EQUAL_MSGSTR: set[str] = { "OK", + "Vector", } @dataclass @@ -221,37 +223,236 @@ def _load_po_files(self): # Create parser for new locale self.po_files[locale_dir.name] = PoFileParser(po_file) - def _extract_strings_from_calls(self, node: ast.AST) -> List[Tuple[str, int, str]]: - """Extract string literals from _() function calls and process_log usage.""" - strings = [] + def _called_name(self, node: ast.AST) -> str | None: + """Return a simple function name when the call target is a bare identifier.""" + if isinstance(node, ast.Name): + return node.id + return None + + def _is_get_text_call(self, node: ast.AST) -> bool: + """Return True when an expression is a direct get_text() call.""" + return isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "get_text" + + def _iter_assigned_names(self, node: ast.AST) -> List[str]: + """Return simple variable names assigned by an assignment target.""" + names: List[str] = [] + if isinstance(node, ast.Name): + names.append(node.id) + elif isinstance(node, (ast.Tuple, ast.List)): + for element in node.elts: + names.extend(self._iter_assigned_names(element)) + return names + + def _function_param_names(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> List[str]: + """Return function parameter names in declaration order.""" + names = [arg.arg for arg in node.args.posonlyargs] + names.extend(arg.arg for arg in node.args.args) + if node.args.vararg is not None: + names.append(node.args.vararg.arg) + names.extend(arg.arg for arg in node.args.kwonlyargs) + if node.args.kwarg is not None: + names.append(node.args.kwarg.arg) + return names + + def _bind_call_arguments( + self, + node: ast.FunctionDef | ast.AsyncFunctionDef, + call: ast.Call, + ) -> Dict[str, ast.AST]: + """Best-effort binding of call arguments to function parameter names.""" + bound: Dict[str, ast.AST] = {} + positional_params = [arg.arg for arg in node.args.posonlyargs] + positional_params.extend(arg.arg for arg in node.args.args) + + for param_name, arg_value in zip(positional_params, call.args): + bound[param_name] = arg_value + + for keyword in call.keywords: + if keyword.arg is not None: + bound[keyword.arg] = keyword.value + + return bound + + def _expr_is_translation_callable(self, node: ast.AST, callable_names: set[str]) -> bool: + """Return True when an expression refers to a known translation callable.""" + if isinstance(node, ast.Name): + return node.id in callable_names + return self._is_get_text_call(node) + + def _collect_function_defs( + self, + node: ast.AST, + ) -> Dict[str, ast.FunctionDef | ast.AsyncFunctionDef]: + """Return function definitions keyed by simple function name.""" + function_defs: Dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {} + for child in ast.walk(node): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): + function_defs[child.name] = child + return function_defs + def _collect_translation_names_from_assignments(self, node: ast.AST) -> set[str]: + """Return names bound directly to get_text() results.""" + translation_names: set[str] = {"_"} for child in ast.walk(node): - if isinstance(child, ast.Call): - # Check for _() calls - if isinstance(child.func, ast.Name) and child.func.id == "_": - if child.args and isinstance(child.args[0], ast.Constant) and isinstance(child.args[0].value, str): - strings.append((child.args[0].value, child.lineno, "_() call")) + if isinstance(child, ast.Assign) and self._is_get_text_call(child.value): + for target in child.targets: + translation_names.update(self._iter_assigned_names(target)) + continue + if isinstance(child, ast.AnnAssign) and child.value is not None and self._is_get_text_call(child.value): + translation_names.update(self._iter_assigned_names(child.target)) + return translation_names + + def _update_translation_params_from_calls( + self, + node: ast.AST, + function_defs: Dict[str, ast.FunctionDef | ast.AsyncFunctionDef], + translation_names: set[str], + wrapper_names: set[str], + translation_params: Dict[str, set[str]], + ) -> bool: + """Propagate translation-callable arguments into callee parameter sets.""" + changed = False + known_translation_callables = translation_names | wrapper_names - # Check for get_text()() calls - elif (isinstance(child.func, ast.Call) and - isinstance(child.func.func, ast.Name) and - child.func.func.id == "get_text"): + for child in ast.walk(node): + if not isinstance(child, ast.Call): + continue + callee_name = self._called_name(child.func) + func_def = function_defs.get(callee_name or "") + if func_def is None: + continue + + bound_args = self._bind_call_arguments(func_def, child) + for param_name, arg_value in bound_args.items(): + if not self._expr_is_translation_callable(arg_value, known_translation_callables): + continue + if param_name in translation_params[func_def.name]: + continue + translation_params[func_def.name].add(param_name) + changed = True + + return changed + + def _update_wrapper_names( + self, + function_defs: Dict[str, ast.FunctionDef | ast.AsyncFunctionDef], + translation_names: set[str], + wrapper_names: set[str], + translation_params: Dict[str, set[str]], + ) -> bool: + """Detect wrapper functions that forward translation callables.""" + changed = False + known_translation_callables = translation_names | wrapper_names + + for func_name, func_def in function_defs.items(): + callable_names = known_translation_callables | translation_params[func_name] + param_names = set(self._function_param_names(func_def)) + for child in ast.walk(func_def): + if not isinstance(child, ast.Call): + continue + callee_name = self._called_name(child.func) + if callee_name not in callable_names: + continue + if not child.args or not isinstance(child.args[0], ast.Name): + continue + if child.args[0].id not in param_names: + continue + if func_name in wrapper_names: + break + wrapper_names.add(func_name) + changed = True + break + + return changed + + def _collect_translation_callables(self, node: ast.AST) -> Tuple[set[str], Dict[str, set[str]]]: + """Collect translation helper names and translation-callable parameters.""" + translation_names = self._collect_translation_names_from_assignments(node) + wrapper_names: set[str] = set() + function_defs = self._collect_function_defs(node) + + translation_params: Dict[str, set[str]] = { + func_name: set() for func_name in function_defs.keys() + } + + changed = True + while changed: + params_changed = self._update_translation_params_from_calls( + node, + function_defs, + translation_names, + wrapper_names, + translation_params, + ) + wrappers_changed = self._update_wrapper_names( + function_defs, + translation_names, + wrapper_names, + translation_params, + ) + changed = params_changed or wrappers_changed + + return translation_names | wrapper_names, translation_params + + def _is_localization_call(self, node: ast.Call, callable_names: set[str]) -> bool: + """Return True when a call node should be treated as a translation lookup.""" + callee_name = self._called_name(node.func) + if callee_name in callable_names: + return True + return ( + isinstance(node.func, ast.Call) + and isinstance(node.func.func, ast.Name) + and node.func.func.id == "get_text" + ) + + def _extract_strings_from_calls(self, node: ast.AST) -> List[Tuple[str, int, str]]: + """Extract string literals from translation calls, including get_text aliases.""" + strings: List[Tuple[str, int, str]] = [] + translation_callables, translation_params = self._collect_translation_callables(node) + + class _CallVisitor(ast.NodeVisitor): + def __init__(self, checker: LocalizationChecker): + self.checker = checker + self.function_stack: List[str] = [] + + def visit_FunctionDef(self, child: ast.FunctionDef) -> None: + self.function_stack.append(child.name) + self.generic_visit(child) + self.function_stack.pop() + + def visit_AsyncFunctionDef(self, child: ast.AsyncFunctionDef) -> None: + self.function_stack.append(child.name) + self.generic_visit(child) + self.function_stack.pop() + + def visit_Call(self, child: ast.Call) -> None: + active_callables = set(translation_callables) + if self.function_stack: + active_callables.update(translation_params.get(self.function_stack[-1], set())) + + if self.checker._is_localization_call(child, active_callables): if child.args and isinstance(child.args[0], ast.Constant) and isinstance(child.args[0].value, str): - strings.append((child.args[0].value, child.lineno, "get_text()() call")) + callee_name = self.checker._called_name(child.func) + if callee_name is None: + context = "get_text()() call" + else: + context = f"{callee_name}() call" + strings.append((child.args[0].value, child.lineno, context)) # Check for process_log calls to see which enum messages are actually used elif isinstance(child.func, ast.Name) and child.func.id == "process_log": if len(child.args) >= 2: - # Second argument should be LogMessage enum message_arg = child.args[1] if isinstance(message_arg, ast.Attribute): - # Look for LogMessage.SOME_MESSAGE pattern - if (isinstance(message_arg.value, ast.Name) and - message_arg.value.id == "LogMessage"): - # This indicates LogMessage.SOME_MESSAGE is being used - # We'll catch the actual string value from the enum definition + if ( + isinstance(message_arg.value, ast.Name) + and message_arg.value.id == "LogMessage" + ): pass + self.generic_visit(child) + + _CallVisitor(self).visit(node) return strings def _extract_enum_strings(self, node: ast.AST) -> List[Tuple[str, int, str]]: @@ -547,13 +748,36 @@ def apply_orphaned_string_cleanup(self) -> int: return removed_count +def _default_source_paths() -> List[Path]: + """Return the default source roots to scan when no path is provided.""" + default_paths = [Path("src/moldflow"), Path("src/moldflow_cli")] + existing_paths = [path for path in default_paths if path.exists()] + return existing_paths or default_paths + + +def _collect_python_files(src_roots: List[Path]) -> List[Path]: + """Collect Python files from one or more source roots.""" + py_files: List[Path] = [] + for src_root in src_roots: + if src_root.is_file(): + if src_root.suffix != ".py": + raise ValueError(f"File is not a Python file: {src_root}") + py_files.append(src_root) + continue + py_files.extend(p for p in src_root.rglob("*.py") if p.is_file()) + return py_files + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--path", type=Path, - default=Path("src/moldflow"), - help="Root directory to scan for Python files containing message enums and localization calls", + action="append", + help=( + "Root directory or Python file to scan for message enums and localization calls. " + "Repeat to scan multiple roots. Defaults to src/moldflow and src/moldflow_cli." + ), ) parser.add_argument( "--locale-path", @@ -588,7 +812,7 @@ def main() -> int: ) args = parser.parse_args() - src_root: Path = args.path + src_roots: List[Path] = args.path or _default_source_paths() locale_root: Path = args.locale_path # Determine which operations to run (all enabled by default unless explicitly disabled) @@ -597,15 +821,18 @@ def main() -> int: run_cleanup_orphaned = not args.check_only and not args.no_cleanup_orphaned # Only require source path if not just checking translation gaps - if not args.translation_gaps_only and not src_root.exists(): - print(f"Source path not found: {src_root}", file=sys.stderr) - return 2 + if not args.translation_gaps_only: + missing_paths = [src_root for src_root in src_roots if not src_root.exists()] + if missing_paths: + for src_root in missing_paths: + print(f"Source path not found: {src_root}", file=sys.stderr) + return 2 if not locale_root.exists(): print(f"Locale path not found: {locale_root}", file=sys.stderr) return 2 - checker = LocalizationChecker(src_root, locale_root) + checker = LocalizationChecker(src_roots[0], locale_root) # Show what operations will be performed if not args.check_only: @@ -628,15 +855,11 @@ def main() -> int: # Skip source code analysis if only checking translation gaps if not args.translation_gaps_only: - # Handle single file vs directory - if src_root.is_file(): - if src_root.suffix == ".py": - py_files = [src_root] - else: - print(f"File is not a Python file: {src_root}", file=sys.stderr) - return 2 - else: - py_files = [p for p in src_root.rglob("*.py") if p.is_file()] + try: + py_files = _collect_python_files(src_roots) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 for py_file in py_files: violations, fixes = checker.check_file(py_file) diff --git a/scripts/moldflow-cli-eval.ps1 b/scripts/moldflow-cli-eval.ps1 new file mode 100644 index 0000000..48d7208 --- /dev/null +++ b/scripts/moldflow-cli-eval.ps1 @@ -0,0 +1,1742 @@ +# Exercises the Moldflow CLI in a user-journey order: session, discovery, project/mesh/solver/loads/plots +# dry-runs, optional live Synergy + project lifecycle, batch JSON scripting, then regression (expected failures). +# +# By default, when live project smoke is enabled (omit -SkipRealSmoke and -SkipProjectSmoke), the script runs +# a customer-style workflow: write a small ASCII STL, import, mesh, place an injection NDBC (create_ndbc_at_xyz), +# then analyze_now (solve) and probe plot/results APIs. Start-Process -Wait only waits for the CLI process; +# Synergy's analyze_now/solve COM call is asynchronous, so after the STL solve invoke this script polls +# study_doc.is_analysis_running until idle and then waits for result files to appear before exercising +# result/plot/probe APIs (see -AnalysisWaitMaxSeconds / -AnalysisPollSeconds). +# Use -SkipSTLWorkflow for mesh_type-only project checks. +# (The old opt-in switch -STLWorkflow is unnecessary; use -SkipSTLWorkflow only when you want the slimmer path.) +param( + [string]$MoldflowExe = "moldflow", + [int]$PauseSeconds = 0, + [switch]$FailFast, + [switch]$SkipRealSmoke, + [switch]$SkipProjectSmoke, + [switch]$SkipSTLWorkflow, + + # After STL analyze_now (solve), poll is_analysis_running until false and wait for result files. + [int]$AnalysisWaitMaxSeconds = 7200, + [int]$AnalysisPollSeconds = 5 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$artifactRoot = Join-Path $repoRoot "demo_models" +$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +$artifactDir = Join-Path $artifactRoot "cli_eval_$timestamp" +$script:DescribeCache = @{} +$script:Launcher = $null + +function Format-CommandText { + param( + [Parameter(Mandatory = $true)] + [string]$Executable, + + [AllowEmptyCollection()] + [string[]]$FixedArgs, + + [Parameter(Mandatory = $true)] + [string[]]$Args + ) + + $parts = @($Executable) + $FixedArgs + $Args + $renderedParts = $parts | ForEach-Object { + if ($_ -match '\s') { + '"{0}"' -f $_ + } + else { + $_ + } + } + + return ($renderedParts -join ' ') +} + +function Resolve-CliLauncher { + param( + [Parameter(Mandatory = $true)] + [string]$PreferredExecutable + ) + + $resolvedCli = Get-Command $PreferredExecutable -ErrorAction SilentlyContinue + if ($null -ne $resolvedCli) { + return [pscustomobject]@{ + Executable = $resolvedCli.Source + FixedArgs = @() + Label = $resolvedCli.Source + } + } + + $resolvedPython = Get-Command "python" -ErrorAction SilentlyContinue + + if ($null -eq $resolvedPython) { + throw "Could not find '$PreferredExecutable' or 'python' on PATH." + } + + return [pscustomobject]@{ + Executable = $resolvedPython.Source + FixedArgs = @("-m", "moldflow_cli") + Label = "$($resolvedPython.Source) -m moldflow_cli" + } +} + +function Get-OptionalPropertyValue { + param( + [Parameter(Mandatory = $true)] + [object]$Object, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ($null -eq $Object) { + return $null + } + + if ($Object -is [System.Collections.IDictionary]) { + if ($Object.Contains($Name)) { + return $Object[$Name] + } + + return $null + } + + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { + return $null + } + + return $property.Value +} + +function Get-ObjectProperties { + param( + [AllowNull()] + [object]$Object + ) + + if ($null -eq $Object) { + return @() + } + + if ($Object -is [System.Collections.IDictionary]) { + $properties = @() + foreach ($key in $Object.Keys) { + $properties += [pscustomobject]@{ + Name = [string]$key + Value = $Object[$key] + } + } + + return $properties + } + + return @($Object.PSObject.Properties) +} + +function Test-IsEnumerableLike { + param( + [Parameter(Mandatory = $true)] + [object]$Value + ) + + return ($Value -is [System.Collections.IEnumerable]) -and -not ($Value -is [string]) -and -not ($Value -is [System.Collections.IDictionary]) +} + +function Get-CollectionCount { + param( + [AllowNull()] + [object]$Value + ) + + if ($null -eq $Value) { + return 0 + } + + if ($Value -is [System.Array]) { + return $Value.Count + } + + if ($Value -is [System.Collections.ICollection]) { + return $Value.Count + } + + if (Test-IsEnumerableLike -Value $Value) { + return @($Value).Count + } + + return 1 +} + +function Read-TextFileLines { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path $Path)) { + return @() + } + + $stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + try { + $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8, $true) + try { + $text = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + } + finally { + $stream.Dispose() + } + + if ([string]::IsNullOrEmpty($text)) { + return @() + } + + return @($text -split "`r?`n") +} + +function Invoke-MoldflowCliCommand { + param( + [Parameter(Mandatory = $true)] + [string]$Title, + + [Parameter(Mandatory = $true)] + [string[]]$Args, + + [ValidateSet("success", "failure")] + [string]$ExpectedOutcome = "success", + + [switch]$CaptureTrace, + + [switch]$Quiet + ) + + $safeTitle = (("{0}" -f ($Title -replace '[^A-Za-z0-9]+', '_')).Trim('_')) + if ([string]::IsNullOrWhiteSpace($safeTitle)) { + $safeTitle = "cli_step" + } + + $stdoutPath = Join-Path $artifactDir ("{0}_stdout.log" -f $safeTitle) + $stderrPath = Join-Path $artifactDir ("{0}_stderr.log" -f $safeTitle) + + if (-not $Quiet) { + Write-Output "" + Write-Output "=== $Title ===" + Write-Output (Format-CommandText -Executable $script:Launcher.Executable -FixedArgs $script:Launcher.FixedArgs -Args $Args) + } + + if (Test-Path $stdoutPath) { + Remove-Item -Path $stdoutPath -Force + } + if (Test-Path $stderrPath) { + Remove-Item -Path $stderrPath -Force + } + + # Use PowerShell's native argv handling rather than Start-Process -ArgumentList. + # This preserves spaces inside single arguments and raw JSON payloads without + # re-joining them into a single command line string. + Push-Location $repoRoot + try { + # Wait blocks until the CLI process exits. mesh_now may run for a long time while COM works. + # analyze_now can return before the solver finishes; the eval script polls is_analysis_running after STL solve. + & $script:Launcher.Executable @($script:Launcher.FixedArgs + $Args) 1> $stdoutPath 2> $stderrPath + $exitCode = if ($null -ne $LASTEXITCODE) { [int]$LASTEXITCODE } else { 0 } + } + finally { + Pop-Location + } + + $stdout = @() + if (Test-Path $stdoutPath) { + $stdout = @(Read-TextFileLines -Path $stdoutPath) + } + + $stderr = @() + if (Test-Path $stderrPath) { + $stderr = @(Read-TextFileLines -Path $stderrPath) + } + + if (-not $Quiet) { + if (@($stdout).Count -gt 0) { + $stdout | ForEach-Object { Write-Output $_ } + } + + if (@($stderr).Count -gt 0) { + $stderrLabel = if ($CaptureTrace) { "[trace]" } else { "[stderr]" } + Write-Output $stderrLabel + $stderr | ForEach-Object { Write-Output $_ } + } + } + + $passed = ($ExpectedOutcome -eq "success" -and $exitCode -eq 0) -or ($ExpectedOutcome -eq "failure" -and $exitCode -ne 0) + $result = [pscustomobject]@{ + Title = $Title + Args = $Args + ExpectedOutcome = $ExpectedOutcome + ExitCode = $exitCode + Passed = $passed + StdOut = @($stdout) + StdErr = @($stderr) + StdOutPath = $stdoutPath + StdErrPath = $stderrPath + TracePath = $stderrPath + } + + if (-not $Quiet) { + if ($passed) { + Write-Output ("[pass] exit code {0}" -f $exitCode) + } + else { + Write-Output ("[fail] expected {0}, got exit code {1}" -f $ExpectedOutcome, $exitCode) + } + } + + if ($FailFast -and -not $passed) { + throw "Step failed: $Title" + } + + if ($PauseSeconds -gt 0 -and -not $Quiet) { + Write-Output ("[pause {0}s]" -f $PauseSeconds) + Start-Sleep -Seconds $PauseSeconds + } + + return $result +} + +function Wait-MoldflowCliAnalysisIdle { + <# + .SYNOPSIS + Poll study_doc.is_analysis_running until false, then wait for result files. + + .NOTES + Invoke JSON marks ok=false when result is boolean false, so polling uses --no-fail-on-false. + Set AnalysisWaitMaxSeconds to 0 on the script to skip this wait (not recommended for STL results probes). + #> + param( + [int]$MaxWaitSeconds = 7200, + [int]$PollSeconds = 5 + ) + + if ($MaxWaitSeconds -le 0) { + Write-Output "" + Write-Output "=== Wait for analysis (skipped: AnalysisWaitMaxSeconds <= 0) ===" + return + } + + $analysisPollArgs = @( + "invoke", + "synergy.study_doc.is_analysis_running", + "--json-output", + "--no-fail-on-false" + ) + $resultsPollArgs = @( + "invoke", + "synergy.plot_manager.get_number_of_results_files", + "--json-output", + "--no-fail-on-false" + ) + $deadline = (Get-Date).AddSeconds($MaxWaitSeconds) + + Write-Output "" + Write-Output ("=== Wait for analysis/results (max {0}s, interval {1}s) ===" -f $MaxWaitSeconds, $PollSeconds) + + while ($true) { + if ((Get-Date) -gt $deadline) { + throw ( + "Timeout after {0}s: Synergy analysis still reported running (is_analysis_running). " -f $MaxWaitSeconds + + "Increase -AnalysisWaitMaxSeconds or inspect Synergy." + ) + } + + $poll = Invoke-MoldflowCliCommand -Title "poll is_analysis_running" -Args $analysisPollArgs -Quiet + if ($poll.ExitCode -ne 0) { + throw ("Poll is_analysis_running failed with exit code {0}." -f $poll.ExitCode) + } + + $raw = ($poll.StdOut -join "").Trim() + if ([string]::IsNullOrWhiteSpace($raw)) { + Start-Sleep -Seconds $PollSeconds + continue + } + + $envelope = $raw | ConvertFrom-Json + $running = $envelope.result + if ($null -eq $running) { + throw "Poll is_analysis_running: JSON envelope missing 'result'." + } + + if ($running -eq $false) { + Write-Output "[pass] analysis idle (is_analysis_running is false)" + break + } + + Write-Output ("[wait] analysis running (is_analysis_running=true), sleeping {0}s..." -f $PollSeconds) + Start-Sleep -Seconds $PollSeconds + } + + while ($true) { + if ((Get-Date) -gt $deadline) { + throw ( + "Timeout after {0}s: result files still unavailable after analysis became idle. " -f $MaxWaitSeconds + + "Inspect Synergy analysis completion and study results." + ) + } + + $poll = Invoke-MoldflowCliCommand -Title "poll get_number_of_results_files" -Args $resultsPollArgs -Quiet + if ($poll.ExitCode -ne 0) { + throw ("Poll get_number_of_results_files failed with exit code {0}." -f $poll.ExitCode) + } + + $raw = ($poll.StdOut -join "").Trim() + if ([string]::IsNullOrWhiteSpace($raw)) { + Start-Sleep -Seconds $PollSeconds + continue + } + + $envelope = $raw | ConvertFrom-Json + $count = $envelope.result + if ($null -eq $count) { + throw "Poll get_number_of_results_files: JSON envelope missing 'result'." + } + + if ([int]$count -gt 0) { + Write-Output ("[pass] results ready (get_number_of_results_files={0})" -f $count) + return + } + + Write-Output ("[wait] results not ready (get_number_of_results_files={0}), sleeping {1}s..." -f $count, $PollSeconds) + Start-Sleep -Seconds $PollSeconds + } +} + +function Test-CapturedOutputCompatibility { + param( + [Parameter(Mandatory = $true)] + [object]$Result, + + [Parameter(Mandatory = $true)] + [string]$Stream, + + [AllowEmptyCollection()] + [string[]]$ExpectedText = @() + ) + + $streamsToCheck = switch ($Stream) { + "stdout" { @("stdout") } + "stderr" { @("stderr") } + "both" { @("stdout", "stderr") } + default { throw "Unsupported compatibility stream '$Stream'." } + } + + foreach ($streamName in $streamsToCheck) { + $propName = if ($streamName -eq "stdout") { "StdOut" } else { "StdErr" } + $propObj = $Result.PSObject.Properties[$propName] + if ($null -eq $propObj) { + return [pscustomobject]@{ + Passed = $false + Message = "Result object missing property '$propName'." + } + } + $lines = @($propObj.Value) + if (@($lines).Count -eq 0) { + return [pscustomobject]@{ + Passed = $false + Message = "Captured $streamName output was empty." + } + } + + $text = ($lines -join "`n") + if ([regex]::IsMatch($text, '[\u2500-\u257F]')) { + return [pscustomobject]@{ + Passed = $false + Message = "Captured $streamName output still contains Unicode box-drawing characters." + } + } + + foreach ($snippet in @($ExpectedText)) { + if (-not $text.Contains($snippet)) { + return [pscustomobject]@{ + Passed = $false + Message = "Captured $streamName output did not include expected text '$snippet'." + } + } + } + } + + return [pscustomobject]@{ + Passed = $true + Message = "Captured $Stream output stayed ASCII-safe." + } +} + +function Convert-StdOutToJson { + param( + [Parameter(Mandatory = $true)] + [object]$Result, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + $text = (($Result.StdOut -join "`n").Trim()) + if ([string]::IsNullOrWhiteSpace($text)) { + throw "No JSON output captured for '$Label'." + } + + try { + return $text | ConvertFrom-Json + } + catch { + throw "Failed to parse JSON output for '$Label': $($_.Exception.Message)" + } +} + +function Get-ListPayload { + $result = Invoke-MoldflowCliCommand -Title "discovery list json" -Args @("list", "--json") -Quiet + if (-not $result.Passed) { + throw "Unable to collect CLI target list." + } + + return @(Convert-StdOutToJson -Result $result -Label "list --json") +} + +function Get-DescribePayload { + param( + [Parameter(Mandatory = $true)] + [string]$Target + ) + + if ($script:DescribeCache.ContainsKey($Target)) { + return $script:DescribeCache[$Target] + } + + $result = Invoke-MoldflowCliCommand -Title ("discovery describe {0}" -f $Target) -Args @("describe", $Target, "--json") -Quiet + if (-not $result.Passed) { + return $null + } + + $payload = Convert-StdOutToJson -Result $result -Label ("describe {0}" -f $Target) + $script:DescribeCache[$Target] = $payload + return $payload +} + +function Test-ContainsComplexValue { + param( + [AllowNull()] + [object]$Value + ) + + if ($null -eq $Value) { + return $false + } + + if ($Value -is [string] -or $Value -is [ValueType]) { + return $false + } + + if ($Value -is [System.Collections.IDictionary]) { + return $true + } + + if (@(Get-ObjectProperties -Object $Value).Count -gt 0) { + return $true + } + + if (Test-IsEnumerableLike -Value $Value) { + return $true + } + + return $false +} + +function Test-ExampleMapIsPrimitiveOnly { + param( + [AllowNull()] + [object]$Map + ) + + if ($null -eq $Map) { + return $false + } + + $properties = @(Get-ObjectProperties -Object $Map) + foreach ($property in $properties) { + if (Test-ContainsComplexValue -Value $property.Value) { + return $false + } + } + + return @($properties).Count -gt 0 +} + +function Test-ExampleMapHasComplexValues { + param( + [AllowNull()] + [object]$Map + ) + + if ($null -eq $Map) { + return $false + } + + foreach ($property in (Get-ObjectProperties -Object $Map)) { + if (Test-ContainsComplexValue -Value $property.Value) { + return $true + } + } + + return $false +} + +function Get-ParamInfoMap { + param( + [Parameter(Mandatory = $true)] + [object]$DescribePayload + ) + + $map = @{} + $params = Get-OptionalPropertyValue -Object $DescribePayload -Name "params" + foreach ($param in @($params)) { + $map[$param.name] = $param + } + + if (-not $map.ContainsKey("value") -and (Get-OptionalPropertyValue -Object $DescribePayload -Name "mode") -eq "property_assignment") { + $map["value"] = [pscustomobject]@{ + name = "value" + annotation = "str" + } + } + + return $map +} + +function Get-SampleValue { + param( + [string]$Annotation, + [string]$NameHint, + [switch]$ForJson + ) + + $annotationText = [string]$Annotation + $hint = ([string]$NameHint).ToLowerInvariant() + + if ($annotationText -match 'EntList') { + if ($ForJson) { + return @{ entity_string = "N1,N2" } + } + + return "N1,N2" + } + + if ($annotationText -match 'VectorArray') { + if ($ForJson) { + return @{ xyz = @(@(0.0, 0.0, 0.0), @(1.0, 0.0, 0.0)) } + } + + return "0,0,0;1,0,0" + } + + if ($annotationText -match 'Vector') { + if ($ForJson) { + return @{ xyz = @(0.0, 0.0, 1.0) } + } + + return "0,0,1" + } + + if ($annotationText -match 'DoubleArray|IntegerArray|StringArray') { + if ($ForJson) { + if ($annotationText -match 'StringArray') { + return @{ values = @("alpha", "beta") } + } + + return @{ values = @(1.0, 2.5) } + } + + if ($annotationText -match 'StringArray') { + return "alpha,beta" + } + + return "1.0,2.5" + } + + if ($annotationText -match 'bool') { + if ($ForJson) { + return $true + } + + return "true" + } + + if ($annotationText -match 'int') { + if ($ForJson) { + return 7 + } + + return "7" + } + + if ($annotationText -match 'float|double') { + if ($ForJson) { + return 1.5 + } + + return "1.5" + } + + if ($hint -match 'path|file') { + return "C:/Temp/demo.mfproj" + } + + if ($hint -match 'plot|dataset|study|project|name|label') { + return "Demo" + } + + if ($hint -match 'mesh_type|type') { + return "3D" + } + + if ($ForJson) { + return "demo" + } + + return "demo" +} + +function Resolve-ExampleValue { + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object]$Value, + + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$CurrentKey, + + [Parameter(Mandatory = $true)] + [hashtable]$ParamInfo, + + [switch]$ForJson + ) + + if ($null -eq $Value) { + $param = $null + if ($ParamInfo.ContainsKey($CurrentKey)) { + $param = $ParamInfo[$CurrentKey] + } + + $annotation = if ($null -ne $param) { [string](Get-OptionalPropertyValue -Object $param -Name "annotation") } else { "" } + return Get-SampleValue -Annotation $annotation -NameHint $CurrentKey -ForJson:$ForJson + } + + if ($Value -is [string]) { + if ($Value -match '^<([^>]+)>$') { + $token = $matches[1] + $param = $null + if ($ParamInfo.ContainsKey($CurrentKey)) { + $param = $ParamInfo[$CurrentKey] + } + elseif ($ParamInfo.ContainsKey($token)) { + $param = $ParamInfo[$token] + } + + $annotation = if ($null -ne $param) { [string](Get-OptionalPropertyValue -Object $param -Name "annotation") } else { "" } + return Get-SampleValue -Annotation $annotation -NameHint $token -ForJson:$ForJson + } + + return $Value + } + + if ($Value -is [System.Collections.IDictionary]) { + $out = @{} + foreach ($key in $Value.Keys) { + $out[$key] = Resolve-ExampleValue -Value $Value[$key] -CurrentKey ([string]$key) -ParamInfo $ParamInfo -ForJson:$ForJson + } + + return $out + } + + if (@(Get-ObjectProperties -Object $Value).Count -gt 0 -and -not (Test-IsEnumerableLike -Value $Value)) { + $out = @{} + foreach ($property in (Get-ObjectProperties -Object $Value)) { + $out[$property.Name] = Resolve-ExampleValue -Value $property.Value -CurrentKey $property.Name -ParamInfo $ParamInfo -ForJson:$ForJson + } + + return $out + } + + if (Test-IsEnumerableLike -Value $Value) { + $items = @() + foreach ($item in $Value) { + $items += Resolve-ExampleValue -Value $item -CurrentKey $CurrentKey -ParamInfo $ParamInfo -ForJson:$ForJson + } + + return $items + } + + return $Value +} + +function Get-InvokeCliArgs { + param( + [Parameter(Mandatory = $true)] + [object]$DescribePayload + ) + + $examples = Get-OptionalPropertyValue -Object $DescribePayload -Name "invoke_examples" + $cliArgs = @(Get-OptionalPropertyValue -Object $examples -Name "cli_args") + $paramInfo = Get-ParamInfoMap -DescribePayload $DescribePayload + $resolvedArgs = @() + + foreach ($arg in $cliArgs) { + if ($arg -match '^(?[^=]+)=(?.*)$') { + $name = $matches['name'] + $value = $matches['value'] + if ($value -match '^<([^>]+)>$') { + $token = $matches[1] + $param = $null + if ($paramInfo.ContainsKey($name)) { + $param = $paramInfo[$name] + } + elseif ($paramInfo.ContainsKey($token)) { + $param = $paramInfo[$token] + } + + $annotation = if ($null -ne $param) { [string](Get-OptionalPropertyValue -Object $param -Name "annotation") } else { "" } + $sample = Get-SampleValue -Annotation $annotation -NameHint $token + $resolvedArgs += ("{0}={1}" -f $name, $sample) + } + else { + $resolvedArgs += $arg + } + } + else { + $resolvedArgs += $arg + } + } + + return $resolvedArgs +} + +function Get-InvokeJsonPayload { + param( + [Parameter(Mandatory = $true)] + [object]$DescribePayload + ) + + $examples = Get-OptionalPropertyValue -Object $DescribePayload -Name "invoke_examples" + $preferred = Get-OptionalPropertyValue -Object $examples -Name "params_json" + if ($null -eq $preferred) { + $preferred = Get-OptionalPropertyValue -Object $DescribePayload -Name "params_json_template" + } + + $paramInfo = Get-ParamInfoMap -DescribePayload $DescribePayload + return Resolve-ExampleValue -Value $preferred -CurrentKey "" -ParamInfo $paramInfo -ForJson +} + +function ConvertTo-StepPrefixedCliArgs { + param( + [Parameter(Mandatory = $true)] + [string]$Target, + + [Parameter(Mandatory = $true)] + [string[]]$Args + ) + + $stepName = ($Target -split '\.')[-1].ToUpperInvariant() + $prefixed = @() + foreach ($arg in $Args) { + if ($arg -match '^(?[^=]+)=(?.*)$') { + $name = $matches['name'] + $value = $matches['value'] + if ($name -match '\.') { + $prefixed += $arg + } + else { + $prefixed += ("{0}.{1}={2}" -f $stepName, $name, $value) + } + } + else { + $prefixed += $arg + } + } + + return $prefixed +} + +function ConvertTo-NestedStepJsonPayload { + param( + [Parameter(Mandatory = $true)] + [string]$Target, + + [Parameter(Mandatory = $true)] + [object]$Payload + ) + + $stepName = ($Target -split '\.')[-1].ToUpperInvariant() + return @{ $stepName = $Payload } +} + +function New-JsonArtifact { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$BaseName, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [object]$Payload + ) + + $path = Join-Path $artifactDir ("{0}.json" -f $BaseName) + $jsonText = $Payload | ConvertTo-Json -Depth 20 + if ($PSCmdlet.ShouldProcess($path, "Create JSON artifact")) { + Set-Content -Path $path -Value $jsonText -Encoding UTF8 + } + return $path +} + +function Show-JsonArtifactPreview { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Label + ) + + Write-Output "" + Write-Output ("=== {0} ===" -f $Label) + Write-Output ("Path: {0}" -f $Path) + $lines = @(Read-TextFileLines -Path $Path) + if (@($lines).Count -eq 0) { + Write-Output "(empty file)" + return + } + $lines | ForEach-Object { Write-Output $_ } +} + +function ConvertTo-NativeJsonArgument { + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object]$Payload + ) + + return ($Payload | ConvertTo-Json -Depth 20 -Compress) +} + +function ConvertTo-CliPath { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $full = [System.IO.Path]::GetFullPath($Path) + return ($full -replace '\\', '/') +} + +function New-DemoAsciiStlBox { + <# + Writes a watertight axis-aligned box (mm) as ASCII STL for Moldflow import demos. + + Supports -WhatIf / -Confirm via ShouldProcess on the file write. Nested helper uses + PositionalBinding=$false so static analysis does not treat it as positionally invocable. + #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [double]$SizeX = 100.0, + [double]$SizeY = 60.0, + [double]$SizeZ = 15.0 + ) + + $c = [System.Globalization.CultureInfo]::InvariantCulture + function LocalFormat([double]$v) { return $v.ToString($c) } + + function Write-StlFacet { + [CmdletBinding(PositionalBinding = $false)] + param( + [Parameter(Mandatory = $true)] + [System.Text.StringBuilder]$Sb, + + [Parameter(Mandatory = $true)] + [double]$nx, + + [Parameter(Mandatory = $true)] + [double]$ny, + + [Parameter(Mandatory = $true)] + [double]$nz, + + [Parameter(Mandatory = $true)] + [double[]]$p1, + + [Parameter(Mandatory = $true)] + [double[]]$p2, + + [Parameter(Mandatory = $true)] + [double[]]$p3 + ) + [void]$Sb.AppendLine((" facet normal {0} {1} {2}" -f (LocalFormat $nx), (LocalFormat $ny), (LocalFormat $nz))) + [void]$Sb.AppendLine(" outer loop") + [void]$Sb.AppendLine((" vertex {0} {1} {2}" -f (LocalFormat $p1[0]), (LocalFormat $p1[1]), (LocalFormat $p1[2]))) + [void]$Sb.AppendLine((" vertex {0} {1} {2}" -f (LocalFormat $p2[0]), (LocalFormat $p2[1]), (LocalFormat $p2[2]))) + [void]$Sb.AppendLine((" vertex {0} {1} {2}" -f (LocalFormat $p3[0]), (LocalFormat $p3[1]), (LocalFormat $p3[2]))) + [void]$Sb.AppendLine(" endloop") + [void]$Sb.AppendLine(" endfacet") + } + + $x0 = 0.0; $x1 = $SizeX + $y0 = 0.0; $y1 = $SizeY + $z0 = 0.0; $z1 = $SizeZ + + $b000 = @($x0, $y0, $z0); $b100 = @($x1, $y0, $z0); $b110 = @($x1, $y1, $z0); $b010 = @($x0, $y1, $z0) + $t000 = @($x0, $y0, $z1); $t100 = @($x1, $y0, $z1); $t110 = @($x1, $y1, $z1); $t010 = @($x0, $y1, $z1) + + $sb = New-Object System.Text.StringBuilder + [void]$sb.AppendLine("solid moldflow_cli_demo") + Write-StlFacet -Sb $sb -nx 0 -ny 0 -nz -1 -p1 $b000 -p2 $b110 -p3 $b100 + Write-StlFacet -Sb $sb -nx 0 -ny 0 -nz -1 -p1 $b000 -p2 $b010 -p3 $b110 + Write-StlFacet -Sb $sb -nx 0 -ny 0 -nz 1 -p1 $t000 -p2 $t100 -p3 $t110 + Write-StlFacet -Sb $sb -nx 0 -ny 0 -nz 1 -p1 $t000 -p2 $t110 -p3 $t010 + Write-StlFacet -Sb $sb -nx -1 -ny 0 -nz 0 -p1 $b000 -p2 $t000 -p3 $t010 + Write-StlFacet -Sb $sb -nx -1 -ny 0 -nz 0 -p1 $b000 -p2 $t010 -p3 $b010 + Write-StlFacet -Sb $sb -nx 1 -ny 0 -nz 0 -p1 $b100 -p2 $t110 -p3 $t100 + Write-StlFacet -Sb $sb -nx 1 -ny 0 -nz 0 -p1 $b100 -p2 $b110 -p3 $t110 + Write-StlFacet -Sb $sb -nx 0 -ny -1 -nz 0 -p1 $b000 -p2 $t100 -p3 $t000 + Write-StlFacet -Sb $sb -nx 0 -ny -1 -nz 0 -p1 $b000 -p2 $b100 -p3 $t100 + Write-StlFacet -Sb $sb -nx 0 -ny 1 -nz 0 -p1 $b010 -p2 $t010 -p3 $t110 + Write-StlFacet -Sb $sb -nx 0 -ny 1 -nz 0 -p1 $b010 -p2 $t110 -p3 $b110 + [void]$sb.AppendLine("endsolid moldflow_cli_demo") + if ($PSCmdlet.ShouldProcess($Path, "Write demo ASCII STL box")) { + [System.IO.File]::WriteAllText($Path, $sb.ToString(), [System.Text.UTF8Encoding]::new($false)) + } +} + +function Find-RepresentativeTarget { + param( + [Parameter(Mandatory = $true)] + [string]$Category, + + [Parameter(Mandatory = $true)] + [object[]]$Rows, + + [Parameter(Mandatory = $true)] + [scriptblock]$Predicate, + + [Parameter(Mandatory = $true)] + [scriptblock]$QuickFilter, + + [string[]]$PreferredTargets = @(), + + [string[]]$UsedTargets = @() + ) + + foreach ($target in $PreferredTargets) { + $row = $Rows | Where-Object { $_.target -eq $target } | Select-Object -First 1 + if ($null -eq $row) { + continue + } + + $describe = Get-DescribePayload -Target $row.target + if ($null -eq $describe) { + continue + } + + if (& $Predicate $row $describe) { + return [pscustomobject]@{ + Category = $Category + Row = $row + Describe = $describe + } + } + } + + $candidates = $Rows | Where-Object { (& $QuickFilter $_) -and ($UsedTargets -notcontains $_.target) } | Sort-Object target + foreach ($row in $candidates) { + $describe = Get-DescribePayload -Target $row.target + if ($null -eq $describe) { + continue + } + + if (& $Predicate $row $describe) { + return [pscustomobject]@{ + Category = $Category + Row = $row + Describe = $describe + } + } + } + + return $null +} + +function Add-Case { + param( + [AllowEmptyCollection()] + [System.Collections.Generic.List[object]]$Cases, + + [Parameter(Mandatory = $true)] + [string]$Group, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string[]]$Args, + + [ValidateSet("success", "failure")] + [string]$ExpectedOutcome = "success", + + [switch]$CaptureTrace, + + [string]$CompatibilityStream, + + [AllowEmptyCollection()] + [string[]]$CompatibilityExpectedText = @(), + + [string]$WorkflowPhase = "" + ) + + $null = $Cases.Add([pscustomobject]@{ + WorkflowPhase = $WorkflowPhase + Group = $Group + Name = $Name + Args = $Args + ExpectedOutcome = $ExpectedOutcome + CaptureTrace = $CaptureTrace.IsPresent + CompatibilityStream = $CompatibilityStream + CompatibilityExpectedText = @($CompatibilityExpectedText) + }) +} + +Push-Location $repoRoot +try { + # Full import/solve/results path only when Synergy + project lifecycle smoke is active. + $runSTLWorkflow = -not $SkipSTLWorkflow -and -not $SkipRealSmoke -and -not $SkipProjectSmoke + + $null = New-Item -ItemType Directory -Path $artifactDir -Force + + if ($env:PYTHONPATH) { + $env:PYTHONPATH = "$repoRoot\src;$($env:PYTHONPATH)" + } + else { + $env:PYTHONPATH = "$repoRoot\src" + } + + $script:Launcher = Resolve-CliLauncher -PreferredExecutable $MoldflowExe + + Write-Output "Moldflow CLI workflow demo (user-journey order + regression checks)" + Write-Output "Launcher: $($script:Launcher.Label)" + Write-Output "Repo source override: $repoRoot\src" + Write-Output "Artifacts: $artifactDir" + Write-Output "" + Write-Output "Story: connect to the CLI, discover targets, document/dry-run typical study work" + Write-Output "(open/create project, mesh, solver status, loads, plots), then optional live Synergy" + Write-Output "steps, a batch JSON 'script', and finally intentional error cases." + if ($runSTLWorkflow) { + Write-Output "STL workflow (default with live project): demo block STL will be written, imported, analyzed, and result/plot APIs queried." + } + elseif (-not $SkipRealSmoke -and -not $SkipProjectSmoke -and $SkipSTLWorkflow) { + Write-Output "STL workflow skipped (-SkipSTLWorkflow): using mesh_type-only project_live checks." + } + + $rows = Get-ListPayload + $usedTargets = @() + $selectedTargets = @{} + + $selectionSpecs = @( + @{ + Category = "zero_arg_method" + PreferredTargets = @("synergy.study_doc.is_analysis_running", "synergy.study_doc.mesh_status") + QuickFilter = { param($row) $row.kind -eq "method" } + Predicate = { + param($row, $describe) + $row.kind -eq "method" -and (Get-CollectionCount -Value (Get-OptionalPropertyValue -Object $describe -Name "params")) -eq 0 -and $null -eq (Get-OptionalPropertyValue -Object $describe -Name "type") + } + }, + @{ + Category = "primitive_method" + PreferredTargets = @("synergy.open_project") + QuickFilter = { param($row) $row.kind -eq "method" -and $row.target -match '^synergy\.[^.]+$' } + Predicate = { + param($row, $describe) + $row.kind -eq "method" -and + (Get-CollectionCount -Value (Get-OptionalPropertyValue -Object $describe -Name "params")) -gt 0 -and + (Test-ExampleMapIsPrimitiveOnly -Map (Get-OptionalPropertyValue -Object (Get-OptionalPropertyValue -Object $describe -Name "invoke_examples") -Name "params_json")) + } + }, + @{ + Category = "complex_method" + PreferredTargets = @("synergy.boundary_conditions.create_volume_loads", "boundary_conditions.create_edge_loads") + QuickFilter = { param($row) $row.kind -eq "method" } + Predicate = { + param($row, $describe) + $row.kind -eq "method" -and + (Get-CollectionCount -Value (Get-OptionalPropertyValue -Object $describe -Name "params")) -gt 0 -and + (Test-ExampleMapHasComplexValues -Map (Get-OptionalPropertyValue -Object (Get-OptionalPropertyValue -Object $describe -Name "invoke_examples") -Name "params_json")) + } + }, + @{ + Category = "nested_method" + PreferredTargets = @("synergy.plot_manager.find_plot_by_name") + QuickFilter = { param($row) $row.kind -eq "method" -and $row.target -match '^synergy\.[^.]+\.[^.]+$' } + Predicate = { + param($row, $describe) + $row.kind -eq "method" -and $row.target -match '^synergy\.[^.]+\.[^.]+$' -and (Get-CollectionCount -Value (Get-OptionalPropertyValue -Object $describe -Name "params")) -gt 0 + } + }, + @{ + Category = "readonly_property" + PreferredTargets = @() + QuickFilter = { param($row) $row.kind -eq "property" } + Predicate = { param($row, $describe) $row.kind -eq "property" } + }, + @{ + Category = "settable_property" + PreferredTargets = @("synergy.study_doc.mesh_type") + QuickFilter = { param($row) $row.kind -eq "settable_property" } + Predicate = { param($row, $describe) $row.kind -eq "settable_property" } + } + ) + + foreach ($spec in $selectionSpecs) { + $selected = Find-RepresentativeTarget -Category $spec.Category -Rows $rows -Predicate $spec.Predicate -QuickFilter $spec.QuickFilter -PreferredTargets $spec.PreferredTargets -UsedTargets $usedTargets + if ($null -ne $selected) { + $selectedTargets[$spec.Category] = $selected + $usedTargets += $selected.Row.target + } + } + + Write-Output "" + Write-Output "Selected representative targets" + foreach ($category in $selectionSpecs.Category) { + $selected = Get-OptionalPropertyValue -Object $selectedTargets -Name $category + if ($null -eq $selected) { + Write-Output ("- {0}: not found" -f $category) + continue + } + + Write-Output ("- {0}: {1}" -f $category, $selected.Row.target) + } + + $primitive = $selectedTargets["primitive_method"] + $primitiveTarget = $null + $primitiveDescribe = $null + $primitiveCliArgs = $null + $primitiveJsonPayload = $null + $primitiveJsonFile = $null + $primitiveJsonOutFile = $null + if ($null -ne $primitive) { + $primitiveTarget = $primitive.Row.target + $primitiveDescribe = $primitive.Describe + $primitiveCliArgs = @(Get-InvokeCliArgs -DescribePayload $primitiveDescribe) + $primitiveJsonPayload = Get-InvokeJsonPayload -DescribePayload $primitiveDescribe + $primitiveJsonFile = New-JsonArtifact -BaseName "primitive_params" -Payload $primitiveJsonPayload + $primitiveJsonOutFile = Join-Path $artifactDir "primitive_dry_run.json" + } + + $complex = $selectedTargets["complex_method"] + $complexTarget = $null + $complexDescribe = $null + $complexCliArgs = $null + $complexJsonPayload = $null + $complexJsonFile = $null + if ($null -ne $complex) { + $complexTarget = $complex.Row.target + $complexDescribe = $complex.Describe + $complexCliArgs = @(Get-InvokeCliArgs -DescribePayload $complexDescribe) + $complexJsonPayload = Get-InvokeJsonPayload -DescribePayload $complexDescribe + $complexJsonFile = New-JsonArtifact -BaseName "complex_params" -Payload $complexJsonPayload + } + + $nested = $selectedTargets["nested_method"] + $nestedTarget = $null + $nestedDescribe = $null + $nestedCliArgs = $null + $nestedJsonPayload = $null + $nestedPrefixedCliArgs = $null + $nestedStepJsonPayload = $null + if ($null -ne $nested) { + $nestedTarget = $nested.Row.target + $nestedDescribe = $nested.Describe + $nestedCliArgs = @(Get-InvokeCliArgs -DescribePayload $nestedDescribe) + $nestedJsonPayload = Get-InvokeJsonPayload -DescribePayload $nestedDescribe + $nestedPrefixedCliArgs = @(ConvertTo-StepPrefixedCliArgs -Target $nestedTarget -Args $nestedCliArgs) + $nestedStepJsonPayload = ConvertTo-NestedStepJsonPayload -Target $nestedTarget -Payload $nestedJsonPayload + } + + $probeChainTarget = "synergy.plot_manager.find_plot_by_name.get_probe_plot_probe_line" + $probeChainDescribe = Get-DescribePayload -Target $probeChainTarget + $probeChainCliArgs = @( + "find_plot_by_name.plot_name=My Plot" + "get_probe_plot_probe_line.index=0" + "get_probe_plot_probe_line.start_pt.x=0" + "get_probe_plot_probe_line.start_pt.y=0" + "get_probe_plot_probe_line.start_pt.z=0" + "get_probe_plot_probe_line.end_pt.x=10" + "get_probe_plot_probe_line.end_pt.y=0" + "get_probe_plot_probe_line.end_pt.z=0" + ) + $probeChainJsonPayload = @{ + find_plot_by_name = @{ + plot_name = "My Plot" + } + get_probe_plot_probe_line = @{ + index = 0 + start_pt = @{ + x = 0 + y = 0 + z = 0 + } + end_pt = @{ + x = 10 + y = 0 + z = 0 + } + } + } + + $zeroArg = $selectedTargets["zero_arg_method"] + $zeroArgTarget = $null + if ($null -ne $zeroArg) { + $zeroArgTarget = $zeroArg.Row.target + } + + $readonlyProperty = $selectedTargets["readonly_property"] + $readonlyTarget = $null + if ($null -ne $readonlyProperty) { + $readonlyTarget = $readonlyProperty.Row.target + } + + $settableProperty = $selectedTargets["settable_property"] + $settableTarget = $null + $settableDescribe = $null + $settableCliArgs = $null + $settableJsonPayload = $null + if ($null -ne $settableProperty) { + $settableTarget = $settableProperty.Row.target + $settableDescribe = $settableProperty.Describe + $settableCliArgs = @(Get-InvokeCliArgs -DescribePayload $settableDescribe) + $settableJsonPayload = Get-InvokeJsonPayload -DescribePayload $settableDescribe + } + + $realBatchFile = $null + $realBatchJsonOut = $null + if (-not $SkipRealSmoke) { + $realBatchPayload = @( + @{ target = "synergy.build" }, + @{ target = "synergy.version" } + ) + $realBatchFile = New-JsonArtifact -BaseName "real_batch_requests" -Payload $realBatchPayload + $realBatchJsonOut = Join-Path $artifactDir "real_batch_results.json" + } + + $batchFile = $null + $batchJsonOut = $null + if ($null -ne $primitive -and $null -ne $complex) { + $batchPayload = @( + @{ + target = $primitive.Row.target + args = @(Get-InvokeCliArgs -DescribePayload $primitive.Describe) + }, + @{ + target = $complex.Row.target + params_json = (Get-InvokeJsonPayload -DescribePayload $complex.Describe) + } + ) + if ($null -ne $settableProperty) { + $batchPayload += @{ + target = $settableProperty.Row.target + params_json = (Get-InvokeJsonPayload -DescribePayload $settableProperty.Describe) + } + } + $batchFile = New-JsonArtifact -BaseName "batch_requests" -Payload $batchPayload + $batchJsonOut = Join-Path $artifactDir "batch_results.json" + } + + $realProjectName = "CliEval_$timestamp" + $realStudyName = "CliSmokeStudy" + $realMeshTypeJson = @{ value = "3D" } + + $wfSession = "1) Session -- help and installed package version" + $wfDiscover = "2) Discover -- filter list output toward project / mesh / boundary work" + $wfProjectFile = "3) Project file workflow -- compare new vs open, then describe + dry-run open_project" + $wfMesh = "4) Study mesh -- mesh_type describe + planned assignment (dry-run)" + $wfSolver = "5) Solver state -- study_doc probes (multi-describe + is_analysis_running)" + $wfBC = "6) Boundary loads -- describe BC method + shorthand/JSON dry-runs" + $wfPlots = "7) Post results -- nested plot_manager targets" + $wfAppMeta = "8) Read-only app metadata on synergy" + $wfLive = "9) Live Synergy -- real reads, trace, small property batch" + $wfProjectLive = "10) Live project -- create project/study, adjust mesh, poll analysis flag" + $wfSTL = "10b) STL workflow -- import generated solid, solve, inspect results/plot APIs (default; -SkipSTLWorkflow for mesh-only)" + $wfBatchScript = "11) Automation -- batch JSON script (dry-run, file output, misuse checks)" + $wfReg = "12) Regression -- invalid flag mixes and hidden targets (expect failures)" + + $cases = New-Object 'System.Collections.Generic.List[object]' + + Add-Case -Cases $cases -Group "session" -Name "root help" -WorkflowPhase $wfSession -Args @("--help") -CompatibilityStream "stdout" -CompatibilityExpectedText @("Options", "Commands") + Add-Case -Cases $cases -Group "session" -Name "version" -WorkflowPhase $wfSession -Args @("version") + + Add-Case -Cases $cases -Group "discover" -Name "list human filtered open_project" -WorkflowPhase $wfDiscover -Args @("list", "--filter", "synergy.open_project") + Add-Case -Cases $cases -Group "discover" -Name "list json filtered open_project" -WorkflowPhase $wfDiscover -Args @("list", "--filter", "synergy.open_project", "--json") + Add-Case -Cases $cases -Group "discover" -Name "list yaml filtered open_project" -WorkflowPhase $wfDiscover -Args @("list", "--filter", "synergy.open_project", "--yaml") + Add-Case -Cases $cases -Group "discover" -Name "list json mesh and open filters" -WorkflowPhase $wfDiscover -Args @("list", "--json", "-f", "open_project", "-f", "mesh_type") + Add-Case -Cases $cases -Group "discover" -Name "list json boundary-related filters" -WorkflowPhase $wfDiscover -Args @("list", "--json", "-f", "boundary", "-f", "volume") + Add-Case -Cases $cases -Group "discover" -Name "list empty filter human" -WorkflowPhase $wfDiscover -Args @("list", "--filter", "definitely_no_such_target") + + if ($null -ne $primitive) { + Add-Case -Cases $cases -Group "project_io" -Name "workflow describe new_project vs open_project" -WorkflowPhase $wfProjectFile -Args @("describe", "synergy.new_project", "synergy.open_project") + Add-Case -Cases $cases -Group "project_io" -Name "describe open_project bare target" -WorkflowPhase $wfProjectFile -Args @("describe", "open_project") + Add-Case -Cases $cases -Group "project_io" -Name "describe open_project mixed case json" -WorkflowPhase $wfProjectFile -Args @("describe", "OPEN_PROJECT", "--json") + Add-Case -Cases $cases -Group "project_io" -Name "describe open_project human" -WorkflowPhase $wfProjectFile -Args @("describe", $primitiveTarget) + Add-Case -Cases $cases -Group "project_io" -Name "describe open_project json" -WorkflowPhase $wfProjectFile -Args @("describe", $primitiveTarget, "--json") + Add-Case -Cases $cases -Group "project_io" -Name "describe open_project yaml" -WorkflowPhase $wfProjectFile -Args @("describe", $primitiveTarget, "--yaml") + Add-Case -Cases $cases -Group "project_io" -Name "describe open_project schema" -WorkflowPhase $wfProjectFile -Args @("describe", $primitiveTarget, "--schema") + + Add-Case -Cases $cases -Group "project_io" -Name "dry-run open_project bare target" -WorkflowPhase $wfProjectFile -Args (@("invoke", "open_project", "--dry-run") + $primitiveCliArgs) + Add-Case -Cases $cases -Group "project_io" -Name "dry-run open_project mixed case target" -WorkflowPhase $wfProjectFile -Args (@("invoke", "OPEN_PROJECT", "--dry-run") + $primitiveCliArgs) + Add-Case -Cases $cases -Group "project_io" -Name "dry-run open_project human" -WorkflowPhase $wfProjectFile -Args (@("invoke", $primitiveTarget, "--dry-run") + $primitiveCliArgs) + Add-Case -Cases $cases -Group "project_io" -Name "dry-run open_project json alias" -WorkflowPhase $wfProjectFile -Args (@("invoke", $primitiveTarget, "--dry-run", "--json") + $primitiveCliArgs) + Add-Case -Cases $cases -Group "project_io" -Name "dry-run open_project json-output" -WorkflowPhase $wfProjectFile -Args (@("invoke", $primitiveTarget, "--dry-run", "--json-output") + $primitiveCliArgs) + Add-Case -Cases $cases -Group "project_io" -Name "dry-run open_project inline params-json" -WorkflowPhase $wfProjectFile -Args @("invoke", $primitiveTarget, "--dry-run", "--params-json", (ConvertTo-NativeJsonArgument -Payload $primitiveJsonPayload)) + Add-Case -Cases $cases -Group "project_io" -Name "dry-run open_project params-json file short -J" -WorkflowPhase $wfProjectFile -Args @("invoke", $primitiveTarget, "--dry-run", "-J", $primitiveJsonFile) + Add-Case -Cases $cases -Group "project_io" -Name "dry-run open_project params-json file" -WorkflowPhase $wfProjectFile -Args @("invoke", $primitiveTarget, "--dry-run", "--params-json-file", $primitiveJsonFile) + Add-Case -Cases $cases -Group "project_io" -Name "dry-run open_project json-file-output" -WorkflowPhase $wfProjectFile -Args (@("invoke", $primitiveTarget, "--dry-run", "--json-file-output", $primitiveJsonOutFile) + $primitiveCliArgs) + Add-Case -Cases $cases -Group "project_io" -Name "dry-run open_project trace" -WorkflowPhase $wfProjectFile -Args (@("invoke", $primitiveTarget, "--dry-run", "--trace", "--json-output") + $primitiveCliArgs) -CaptureTrace + } + + if ($null -ne $settableProperty) { + Add-Case -Cases $cases -Group "mesh" -Name "describe mesh_type human" -WorkflowPhase $wfMesh -Args @("describe", $settableTarget) + Add-Case -Cases $cases -Group "mesh" -Name "describe mesh_type json" -WorkflowPhase $wfMesh -Args @("describe", $settableTarget, "--json") + Add-Case -Cases $cases -Group "mesh" -Name "dry-run mesh_type args" -WorkflowPhase $wfMesh -Args (@("invoke", $settableTarget, "--dry-run") + $settableCliArgs) + Add-Case -Cases $cases -Group "mesh" -Name "dry-run mesh_type params-json" -WorkflowPhase $wfMesh -Args @("invoke", $settableTarget, "--dry-run", "--params-json", (ConvertTo-NativeJsonArgument -Payload $settableJsonPayload)) + } + + if ($null -ne $primitive -and $null -ne $zeroArg) { + Add-Case -Cases $cases -Group "solver" -Name "describe open_project and is_analysis_running json" -WorkflowPhase $wfSolver -Args @("describe", $primitive.Row.target, $zeroArgTarget, "--json") + } + if ($null -ne $zeroArg) { + Add-Case -Cases $cases -Group "solver" -Name "describe is_analysis_running human" -WorkflowPhase $wfSolver -Args @("describe", $zeroArgTarget) + Add-Case -Cases $cases -Group "solver" -Name "dry-run is_analysis_running human" -WorkflowPhase $wfSolver -Args @("invoke", $zeroArgTarget, "--dry-run") + Add-Case -Cases $cases -Group "solver" -Name "dry-run is_analysis_running json-output" -WorkflowPhase $wfSolver -Args @("invoke", $zeroArgTarget, "--dry-run", "--json-output") + } + + if ($null -ne $complex) { + Add-Case -Cases $cases -Group "loads" -Name "describe create_volume_loads human" -WorkflowPhase $wfBC -Args @("describe", $complexTarget) + Add-Case -Cases $cases -Group "loads" -Name "describe create_volume_loads json" -WorkflowPhase $wfBC -Args @("describe", $complexTarget, "--json") + Add-Case -Cases $cases -Group "loads" -Name "dry-run volume loads shorthand" -WorkflowPhase $wfBC -Args (@("invoke", $complexTarget, "--dry-run") + $complexCliArgs) + Add-Case -Cases $cases -Group "loads" -Name "dry-run volume loads params-json" -WorkflowPhase $wfBC -Args @("invoke", $complexTarget, "--dry-run", "--params-json", (ConvertTo-NativeJsonArgument -Payload $complexJsonPayload)) + Add-Case -Cases $cases -Group "loads" -Name "dry-run volume loads params-json file" -WorkflowPhase $wfBC -Args @("invoke", $complexTarget, "--dry-run", "--params-json-file", $complexJsonFile) + } + + if ($null -ne $nested) { + Add-Case -Cases $cases -Group "plots" -Name "describe find_plot_by_name human" -WorkflowPhase $wfPlots -Args @("describe", $nestedTarget) + Add-Case -Cases $cases -Group "plots" -Name "describe find_plot_by_name json" -WorkflowPhase $wfPlots -Args @("describe", $nestedTarget, "--json") + Add-Case -Cases $cases -Group "plots" -Name "dry-run find_plot mixed case target" -WorkflowPhase $wfPlots -Args (@("invoke", "PLOT_MANAGER.FIND_PLOT_BY_NAME", "--dry-run") + $nestedCliArgs) + Add-Case -Cases $cases -Group "plots" -Name "dry-run find_plot direct args" -WorkflowPhase $wfPlots -Args (@("invoke", $nestedTarget, "--dry-run") + $nestedCliArgs) + Add-Case -Cases $cases -Group "plots" -Name "dry-run find_plot step-prefixed args" -WorkflowPhase $wfPlots -Args (@("invoke", $nestedTarget, "--dry-run") + $nestedPrefixedCliArgs) + Add-Case -Cases $cases -Group "plots" -Name "dry-run find_plot step json" -WorkflowPhase $wfPlots -Args @("invoke", $nestedTarget, "--dry-run", "--params-json", (ConvertTo-NativeJsonArgument -Payload $nestedStepJsonPayload)) + } + if ($null -ne $probeChainDescribe) { + Add-Case -Cases $cases -Group "plots" -Name "dry-run probe plot probe line raw chain args" -WorkflowPhase $wfPlots -Args (@("invoke", $probeChainTarget, "--dry-run") + $probeChainCliArgs) + Add-Case -Cases $cases -Group "plots" -Name "dry-run probe plot probe line grouped params-json" -WorkflowPhase $wfPlots -Args @("invoke", $probeChainTarget, "--dry-run", "--params-json", (ConvertTo-NativeJsonArgument -Payload $probeChainJsonPayload)) + } + + if ($null -ne $readonlyProperty) { + Add-Case -Cases $cases -Group "app_meta" -Name "describe build property human" -WorkflowPhase $wfAppMeta -Args @("describe", $readonlyTarget) + Add-Case -Cases $cases -Group "app_meta" -Name "describe build property json" -WorkflowPhase $wfAppMeta -Args @("describe", $readonlyTarget, "--json") + Add-Case -Cases $cases -Group "app_meta" -Name "dry-run build property" -WorkflowPhase $wfAppMeta -Args @("invoke", $readonlyTarget, "--dry-run") + } + + if (-not $SkipRealSmoke) { + Add-Case -Cases $cases -Group "live" -Name "invoke synergy.build json-output" -WorkflowPhase $wfLive -Args @("invoke", "synergy.build", "--json-output") + Add-Case -Cases $cases -Group "live" -Name "invoke synergy.version json-output" -WorkflowPhase $wfLive -Args @("invoke", "synergy.version", "--json-output") + Add-Case -Cases $cases -Group "live" -Name "invoke synergy.build trace json" -WorkflowPhase $wfLive -Args @("invoke", "synergy.build", "--trace", "--json") -CaptureTrace + Add-Case -Cases $cases -Group "live" -Name "invoke synergy.version no-fail-on-false" -WorkflowPhase $wfLive -Args @("invoke", "synergy.version", "--json-output", "--no-fail-on-false") + Add-Case -Cases $cases -Group "live" -Name "invoke batch-file build and version json-output" -WorkflowPhase $wfLive -Args @("invoke", "--batch-file", $realBatchFile, "--json-output") + Add-Case -Cases $cases -Group "live" -Name "invoke batch-file build and version json-file-output" -WorkflowPhase $wfLive -Args @("invoke", "--batch-file", $realBatchFile, "--json-file-output", $realBatchJsonOut) + + if (-not $SkipProjectSmoke) { + $addFileJsonArg = $null + if ($runSTLWorkflow) { + $stlDiskPath = Join-Path $artifactDir "cli_demo_block.stl" + New-DemoAsciiStlBox -Path $stlDiskPath + $stlCliPath = ConvertTo-CliPath -Path $stlDiskPath + Write-Output "" + Write-Output ("Demo STL (100x60x15 mm box) written to: {0}" -f $stlDiskPath) + $addFilePayload = @{ + name = $stlCliPath + show_logs = $false + opts = @{ + __type__ = "ImportOptions" + mesh_type = "3D" + mdl_mesh = $true + units = "mm" + } + } + $addFileJsonArg = ConvertTo-NativeJsonArgument -Payload $addFilePayload + # Injection NDBC at top-center of demo box (mm); prop_type = 40000. + # Vector params use signature coercion - xyz triplets, no __type__ (see describe create_ndbc_at_xyz). + $injNdbcPayload = @{ + coord = @{ xyz = @(50.0, 30.0, 15.0) } + normal = @{ xyz = @(0.0, 0.0, -1.0) } + prop_type = 40000 + } + $injNdbcJsonArg = ConvertTo-NativeJsonArgument -Payload $injNdbcPayload + $probePlotGetPayload = @{ + find_plot_by_name = @{ + plot_name = "Title:Probe XYPlot" + } + get_probe_plot_probe_line = @{ + index = 1 + start_pt = @{ + __type__ = "Vector" + } + end_pt = @{ + __type__ = "Vector" + } + } + } + $probePlotGetJsonArg = ConvertTo-NativeJsonArgument -Payload $probePlotGetPayload + } + + Add-Case -Cases $cases -Group "project_live" -Name "invoke new_project" -WorkflowPhase $wfProjectLive -Args @("invoke", "synergy.new_project", "name=$realProjectName", "path=$artifactDir", "--json-output") + Add-Case -Cases $cases -Group "project_live" -Name "invoke project new_study" -WorkflowPhase $wfProjectLive -Args @("invoke", "synergy.project.new_study", "study_name=$realStudyName", "--json-output") + + if ($runSTLWorkflow) { + Add-Case -Cases $cases -Group "project_live" -Name "stl set mesh_type 3D" -WorkflowPhase $wfSTL -Args @("invoke", "synergy.study_doc.mesh_type", "value=3D", "--json-output") + Add-Case -Cases $cases -Group "project_live" -Name "stl read analysis_sequence" -WorkflowPhase $wfSTL -Args @("invoke", "synergy.study_doc.analysis_sequence", "--json-output") + # Read molding process only: new_study already targets thermoplastic injection: setting the property + # often fails on live Synergy (redundant or study-state guard) and is not required for mesh/solve. + Add-Case -Cases $cases -Group "project_live" -Name "stl read molding_process" -WorkflowPhase $wfSTL -Args @("invoke", "synergy.study_doc.molding_process", "--json-output") + Add-Case -Cases $cases -Group "project_live" -Name "stl study_doc add_file" -WorkflowPhase $wfSTL -Args @("invoke", "synergy.study_doc.add_file", "--params-json", $addFileJsonArg, "--json-output") + # 3D flow needs a volume mesh before placing nodal BCs; runner_generator is not used for this solid import path. + Add-Case -Cases $cases -Group "project_live" -Name "stl study_doc mesh_now" -WorkflowPhase $wfSTL -Args @("invoke", "synergy.study_doc.mesh_now", "show_prompts=false", "--json-output") + + Add-Case -Cases $cases -Group "project_live" -Name "stl boundary_conditions create_ndbc_at_xyz injection" -WorkflowPhase $wfSTL -Args @("invoke", "synergy.boundary_conditions.create_ndbc_at_xyz", "--params-json", $injNdbcJsonArg, "--json-output") + Add-Case -Cases $cases -Group "project_live" -Name "stl analyze_now solve" -WorkflowPhase $wfSTL -Args @("invoke", "synergy.study_doc.analyze_now", "check=false", "solve=true", "prompts=false", "--json-output", "--no-fail-on-false") + Add-Case -Cases $cases -Group "project_live" -Name "stl plot_manager add_default_plots" -WorkflowPhase $wfSTL -Args @("invoke", "synergy.plot_manager.add_default_plots", "--json-output", "--no-fail-on-false") + Add-Case -Cases $cases -Group "project_live" -Name "stl plot_manager get_number_of_results_files" -WorkflowPhase $wfSTL -Args @("invoke", "synergy.plot_manager.get_number_of_results_files", "--json-output") + Add-Case -Cases $cases -Group "project_live" -Name "stl plot_manager get_results_file_name index 0" -WorkflowPhase $wfSTL -Args @("invoke", "synergy.plot_manager.get_results_file_name", "index=0", "--json-output") + Add-Case -Cases $cases -Group "project_live" -Name "stl plot_manager get_first_plot" -WorkflowPhase $wfSTL -Args @("invoke", "synergy.plot_manager.get_first_plot", "--json-output") + Add-Case -Cases $cases -Group "project_live" -Name "stl delete existing probe xy plot" -WorkflowPhase $wfSTL -Args @( + "invoke", + "synergy.plot_manager.delete_plot_by_name", + "plot_name=Title:Probe XYPlot", + "--json-output", + "--no-fail-on-false" + ) + Add-Case -Cases $cases -Group "project_live" -Name "stl create probe xy plot add_probe_plot_probe_line" -WorkflowPhase $wfSTL -Args @( + "invoke", + "synergy.plot_manager.create_plot_by_ds_id.add_probe_plot_probe_line", + "create_plot_by_ds_id.ds_id=1", + "create_plot_by_ds_id.plot_type=21", + "add_probe_plot_probe_line.start_pt.x=0", + "add_probe_plot_probe_line.start_pt.y=0", + "add_probe_plot_probe_line.start_pt.z=0", + "add_probe_plot_probe_line.end_pt.x=10", + "add_probe_plot_probe_line.end_pt.y=0", + "add_probe_plot_probe_line.end_pt.z=0", + "--json-output" + ) + Add-Case -Cases $cases -Group "project_live" -Name "stl probe xy plot get_probe_plot_probe_line" -WorkflowPhase $wfSTL -Args @( + "invoke", + "synergy.plot_manager.find_plot_by_name.get_probe_plot_probe_line", + "--params-json", + $probePlotGetJsonArg, + "--json-output" + ) + } + else { + Add-Case -Cases $cases -Group "project_live" -Name "invoke study_doc mesh_type read" -WorkflowPhase $wfProjectLive -Args @("invoke", "synergy.study_doc.mesh_type", "--json-output") + Add-Case -Cases $cases -Group "project_live" -Name "invoke study_doc mesh_type set args" -WorkflowPhase $wfProjectLive -Args @("invoke", "synergy.study_doc.mesh_type", "value=3D", "--json-output") + Add-Case -Cases $cases -Group "project_live" -Name "invoke study_doc mesh_type set params-json" -WorkflowPhase $wfProjectLive -Args @("invoke", "synergy.study_doc.mesh_type", "--params-json", (ConvertTo-NativeJsonArgument -Payload $realMeshTypeJson), "--json-output") + } + + if ($null -ne $zeroArg) { + # is_analysis_running returns false when idle; CLI exits 1 on boolean false unless --no-fail-on-false (same as poll loop). + Add-Case -Cases $cases -Group "project_live" -Name "invoke is_analysis_running after mesh change" -WorkflowPhase $wfProjectLive -Args @("invoke", $zeroArgTarget, "--json-output", "--no-fail-on-false") + } + } + } + + if ($null -ne $batchFile) { + Add-Case -Cases $cases -Group "batch_script" -Name "invoke batch-file dry-run json-output" -WorkflowPhase $wfBatchScript -Args @("invoke", "--batch-file", $batchFile, "--dry-run", "--json-output") + Add-Case -Cases $cases -Group "batch_script" -Name "invoke batch-file dry-run json-file-output" -WorkflowPhase $wfBatchScript -Args @("invoke", "--batch-file", $batchFile, "--dry-run", "--json-file-output", $batchJsonOut) -CompatibilityStream "stdout" -CompatibilityExpectedText @("Batch results") + Add-Case -Cases $cases -Group "batch_script" -Name "invoke batch-file misuse with extra target" -WorkflowPhase $wfBatchScript -Args @("invoke", "synergy.build", "--batch-file", $batchFile) -ExpectedOutcome "failure" + Add-Case -Cases $cases -Group "batch_script" -Name "invoke batch-file misuse with params-json" -WorkflowPhase $wfBatchScript -Args @("invoke", "--batch-file", $batchFile, "--params-json", (ConvertTo-NativeJsonArgument -Payload @{ value = "demo" })) -ExpectedOutcome "failure" + } + + Add-Case -Cases $cases -Group "regression" -Name "list json yaml conflict" -WorkflowPhase $wfReg -Args @("list", "--json", "--yaml") -ExpectedOutcome "failure" + + if ($null -ne $primitive) { + Add-Case -Cases $cases -Group "regression" -Name "describe open_project json yaml conflict" -WorkflowPhase $wfReg -Args @("describe", $primitiveTarget, "--json", "--yaml") -ExpectedOutcome "failure" + Add-Case -Cases $cases -Group "regression" -Name "describe open_project schema json conflict" -WorkflowPhase $wfReg -Args @("describe", $primitiveTarget, "--schema", "--json") -ExpectedOutcome "failure" + Add-Case -Cases $cases -Group "regression" -Name "invoke open_project dry-run missing path" -WorkflowPhase $wfReg -Args @("invoke", $primitiveTarget, "--dry-run") -ExpectedOutcome "failure" + Add-Case -Cases $cases -Group "regression" -Name "invoke open_project dry-run bad params-json" -WorkflowPhase $wfReg -Args @("invoke", $primitiveTarget, "--dry-run", "--params-json", "{bad}") -ExpectedOutcome "failure" + Add-Case -Cases $cases -Group "regression" -Name "invoke open_project params-json and file both" -WorkflowPhase $wfReg -Args @("invoke", $primitiveTarget, "--dry-run", "--params-json", (ConvertTo-NativeJsonArgument -Payload $primitiveJsonPayload), "--params-json-file", $primitiveJsonFile) -ExpectedOutcome "failure" + Add-Case -Cases $cases -Group "regression" -Name "invoke open_project params-json and positional" -WorkflowPhase $wfReg -Args @("invoke", $primitiveTarget, "--dry-run", "--params-json", (ConvertTo-NativeJsonArgument -Payload $primitiveJsonPayload), $primitiveCliArgs[0]) -ExpectedOutcome "failure" + Add-Case -Cases $cases -Group "regression" -Name "invoke open_project removed --template" -WorkflowPhase $wfReg -Args @("invoke", $primitiveTarget, "--template") -ExpectedOutcome "failure" + } + + $hiddenTarget = "boundary_conditions.create_entity_list" + Add-Case -Cases $cases -Group "regression" -Name "describe hidden transient wrapper" -WorkflowPhase $wfReg -Args @("describe", $hiddenTarget) -ExpectedOutcome "failure" -CompatibilityStream "stderr" -CompatibilityExpectedText @("Error") + + if (-not $SkipRealSmoke -and -not $SkipProjectSmoke) { + Add-Case -Cases $cases -Group "project_live" -Name "invoke project close cleanup" -WorkflowPhase $wfProjectLive -Args @("invoke", "synergy.project.close", "prompts=false", "--json-output", "--fail-on-false") + } + + Write-Output "" + Write-Output ("Planned cases: {0}" -f $cases.Count) + + if (-not $SkipRealSmoke -and $null -ne $realBatchFile) { + Write-Output "" + Write-Output "=== Preview: live Synergy batch (build / version) ===" + Show-JsonArtifactPreview -Path $realBatchFile -Label "Batch JSON: read synergy.build and synergy.version" + } + if ($null -ne $batchFile) { + Write-Output "" + Write-Output "=== Preview: automation script (multi-step dry-run batch) ===" + Show-JsonArtifactPreview -Path $batchFile -Label "Batch JSON: open_project + volume loads (+ mesh when live project enabled)" + } + + $results = @() + $currentWorkflowPhase = "" + $currentGroup = "" + foreach ($case in $cases) { + $phase = [string]$case.WorkflowPhase + if (-not [string]::IsNullOrWhiteSpace($phase) -and $phase -ne $currentWorkflowPhase) { + $currentWorkflowPhase = $phase + Write-Output "" + Write-Output ("** {0}" -f $currentWorkflowPhase) + } + if ($case.Group -ne $currentGroup) { + $currentGroup = $case.Group + Write-Output "" + Write-Output ("## {0}" -f $currentGroup.ToUpperInvariant()) + } + + $invocationOutput = @( + Invoke-MoldflowCliCommand -Title $case.Name -Args $case.Args -ExpectedOutcome $case.ExpectedOutcome -CaptureTrace:$case.CaptureTrace + ) + foreach ($item in $invocationOutput) { + if ( + $null -eq $item -or + ($item.PSObject -and $null -ne $item.PSObject.Properties["Passed"]) + ) { + continue + } + Write-Output $item + } + $result = @( + $invocationOutput | + Where-Object { + $_ -ne $null -and + $_.PSObject -and + $null -ne $_.PSObject.Properties["Passed"] -and + $null -ne $_.PSObject.Properties["StdOut"] -and + $null -ne $_.PSObject.Properties["StdErr"] + } + )[-1] + if ($null -eq $result) { + throw "Invoke-MoldflowCliCommand did not return a structured result object for case '$($case.Name)'." + } + + Add-Member -InputObject $result -NotePropertyName CompatibilityChecked -NotePropertyValue $false + Add-Member -InputObject $result -NotePropertyName CompatibilityPassed -NotePropertyValue $null + Add-Member -InputObject $result -NotePropertyName CompatibilityMessage -NotePropertyValue $null + Add-Member -InputObject $result -NotePropertyName CompatibilityStream -NotePropertyValue $case.CompatibilityStream + + if (-not [string]::IsNullOrWhiteSpace($case.CompatibilityStream)) { + $compatibility = Test-CapturedOutputCompatibility -Result $result -Stream $case.CompatibilityStream -ExpectedText $case.CompatibilityExpectedText + $result.CompatibilityChecked = $true + $result.CompatibilityPassed = $compatibility.Passed + $result.CompatibilityMessage = $compatibility.Message + + if ($compatibility.Passed) { + Write-Output ("[compat] {0}" -f $compatibility.Message) + } + else { + Write-Output ("[compat-fail] {0}" -f $compatibility.Message) + $result.Passed = $false + if ($FailFast) { + throw "Compatibility check failed: $($case.Name)" + } + } + } + + $results += $result + + if ($case.Name -eq "stl analyze_now solve" -and $result.Passed) { + Wait-MoldflowCliAnalysisIdle -MaxWaitSeconds $AnalysisWaitMaxSeconds -PollSeconds $AnalysisPollSeconds + } + } + + $passedCount = @($results | Where-Object { $_.Passed }).Count + $failedCount = @($results | Where-Object { -not $_.Passed }).Count + + Write-Output "" + Write-Output "Summary" + $results | + Select-Object Title, ExpectedOutcome, ExitCode, Passed, @{Name = "Compat"; Expression = { + if (-not $_.CompatibilityChecked) { + "" + } + elseif ($_.CompatibilityPassed) { + "ascii" + } + else { + "failed" + } + }} | + Format-Table -AutoSize | + Out-String | + Write-Output + + $compatibilityResults = @($results | Where-Object { $_.CompatibilityChecked }) + if (@($compatibilityResults).Count -gt 0) { + Write-Output "" + Write-Output "Redirected output compatibility" + $compatibilityResults | + Select-Object Title, CompatibilityStream, @{Name = "Status"; Expression = { + if ($_.CompatibilityPassed) { + "ascii-safe" + } + else { + "failed" + } + }}, @{Name = "Details"; Expression = { $_.CompatibilityMessage }} | + Format-Table -Wrap -AutoSize | + Out-String | + Write-Output + } + + Write-Output ("Passed: {0}" -f $passedCount) + if ($failedCount -gt 0) { + Write-Output ("Failed: {0}" -f $failedCount) + $results | Where-Object { -not $_.Passed } | ForEach-Object { + Write-Output ("- {0}" -f $_.Title) + } + throw "CLI surface evaluation completed with failures." + } + + Write-Output ("Failed: {0}" -f $failedCount) + Write-Output "" + Write-Output "Evaluation complete." +} +finally { + Pop-Location +} diff --git a/setup.cfg.in b/setup.cfg.in index 774836a..7d410ff 100644 --- a/setup.cfg.in +++ b/setup.cfg.in @@ -42,3 +42,14 @@ where = src [options.package_data] moldflow = locale/**/LC_MESSAGES/*.mo + +[options.extras_require] +cli = + typer>=0.24.1,<0.25 + rich>=14.3.3,<15 + PyYAML>=6.0.3,<7 + pyreadline3>=3.5,<4; platform_system=='Windows' + +[options.entry_points] +console_scripts = + moldflow = moldflow_cli.__main__:main diff --git a/src/moldflow/animation_export_options.py b/src/moldflow/animation_export_options.py index b6f5369..8a4dde8 100644 --- a/src/moldflow/animation_export_options.py +++ b/src/moldflow/animation_export_options.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + """ Usage: AnimationExportOptions Class API Wrapper diff --git a/src/moldflow/boundary_conditions.py b/src/moldflow/boundary_conditions.py index e01bb13..3e1261c 100644 --- a/src/moldflow/boundary_conditions.py +++ b/src/moldflow/boundary_conditions.py @@ -10,6 +10,7 @@ from .common import LogMessage, AnalysisType, ConstraintType from .helper import check_type, get_enum_value from .com_proxy import safe_com +from .cli_input_metadata import CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY, cli_hidden from .logger import process_log from .ent_list import EntList from .vector import Vector @@ -52,6 +53,7 @@ def _check_vector(self, vector: Vector | None) -> Vector: vector.z = get_enum_value(int(vector.z), ConstraintType) return vector + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_entity_list(self) -> EntList: """ Creates a new entity list. diff --git a/src/moldflow/boundary_list.py b/src/moldflow/boundary_list.py index 78a09f4..5f27bc5 100644 --- a/src/moldflow/boundary_list.py +++ b/src/moldflow/boundary_list.py @@ -6,6 +6,7 @@ BoundaryList Class API Wrapper """ +from .cli_input_metadata import CLI_VALUE_KIND_SELECTION_TEXT, cli_input_adapter from .helper import check_type, check_index from .com_proxy import safe_com from .logger import process_log @@ -27,6 +28,7 @@ def __init__(self, _boundary_list): process_log(__name__, LogMessage.CLASS_INIT, locals(), name="BoundaryList") self.boundary_list = safe_com(_boundary_list) + @cli_input_adapter(value_kind=CLI_VALUE_KIND_SELECTION_TEXT, shorthand_supported=True) def select_from_string(self, value: str) -> None: """ Selects a list of entities from a string diff --git a/src/moldflow/cad_diagnostic.py b/src/moldflow/cad_diagnostic.py index c8e6435..d33e209 100644 --- a/src/moldflow/cad_diagnostic.py +++ b/src/moldflow/cad_diagnostic.py @@ -1,9 +1,13 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + """ Usage: CADDiagnostic Class API Wrapper """ from .logger import process_log, LogMessage +from .cli_input_metadata import CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY, cli_hidden from .double_array import DoubleArray from .ent_list import EntList from .integer_array import IntegerArray @@ -25,6 +29,7 @@ def __init__(self, _cad_diagnostic): process_log(__name__, LogMessage.CLASS_INIT, locals(), name="CADDiagnostic") self.cad_diagnostic = _cad_diagnostic + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_entity_list(self) -> EntList: """ Creates an empty EntList object diff --git a/src/moldflow/cad_manager.py b/src/moldflow/cad_manager.py index f2cfe9f..b5ca8c3 100644 --- a/src/moldflow/cad_manager.py +++ b/src/moldflow/cad_manager.py @@ -8,6 +8,7 @@ from .ent_list import EntList from .vector import Vector +from .cli_input_metadata import CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY, cli_hidden from .logger import process_log, LogMessage from .helper import check_type, coerce_optional_dispatch from .com_proxy import safe_com @@ -28,6 +29,7 @@ def __init__(self, _cad_manager): process_log(__name__, LogMessage.CLASS_INIT, locals(), name="CADManager") self.cad_manager = safe_com(_cad_manager) + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_entity_list(self) -> EntList: """ Creates an empty EntList object diff --git a/src/moldflow/cli_input_metadata.py b/src/moldflow/cli_input_metadata.py new file mode 100644 index 0000000..a66b19e --- /dev/null +++ b/src/moldflow/cli_input_metadata.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Metadata helpers for declaring CLI-specific wrapper behavior.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, TypeVar + + +_CLI_INPUT_ADAPTER_ATTR = "__moldflow_cli_input_adapter__" +_CLI_VISIBILITY_ATTR = "__moldflow_cli_visibility__" +CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY = "transient_wrapper_factory" +CLI_VALUE_KIND_SELECTION_TEXT = "selection_text" +CLI_VALUE_KIND_LIST_VALUES = "list_values" +CLI_VALUE_KIND_VECTOR_TRIPLET = "vector_triplet" +CLI_VALUE_KIND_VECTOR_ARRAY_VALUES = "vector_array_values" +_Fn = TypeVar("_Fn", bound=Callable[..., Any]) + + +@dataclass(frozen=True) +class CliInputAdapterMetadata: + """Describes how a wrapper method should be exposed to the CLI input layer.""" + + value_kind: str + preferred_field: str | None = None + shorthand_supported: bool = False + + +@dataclass(frozen=True) +class CliVisibilityMetadata: + """Describes whether a wrapper member should be hidden from CLI discovery.""" + + hidden: bool = True + reason: str = "hidden" + message: str | None = None + + +def cli_input_adapter( + *, value_kind: str, preferred_field: str | None = None, shorthand_supported: bool = False +) -> Callable[[_Fn], _Fn]: + """Attach explicit CLI input metadata to a wrapper method.""" + + def decorator(func: _Fn) -> _Fn: + setattr( + func, + _CLI_INPUT_ADAPTER_ATTR, + CliInputAdapterMetadata( + value_kind=value_kind, + preferred_field=preferred_field, + shorthand_supported=shorthand_supported, + ), + ) + return func + + return decorator + + +def cli_hidden(*, reason: str = "hidden", message: str | None = None) -> Callable[[_Fn], _Fn]: + """Attach metadata marking a wrapper member as intentionally hidden from the CLI.""" + + def decorator(func: _Fn) -> _Fn: + setattr( + func, + _CLI_VISIBILITY_ATTR, + CliVisibilityMetadata(hidden=True, reason=reason, message=message), + ) + return func + + return decorator + + +def get_cli_input_adapter_metadata(method: Any) -> CliInputAdapterMetadata | None: + """Return CLI adapter metadata attached to a wrapper method, if any.""" + + return getattr(method, _CLI_INPUT_ADAPTER_ATTR, None) + + +def get_cli_visibility_metadata(method: Any) -> CliVisibilityMetadata | None: + """Return CLI visibility metadata attached to a wrapper member, if any.""" + + return getattr(method, _CLI_VISIBILITY_ATTR, None) diff --git a/src/moldflow/constants.py b/src/moldflow/constants.py index 49551aa..767bae4 100644 --- a/src/moldflow/constants.py +++ b/src/moldflow/constants.py @@ -13,6 +13,9 @@ DEFAULT_THREE_LETTER_CODE = "enu" LOCALE_FILE_NAME = "locale" MOLDFLOW_DIR = os.path.dirname(os.path.abspath(__file__)) +# Use package-local locale directory so translations live inside the +# installed moldflow package (src/moldflow/locale). This is the canonical +# location used at runtime and for packaging. LOCALE_DIR = os.path.join(MOLDFLOW_DIR, "locale") # Registry constants @@ -27,7 +30,7 @@ # Animation speed constants ANIMATION_SPEED_CONVERTER = {"Slow": 0, "Medium": 1, "Fast": 2} -# BCP-47 standard constants +# BCP-47 standard constants (MSI/registry use three-letter codes only) THREE_LETTER_TO_BCP_47 = { "chs": "zh-CN", "cht": "zh-TW", diff --git a/src/moldflow/diagnosis_manager.py b/src/moldflow/diagnosis_manager.py index 17dc75d..2ad6309 100644 --- a/src/moldflow/diagnosis_manager.py +++ b/src/moldflow/diagnosis_manager.py @@ -9,6 +9,7 @@ from .logger import process_log from .common import LogMessage +from .cli_input_metadata import CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY, cli_hidden from .helper import check_type, check_min_max, coerce_optional_dispatch from .com_proxy import safe_com from .ent_list import EntList @@ -96,6 +97,7 @@ def show_aspect_ratio( min_value, max_value, std_ar, assign_layer, show_txt, visible ) + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_entity_list(self) -> EntList: """ Creates an empty EntList object diff --git a/src/moldflow/double_array.py b/src/moldflow/double_array.py index be3cd28..0670c11 100644 --- a/src/moldflow/double_array.py +++ b/src/moldflow/double_array.py @@ -6,6 +6,7 @@ DoubleArray Class API Wrapper """ +from .cli_input_metadata import CLI_VALUE_KIND_LIST_VALUES, cli_input_adapter from .logger import process_log from .helper import check_type from .com_proxy import flag_com_method, safe_com @@ -67,6 +68,7 @@ def to_list(self) -> list[float]: vb_array = self.double_array.ToVBSArray() return list(vb_array) + @cli_input_adapter(value_kind=CLI_VALUE_KIND_LIST_VALUES, shorthand_supported=True) def from_list(self, values: list[float]) -> int: """ Convert a list of floats to a double array. diff --git a/src/moldflow/ent_list.py b/src/moldflow/ent_list.py index 1c39a4c..c32c7ea 100644 --- a/src/moldflow/ent_list.py +++ b/src/moldflow/ent_list.py @@ -6,6 +6,7 @@ EntList Class API Wrapper """ +from .cli_input_metadata import CLI_VALUE_KIND_SELECTION_TEXT, cli_input_adapter from .helper import check_index, check_type, coerce_optional_dispatch from .com_proxy import safe_com, expose_oleobj from .predicate import Predicate @@ -45,6 +46,7 @@ def entity(self, index: int) -> "EntList": check_index(index, 0, self.size) return EntList(self.ent_list.Entity(index)) + @cli_input_adapter(value_kind=CLI_VALUE_KIND_SELECTION_TEXT, shorthand_supported=True) def select_from_string(self, entity_string: str) -> None: """ Converts a string to a list of entities diff --git a/src/moldflow/folder_manager.py b/src/moldflow/folder_manager.py index f423f53..83faf25 100644 --- a/src/moldflow/folder_manager.py +++ b/src/moldflow/folder_manager.py @@ -7,6 +7,7 @@ """ from .logger import process_log, LogMessage +from .cli_input_metadata import CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY, cli_hidden from .ent_list import EntList from .common import EntityType, DisplayOption from .helper import get_enum_value, check_type, check_range, coerce_optional_dispatch @@ -112,6 +113,7 @@ def remove_objects_from_folder(self, objects: EntList | None) -> bool: coerce_optional_dispatch(objects, "ent_list") ) + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_entity_list(self) -> EntList: """ Create an entity list. diff --git a/src/moldflow/image_export_options.py b/src/moldflow/image_export_options.py index deb725a..01a0f84 100644 --- a/src/moldflow/image_export_options.py +++ b/src/moldflow/image_export_options.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + """ Usage: ImageExportOptions Class API Wrapper @@ -390,6 +393,41 @@ def fit_to_screen(self, value: bool) -> None: check_type(value, bool) self.image_export_options.FitToScreen = value + @property + def transparent_background(self) -> bool: + """ + Whether to render with a transparent background. + + Only applicable when the output file has a .png extension; + ignored for all other formats. + + .. note:: + Setting this to ``True`` has no effect unless the :attr:`file_name` + ends with ``.png``. For JPEG, BMP, TIFF and other formats the flag + is silently ignored and the image is saved with an opaque background. + + :default: False + :getter: Get transparent_background. + :setter: Set transparent_background. + :type: bool + """ + process_log(__name__, LogMessage.PROPERTY_GET, locals(), name="transparent_background") + return self.image_export_options.TransparentBackground + + @transparent_background.setter + def transparent_background(self, value: bool) -> None: + """ + Set whether to render with a transparent background. + + Args: + value (bool): Use transparent background or not. + """ + process_log( + __name__, LogMessage.PROPERTY_SET, locals(), name="transparent_background", value=value + ) + check_type(value, bool) + self.image_export_options.TransparentBackground = value + @property def capture_mode(self) -> int: """ diff --git a/src/moldflow/integer_array.py b/src/moldflow/integer_array.py index ab4c4a4..851b14f 100644 --- a/src/moldflow/integer_array.py +++ b/src/moldflow/integer_array.py @@ -6,6 +6,7 @@ IntegerArray Class API Wrapper """ +from .cli_input_metadata import CLI_VALUE_KIND_LIST_VALUES, cli_input_adapter from .logger import process_log from .helper import check_type from .com_proxy import safe_com, flag_com_method @@ -67,6 +68,7 @@ def to_list(self) -> list[int]: vb_array = self.integer_array.ToVBSArray() return list(vb_array) + @cli_input_adapter(value_kind=CLI_VALUE_KIND_LIST_VALUES, shorthand_supported=True) def from_list(self, values: list[int]) -> int: """ Convert a list of integers to an integer array. diff --git a/src/moldflow/layer_manager.py b/src/moldflow/layer_manager.py index 8ade557..ddf73f1 100644 --- a/src/moldflow/layer_manager.py +++ b/src/moldflow/layer_manager.py @@ -7,6 +7,7 @@ """ from .ent_list import EntList +from .cli_input_metadata import CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY, cli_hidden from .common import EntityType, DisplayOption from .logger import process_log, LogMessage from .helper import check_type, check_range, get_enum_value, coerce_optional_dispatch @@ -68,6 +69,7 @@ def activate_layer(self, layer: EntList | None) -> bool: check_type(layer, EntList) return self.layer_manager.ActivateLayer(coerce_optional_dispatch(layer, "ent_list")) + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_entity_list(self) -> EntList: """ Creates a new entity list. diff --git a/src/moldflow/locale/__init__.py b/src/moldflow/locale/__init__.py index 6cfd062..5df89f7 100644 --- a/src/moldflow/locale/__init__.py +++ b/src/moldflow/locale/__init__.py @@ -2,5 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 """ -Localization package (contains compiled message catalogs) +Shared localization package (contains message catalogs). + +This directory is the canonical, repo-level location for localization files. +It is used at runtime and during packaging/build steps. """ diff --git a/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po b/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po index 9c468bc..a2e4acb 100644 --- a/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po +++ b/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po @@ -3,62 +3,395 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: de-DE\n" +msgid "\nDid you mean step '{step_name}'?" +msgstr "\nMeinten Sie den Schritt '{step_name}'?" + +msgid "\nFor JSON input on multi-step targets, group parameters by step name, e.g. {example}" +msgstr "\nBei JSON-Eingaben für mehrstufige Ziele gruppieren Sie Parameter nach Schrittnamen, z. B. {example}" + +msgid "\nFor JSON input, this step key must map to an object of parameter names, e.g. {example}" +msgstr "\nBei JSON-Eingaben muss dieser Schritt-Schlüssel einem Objekt mit Parameternamen zugeordnet sein, z. B. {example}" + +msgid " Did you mean '{parameter}'?" +msgstr " Meinten Sie '{parameter}'?" + +msgid " Known parameters: {known_params}." +msgstr " Bekannte Parameter: {known_params}." + +msgid "'{type_name}' no longer exposes adapter method '{method_name}'." +msgstr "'{type_name}' stellt die Adaptermethode '{method_name}' nicht mehr bereit." + +msgid "--yaml requested but PyYAML is not installed: {error}" +msgstr "--yaml wurde angefordert, aber PyYAML ist nicht installiert: {error}" + +msgid "--yaml requested but PyYAML is not installed: {exc}" +msgstr "--yaml wurde angefordert, aber PyYAML ist nicht installiert: {exc}" + +msgid "Aborted." +msgstr "Abgebrochen." + +msgid "Advanced fallback only. Use this tagged shape when annotation context is unavailable, when a nested payload is truly generic, or when multiple wrapper families would be ambiguous." +msgstr "Nur für erweiterten Fallback. Verwenden Sie diese getaggte Form, wenn der Annotationskontext nicht verfügbar ist, wenn eine verschachtelte Nutzlast wirklich generisch ist oder wenn mehrere Wrapper-Familien mehrdeutig wären." + +msgid "Argument '{argument}' must specify a parameter name after the step (e.g., {step_name}.param=...).{extra}" +msgstr "Argument '{argument}' muss nach dem Schritt einen Parameternamen angeben (z. B. {step_name}.param=...).{extra}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}" +msgstr "Argument '{argument}' muss mit einem der folgenden beginnen: {valid_steps}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "Argument '{argument}' muss mit einem der folgenden beginnen: {valid_steps}.{extra}" + +msgid "Argument '{step_name}' must start with one of: {names}" +msgstr "Argument '{step_name}' muss mit einem der folgenden beginnen: {names}" + +msgid "Argument error calling {target}{signature}: {error}" +msgstr "Argumentfehler beim Aufruf von {target}{signature}: {error}" + +msgid "Arguments as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value. Duplicate or conflicting paths are rejected (for example param=1 with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "Argumente als key=value oder param.attr=value. Bei verketteten Zielen stellen Sie dem Parameter den Methodennamen voran, zum Beispiel find_plot_by_name.plot_name=\"My Plot\". Verschachtelte Zuordnung verwendet step.param.attr=value. Doppelte oder widersprüchliche Pfade werden abgewiesen (zum Beispiel param=1 zusammen mit param.attr=2), und Methoden mit ausschließlich positionsbezogenen Parametern werden von der benannten CLI-Weiterleitung nicht unterstützt." + +msgid "Arguments for step '{step_name}' must be a JSON object of parameters." +msgstr "Argumente für Schritt '{step_name}' müssen ein JSON-Objekt mit Parametern sein." + +msgid "Array" +msgstr "Feld" + +msgid "Attribute '{matched_name}' on class '{class_name}' returns a non-wrapper value{continuation}" +msgstr "Attribut '{matched_name}' auf Klasse '{class_name}' gibt einen Nicht-Wrapper-Wert zurück{continuation}" + +msgid "Attribute '{matched_name}' on object '{type_name}' returns a non-wrapper value{continuation}" +msgstr "Attribut '{matched_name}' auf Objekt '{type_name}' gibt einen Nicht-Wrapper-Wert zurück{continuation}" + +msgid "Batch file must contain a JSON array of invoke call objects." +msgstr "Die Batch-Datei muss ein JSON-Array mit Objekten für Invoke-Aufrufe enthalten." + +msgid "Batch item field 'args' must be a list of strings." +msgstr "Das Feld 'args' eines Batch-Eintrags muss eine Liste von Zeichenfolgen sein." + +msgid "Batch item field 'params_json_file' must be a string path." +msgstr "Das Feld 'params_json_file' eines Batch-Eintrags muss ein Pfad als Zeichenfolge sein." + +msgid "Batch item must be a JSON object." +msgstr "Ein Batch-Eintrag muss ein JSON-Objekt sein." + +msgid "Batch item requires string field 'target'." +msgstr "Ein Batch-Eintrag erfordert das Zeichenfolgenfeld 'target'." + +msgid "Batch item {index} error: {error}" +msgstr "Fehler in Batch-Eintrag {index}: {error}" + +msgid "Batch results" +msgstr "Batch-Ergebnisse" + +msgid "Batch summary: {succeeded}/{total} succeeded, {failed} failed." +msgstr "Batch-Zusammenfassung: {succeeded}/{total} erfolgreich, {failed} fehlgeschlagen." + +msgid "CLI argument: {value}" +msgstr "CLI-Argument: {value}" + msgid "Cancel" msgstr "Abbrechen" +msgid "Cannot assign property '{property_name}' while resolving target '{target}' because the owner object resolved to None." +msgstr "Eigenschaft '{property_name}' kann beim Auflösen des Ziels '{target}' nicht zugewiesen werden, weil das Besitzerobjekt zu None aufgelöst wurde." + +msgid "Cannot build instance for type 'EntList'. No create_entity_list provider found." +msgstr "Instanz für Typ 'EntList' kann nicht erstellt werden. Kein create_entity_list-Provider gefunden." + +msgid "Cannot build instance for type '{type_name}'. Not a known Synergy property or factory." +msgstr "Instanz für Typ '{type_name}' kann nicht erstellt werden. Keine bekannte Synergy-Eigenschaft oder Factory." + +msgid "Cannot configure field '{key_name}' on '{type_name}': {error}" +msgstr "Feld '{key_name}' für '{type_name}' kann nicht konfiguriert werden: {error}" + +msgid "Cannot invoke method '{segment}' for target '{target}' because '{owner}' is unavailable in the current session (it resolved to None). This target only works when that object exists." +msgstr "Methode '{segment}' für Ziel '{target}' kann nicht aufgerufen werden, weil '{owner}' in der aktuellen Sitzung nicht verfügbar ist (es wurde zu None aufgelöst). Dieses Ziel funktioniert nur, wenn dieses Objekt existiert." + +msgid "Cannot read JSON file '{file}': {exc}" +msgstr "JSON-Datei '{file}' kann nicht gelesen werden: {exc}" + +msgid "Cannot read JSON file '{path}': {error}" +msgstr "JSON-Datei '{path}' kann nicht gelesen werden: {error}" + +msgid "Cannot read batch file '{path}': {error}" +msgstr "Batch-Datei '{path}' kann nicht gelesen werden: {error}" + +msgid "Cannot resolve '{first}' on moldflow for introspection" +msgstr "'{first}' auf moldflow kann für die Introspektion nicht aufgelöst werden" + +msgid "Cannot resolve attribute '{segment}' on '{class_name}' when executing target '{target}': {error}" +msgstr "Attribut '{segment}' auf '{class_name}' kann beim Ausführen des Ziels '{target}' nicht aufgelöst werden: {error}" + +msgid "Cannot resolve attribute '{segment}' without an object instance when resolving target '{target}'" +msgstr "Attribut '{segment}' kann beim Auflösen des Ziels '{target}' nicht ohne Objektinstanz aufgelöst werden" + +msgid "Cannot resolve segment '{segment}' in target '{target}' without a class context. Use a Synergy-rooted target such as 'synergy.some_method'." +msgstr "Segment '{segment}' im Ziel '{target}' kann ohne Klassenkontext nicht aufgelöst werden. Verwenden Sie ein in Synergy verwurzeltes Ziel wie 'synergy.some_method'." + +msgid "Cannot set nested argument '{path}': {error}" +msgstr "Verschachteltes Argument '{path}' kann nicht gesetzt werden: {error}" + +msgid "Cannot set nested attributes for '{param_name}' without signature info on '{step_name}'." +msgstr "Verschachtelte Attribute für '{param_name}' können ohne Signaturinformationen zu '{step_name}' nicht gesetzt werden." + +msgid "Cannot set property '{property_name}' on target '{target}': {error}" +msgstr "Eigenschaft '{property_name}' auf Ziel '{target}' kann nicht gesetzt werden: {error}" + +msgid "Cannot write JSON file '{path}': {error}" +msgstr "JSON-Datei '{path}' kann nicht geschrieben werden: {error}" + +msgid "Canonical JSON field is derived from the reflected wrapper method signature for {method_name}()." +msgstr "Das kanonische JSON-Feld wird aus der reflektierten Signatur der Wrapper-Methode für {method_name}() abgeleitet." + +msgid "Canonical list field is derived from the reflected wrapper method {method_name}()." +msgstr "Das kanonische Listenfeld wird aus der reflektierten Wrapper-Methode {method_name}() abgeleitet." + +msgid "Canonical triplet field is derived from the reflected wrapper method {method_name}()." +msgstr "Das kanonische Tripel-Feld wird aus der reflektierten Wrapper-Methode {method_name}() abgeleitet." + +msgid "Canonical vector-array field is derived from the reflected wrapper method {method_name}()." +msgstr "Das kanonische Vektor-Array-Feld wird aus der reflektierten Wrapper-Methode {method_name}() abgeleitet." + +msgid "Chained targets with repeated method names are ambiguous for argument routing: {duplicate_names}. Please use an equivalent target path where each invoked step name is unique." +msgstr "Verkettete Ziele mit wiederholten Methodennamen sind für die Argumentzuordnung mehrdeutig: {duplicate_names}. Bitte verwenden Sie einen äquivalenten Zielpfad, bei dem jeder aufgerufene Schrittname eindeutig ist." + msgid "Checking file extension {file_name}" -msgstr "Überprüfen der Dateierweiterung {file_name}" +msgstr "Prüfe Dateierweiterung {file_name}" msgid "Checking folder path {folder_path}" msgstr "Überprüfen des Ordnerpfads {folder_path}" msgid "Checking index {index} is in range" -msgstr "Überprüfen, ob der Index {index} im Bereich liegt" +msgstr "Prüfe, ob Index {index} im gültigen Bereich liegt" msgid "Checking {value} is greater than {min_value}" -msgstr "Überprüfen, ob {value} größer als {min_value} ist" +msgstr "Prüfe, ob {value} größer als {min_value} ist" msgid "Checking {value} is in expected values" -msgstr "Überprüfen, ob {value} in den erwarteten Werten enthalten ist" +msgstr "Prüfe, ob {value} in den erwarteten Werten enthalten ist" msgid "Checking {value} is in range {min_value} to {max_value}" -msgstr "Überprüfen, ob {value} im Bereich von {min_value} bis {max_value} liegt" +msgstr "Prüfe, ob {value} im Bereich von {min_value} bis {max_value} liegt" msgid "Checking {value} is less than {max_value}" -msgstr "Überprüfen, ob {value} kleiner als {max_value} ist" +msgstr "Prüfe, ob {value} kleiner als {max_value} ist" msgid "Checking {value} is negative" -msgstr "Überprüfen, ob {value} negativ ist" +msgstr "Prüfe, ob {value} negativ ist" msgid "Checking {value} is non-negative" -msgstr "Überprüfen, ob {value} nicht negativ ist" +msgstr "Prüfe, ob {value} nicht negativ ist" msgid "Checking {value} is non-zero" -msgstr "Überprüfen, ob {value} ungleich Null ist" +msgstr "Prüfe, ob {value} ungleich null ist" msgid "Checking {value} is positive" -msgstr "Überprüfen, ob {value} positiv ist" +msgstr "Prüfe, ob {value} positiv ist" msgid "Checking {value} is {name}" -msgstr "Überprüfung {value} ist {name}" +msgstr "Prüfe, ob {value} {name} ist" + +msgid "Class '{class_name}' has no attribute '{attr_name}'" +msgstr "Klasse '{class_name}' hat kein Attribut '{attr_name}'" + +msgid "Clear the screen" +msgstr "Bildschirm löschen" + +msgid "Clears the terminal screen and redraws the banner." +msgstr "Löscht den Terminalbildschirm und zeichnet das Banner neu." + +msgid "Close Synergy and reset the session" +msgstr "Synergy schließen und Sitzung zurücksetzen" + +msgid "Closes Synergy and resets the session for a fresh start." +msgstr "Schließt Synergy und setzt die Sitzung zurück, um einen Neustart zu ermöglichen." + +msgid "Command exited with status {code}" +msgstr "Befehl wurde mit Status {code} beendet" + +msgid "Commands:" +msgstr "Befehle:" + +msgid "Conflicting argument paths '{left_path}' and '{right_path}' are not allowed." +msgstr "Konfliktierende Argumentpfade '{left_path}' und '{right_path}' sind nicht zulässig." msgid "Could not initialize with Instance ID: {value}" -msgstr "Konnte nicht mit der Instanz-ID initialisieren: {value}" +msgstr "Initialisierung mit Instanz-ID {value} fehlgeschlagen" + +msgid "Ctrl+D also exits." +msgstr "Strg+D beendet ebenfalls." + +msgid "Detail" +msgstr "Details" + +msgid "Direct parameter assignment remains the preferred non-JSON form." +msgstr "Die direkte Parameterzuweisung bleibt die bevorzugte Nicht-JSON-Form." + +msgid "Disable ANSI color/styling in CLI output." +msgstr "ANSI-Farben/-Formatierung in der CLI-Ausgabe deaktivieren." + +msgid "Discover invokable targets and the next command to run for each one." +msgstr "Ermittelt aufrufbare Ziele und den jeweils nächsten auszuführenden Befehl." + +msgid "Do not pass TARGET when --batch-file is used." +msgstr "Geben Sie TARGET nicht an, wenn --batch-file verwendet wird." + +msgid "Do not pass positional args/JSON input with --batch-file." +msgstr "Geben Sie mit --batch-file keine positionsbezogenen Argumente/JSON-Eingaben an." + +msgid "Dotted path to a method or function, optionally chained, for example 'synergy.new_project' or 'synergy.plot_manager.find_plot_by_name'." +msgstr "Punktpfad zu einer Methode oder Funktion, optional verkettet, zum Beispiel 'synergy.new_project' oder 'synergy.plot_manager.find_plot_by_name'." + +msgid "Dotted path, e.g., synergy.new_project" +msgstr "Punktpfad, z. B. synergy.new_project" + +msgid "Dry run for {target}" +msgstr "Probelauf für {target}" + +msgid "Duplicate argument path '{path}' is not allowed." +msgstr "Doppelter Argumentpfad '{path}' ist nicht zulässig." + +msgid "Duplicate/conflicting paths are rejected. Arguments are passed as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value (for example param=1 conflicts with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "Doppelte/konfliktierende Pfade werden abgelehnt. Argumente werden als key=value oder param.attr=value übergeben. Für verkettete Ziele wird der Parameter mit dem Methodennamen versehen, z. B. find_plot_by_name.plot_name=\"My Plot\". Verschachteltes Routing verwendet step.param.attr=value (z. B. param=1 steht in Konflikt mit param.attr=2), und Methoden mit rein positionsbasierten Parametern werden durch benanntes CLI-Routing nicht unterstützt." + +msgid "Emit a JSON schema-like representation of target parameters." +msgstr "Gibt eine JSON-Schema-ähnliche Darstellung der Zielparameter aus." + +msgid "Emit line-delimited JSON trace events to stderr for target resolution and runtime invoke binding." +msgstr "Gibt zeilengetrennte JSON-Trace-Ereignisse für Zielauflösung und Laufzeit-Bindung von Invoke auf stderr aus." + +msgid "Emit structured JSON for scripting or agent use." +msgstr "Gibt strukturiertes JSON für Skripte oder Agenten aus." + +msgid "Emit structured YAML for scripting or agent use (requires PyYAML)." +msgstr "Gibt strukturiertes YAML für Skripte oder Agenten aus (PyYAML erforderlich)." + +msgid "Emit the structured result as JSON to stdout (useful for automation/LLMs)." +msgstr "Gibt das strukturierte Ergebnis als JSON auf stdout aus (nützlich für Automatisierung/LLMs)." + +msgid "Empty target" +msgstr "Leeres Ziel" + +msgid "Empty type tag is not valid for '{primary_expected}'." +msgstr "Ein leerer Typtag ist für '{primary_expected}' nicht gültig." + +msgid "Error:" +msgstr "Fehler:" + +msgid "Example object shape: {shape}." +msgstr "Beispiel-Objektform: {shape}." msgid "Executing {name}" -msgstr "Ausführung {name}" +msgstr "Führe {name} aus" + +msgid "Exit the REPL" +msgstr "REPL beenden" + +msgid "Exits the REPL." +msgstr "Beendet die REPL." + +msgid "Explicit field form: {value}" +msgstr "Explizite Feldform: {value}" msgid "Failed to initialize Synergy: Synergy not found" msgstr "Initialisierung von Synergy fehlgeschlagen: Synergy nicht gefunden" +msgid "Failed to render JSON output for {context}: {error}" +msgstr "JSON-Ausgabe für {context} konnte nicht gerendert werden: {error}" + +msgid "Failed to render JSON output for {context}: {exc}" +msgstr "JSON-Ausgabe für {context} konnte nicht gerendert werden: {exc}" + +msgid "Failed to render YAML output for {context}: {error}" +msgstr "YAML-Ausgabe für {context} konnte nicht gerendert werden: {error}" + +msgid "Failed to render YAML output for {context}: {exc}" +msgstr "YAML-Ausgabe für {context} konnte nicht gerendert werden: {exc}" + +msgid "Failed to reset session:" +msgstr "Sitzung konnte nicht zurückgesetzt werden:" + +msgid "Field '{key_name}' is not valid for '{type_name}'." +msgstr "Feld '{key_name}' ist für '{type_name}' nicht gültig." + +msgid "Field '{key}' for '{type_name}' must be a 3-item numeric sequence like [0, 0, 1]." +msgstr "Feld '{key}' für '{type_name}' muss eine numerische Folge mit 3 Elementen wie [0, 0, 1] sein." + +msgid "Field '{key}' for '{type_name}' must be a JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "Feld '{key}' für '{type_name}' muss ein JSON-Array von Tripeln oder eine durch Semikolon getrennte Liste wie '0,0,0;1,0,0' sein." + +msgid "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list." +msgstr "Feld '{key}' für '{type_name}' muss ein JSON-Array oder eine durch Kommas getrennte Liste sein." + +msgid "Field '{key}' for '{type_name}' must be a comma-separated numeric triplet like '0,0,1'." +msgstr "Feld '{key}' für '{type_name}' muss ein durch Kommas getrenntes numerisches Tripel wie '0,0,1' sein." + +msgid "Field '{key}' for '{type_name}' must be a list of numeric triplets." +msgstr "Feld '{key}' für '{type_name}' muss eine Liste numerischer Tripel sein." + +msgid "Field '{key}' for '{type_name}' must be a string selection expression. Expected field: {preferred_field}." +msgstr "Feld '{key}' für '{type_name}' muss ein Auswahlausdruck als Zeichenfolge sein. Erwartetes Feld: {preferred_field}." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "Feld '{key}' für '{type_name}' muss ein gültiges JSON-Array von Tripeln oder eine durch Semikolon getrennte Liste wie '0,0,0;1,0,0' sein." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array or a comma-separated list." +msgstr "Feld '{key}' für '{type_name}' muss ein gültiges JSON-Array oder eine durch Kommas getrennte Liste sein." + +msgid "Field '{key}' for '{type_name}' must contain integer values." +msgstr "Feld '{key}' für '{type_name}' muss ganzzahlige Werte enthalten." + +msgid "Field '{key}' for '{type_name}' must contain numeric values." +msgstr "Feld '{key}' für '{type_name}' muss numerische Werte enthalten." + +msgid "Fields '{previous_key}' and '{key}' both map to the same input for '{type_name}'. Provide only one of: {preferred_field} or direct parameter shorthand." +msgstr "Die Felder '{previous_key}' und '{key}' verweisen beide auf dieselbe Eingabe für '{type_name}'. Geben Sie nur eines von beiden an: {preferred_field} oder die direkte Parameter-Kurzform." + +msgid "Filter by substring or wildcard pattern (* and ?)." +msgstr "Nach Teilstring oder Platzhaltermuster (* und ?) filtern." + +msgid "Filter by substring or wildcard pattern (* and ?). Repeat to keep targets matching any filter." +msgstr "Filtert nach Teilstring oder Platzhaltermuster (* und ?). Wiederholen Sie die Option, damit Ziele beibehalten werden, die einem der Filter entsprechen." + +msgid "Filtered matches:" +msgstr "Gefilterte Treffer:" + +msgid "For JSON input, group parameters by step name. Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "Bei JSON-Eingaben gruppieren Sie Parameter nach Schrittnamen. Argument '{argument}' muss mit einem der folgenden beginnen: {valid_steps}.{extra}" + +msgid "For multi-step targets, group params-json fields by step name." +msgstr "Bei mehrstufigen Zielen gruppieren Sie params-json-Felder nach Schrittnamen." + msgid "Getting {name}" -msgstr "{name} erhalten" +msgstr "Rufe {name} ab" msgid "Getting {name} at index {value}" -msgstr "{name} bei index {value} abrufen" +msgstr "Rufe {name} an Index {value} ab" + +msgid "Goodbye!" +msgstr "Auf Wiedersehen!" + +msgid "If shorthand input is ambiguous, switch to --params-json. {guidance}" +msgstr "Wenn die Kurzform mehrdeutig ist, wechseln Sie zu --params-json. {guidance}" + +msgid "In non-JSON mode, prefer direct shorthand like '{preferred_non_json}'." +msgstr "Im Nicht-JSON-Modus bevorzugen Sie eine direkte Kurzform wie '{preferred_non_json}'." + +msgid "Index" +msgstr "Indexposition" msgid "Initializing {name}" -msgstr "Initialisierung {name}" +msgstr "Initialisiere {name}" + +msgid "Input hints:" +msgstr "Eingabehinweise:" + +msgid "Inspect a target's signature, docs, examples, and structured invoke template." +msgstr "Zeigt die Signatur, Dokumentation, Beispiele und die strukturierte Invoke-Vorlage eines Ziels an." + +msgid "Interrupted." +msgstr "Unterbrochen." msgid "Invalid Attribute: {attribute} is not supported" msgstr "Ungültiges Attribut: {attribute} wird nicht unterstützt" @@ -69,17 +402,191 @@ msgstr "Ungültiger Dateityp: {file_name}, muss {extensions} sein" msgid "Invalid Index: out of range" msgstr "Ungültiger Index: außerhalb des gültigen Bereichs" +msgid "Invalid JSON payload for parameters: {error}" +msgstr "Ungültige JSON-Nutzlast für Parameter: {error}" + +msgid "Invalid JSON payload for parameters: {exc}" +msgstr "Ungültige JSON‑Nutzlast für Parameter: {exc}" + +msgid "Invalid JSON value for parameter '{param_name}': {error}" +msgstr "Ungültiger JSON-Wert für Parameter '{param_name}': {error}" + msgid "Invalid Type: must be {expected_types}, not {variable_type}" -msgstr "Ungültiger Typ: MUSS {expected_types}, nicht {variable_type} sein" +msgstr "Ungültiger Typ: muss {expected_types} sein, nicht {variable_type}" msgid "Invalid Value: {reason}" msgstr "Ungültiger Wert: {reason}" +msgid "Invalid argument '{item}'. Expected key=value or param.attr=value." +msgstr "Ungültiges Argument '{item}'. Erwartet: key=value oder param.attr=value." + +msgid "Invalid argument for step '{step_name}': missing parameter name." +msgstr "Ungültiges Argument für Schritt '{step_name}': fehlender Parametername." + +msgid "Invalid nested argument path '{path}': attribute '{attr}' does not exist on '{obj_type}'." +msgstr "Ungültiger verschachtelter Argumentpfad '{path}': Attribut '{attr}' existiert nicht auf '{obj_type}'." + +msgid "Invalid nested argument path '{path}': cannot nest into non-object '{obj_type}'." +msgstr "Ungültiger verschachtelter Argumentpfad '{path}': Verschachtelung in Nicht-Objekt '{obj_type}' ist nicht möglich." + +msgid "Invalid nested argument path '{path}': cannot set '{final_attr}' on non-object '{obj_type}'." +msgstr "Ungültiger verschachtelter Argumentpfad '{path}': '{final_attr}' kann auf Nicht-Objekt '{obj_type}' nicht gesetzt werden." + +msgid "Invalid value for parameter '{param_name}': {error}" +msgstr "Ungültiger Wert für Parameter '{param_name}': {error}" + +msgid "Invalid {field_name} '{path_text}': empty path segment is not allowed." +msgstr "Ungültiges {field_name} '{path_text}': Leeres Pfadsegment ist nicht erlaubt." + +msgid "Invalid {field_name} '{path_text}': segment '{segment}' must be a valid identifier." +msgstr "Ungültiges {field_name} '{path_text}': Segment '{segment}' muss ein gültiger Bezeichner sein." + +msgid "Invalid {field_name}: value cannot be empty." +msgstr "Ungültiges {field_name}: Wert darf nicht leer sein." + +msgid "JSON example:" +msgstr "JSON-Beispiel:" + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays and scalars are not allowed." +msgstr "JSON-Objekt mit Parameterzuordnungen (überschreibt positionsbezogene Argumente). Arrays und Skalare auf oberster Ebene sind nicht zulässig." + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays/scalars are not allowed." +msgstr "JSON-Objekt mit Parameterzuordnungen (überschreibt positionsbezogene Argumente). Arrays und Skalare auf oberster Ebene sind nicht zulässig." + +msgid "JSON parameters must be a JSON object of named arguments. Example: --params-json '{\"param\": 1}' or --params-json '{\"step\": {\"param\": 1}}' for chained targets." +msgstr "JSON‑Parameter müssen ein JSON‑Objekt benannter Argumente sein. Beispiel: --params-json '{\"param\": 1}' oder --params-json '{\"step\": {\"param\": 1}}' für verkettete Ziele." + +msgid "JSON type tag '{type_tag}' does not match expected wrapper '{primary_expected}'." +msgstr "JSON-Typtag '{type_tag}' stimmt nicht mit dem erwarteten Wrapper '{primary_expected}' überein." + +msgid "JSON value:" +msgstr "JSON-Wert:" + +msgid "List result:" +msgstr "Ergebnisliste:" + msgid "Logger was not setup" msgstr "Logger wurde nicht eingerichtet" +msgid "Missing required parameter '{parameter}' for {target}{signature}" +msgstr "Fehlender erforderlicher Parameter '{parameter}' für {target}{signature}" + +msgid "Moldflow command-line interface.\n\nStart with 'list' to discover targets, 'describe ' to inspect usage, then 'invoke ...' to run it." +msgstr "Moldflow-Befehlszeilenschnittstelle.\n\nBeginnen Sie mit 'list', um Ziele zu finden, verwenden Sie 'describe ', um die Verwendung zu prüfen, und führen Sie dann 'invoke ...' aus." + +msgid "Moldflow invokable targets" +msgstr "Aufrufbare Moldflow-Ziele" + +msgid "Nested argument '{path}' is not supported for **kwargs on step '{step_name}'. Use a single key (e.g., {example}=...)." +msgstr "Verschachteltes Argument '{path}' wird für **kwargs in Schritt '{step_name}' nicht unterstützt. Verwenden Sie einen einzelnen Schlüssel (z. B. {example}=...)." + +msgid "No invokable targets matched these filters." +msgstr "Keine aufrufbaren Ziele entsprechen diesen Filtern." + +msgid "No invokable targets matched this filter." +msgstr "Keine aufrufbaren Ziele entsprechen diesem Filter." + +msgid "Non-public argument path '{key}' is not allowed." +msgstr "Nicht‑öffentlicher Argumentpfad '{key}' ist nicht erlaubt." + +msgid "Non-public argument path '{left}' is not allowed." +msgstr "Nicht‑öffentlicher Argumentpfad '{left}' ist nicht erlaubt." + +msgid "Non-public argument path '{path}' is not allowed." +msgstr "Nicht-öffentlicher Argumentpfad '{path}' ist nicht erlaubt." + +msgid "Non-public argument path '{step_name}' is not allowed." +msgstr "Nicht‑öffentlicher Argumentpfad '{step_name}' ist nicht erlaubt." + +msgid "Non-public argument path '{step}.{key}' is not allowed." +msgstr "Nicht‑öffentlicher Argumentpfad '{step}.{key}' ist nicht erlaubt." + +msgid "Non-public field '{key_name}' is not allowed when constructing '{type_name}' from JSON." +msgstr "Nicht-öffentliches Feld '{key_name}' ist beim Erstellen von '{type_name}' aus JSON nicht erlaubt." + +msgid "Non-public segment '{segment}' is not allowed in target '{target}'." +msgstr "Nicht-öffentliches Segment '{segment}' ist im Ziel '{target}' nicht erlaubt." + +msgid "Non-public segment '{seg}' is not allowed in target '{target}'." +msgstr "Nicht‑öffentliches Segment '{seg}' ist im Ziel '{target}' nicht erlaubt." + msgid "OK" -msgstr "OK" +msgstr "In Ordnung" + +msgid "Object" +msgstr "Objekt" + +msgid "Object '{type_name}' has no attribute '{attr_name}'" +msgstr "Objekt '{type_name}' hat kein Attribut '{attr_name}'" + +msgid "One or more dotted targets, for example synergy.new_project." +msgstr "Ein oder mehrere Ziele in Punktnotation, zum Beispiel synergy.new_project." + +msgid "Only one of --json or --yaml may be specified." +msgstr "Es darf nur entweder --json oder --yaml angegeben werden." + +msgid "Only one of --json, --yaml, or --schema may be specified." +msgstr "Es darf nur eines von --json, --yaml oder --schema angegeben werden." + +msgid "Only one of --params-json or --params-json-file may be specified." +msgstr "Es darf nur eines von --params-json oder --params-json-file angegeben werden." + +msgid "Parameter '{param_name}' contains a null byte which is not allowed." +msgstr "Parameter '{param_name}' enthält ein Null‑Byte, das nicht erlaubt ist." + +msgid "Parameter '{param_name}' contains control characters (newline/tab/carriage return); please provide a single-line value or quote/escape as needed." +msgstr "Parameter '{param_name}' enthält Steuerzeichen (Zeilenumbruch/Tab/Wagenrücklauf); bitte geben Sie einen einzeiligen Wert an oder verwenden Sie Anführungszeichen/Escape‑Sequenzen." + +msgid "Parse error:" +msgstr "Analysefehler:" + +msgid "Parse/validate/build kwargs and emit a call plan without executing invoke steps." +msgstr "Parst/validiert/erstellt kwargs und gibt einen Aufrufplan aus, ohne Invoke-Schritte auszuführen." + +msgid "Parse/validate/build kwargs and emit a template summary call plan without executing invoke steps." +msgstr "Parst/validiert/erstellt kwargs und gibt einen Vorlagen-Zusammenfassungs-Aufrufplan aus, ohne Invoke-Schritte auszuführen." + +msgid "Path to a JSON file containing an array of invoke calls for batch execution." +msgstr "Pfad zu einer JSON-Datei, die ein Array von Invoke-Aufrufen für die Batch-Ausführung enthält." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object, not arrays/scalars." +msgstr "Pfad zu einer JSON-Datei mit Parameterzuordnungen (überschreibt positionsbezogene Argumente). Die Nutzlast auf oberster Ebene muss ein Objekt sein." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object." +msgstr "Pfad zu einer JSON-Datei mit Parameterzuordnungen (überschreibt positionsbezogene Argumente). Die Nutzlast auf oberster Ebene muss ein Objekt sein." + +msgid "Planned steps:" +msgstr "Geplante Schritte:" + +msgid "Prefer chaining invoke targets so this parameter is produced by a previous step, instead of constructing it manually in JSON." +msgstr "Bevorzugen Sie verkettete Invoke-Ziele, damit dieser Parameter von einem vorherigen Schritt erzeugt wird, statt ihn manuell in JSON zu konstruieren." + +msgid "Print the installed moldflow package version." +msgstr "Gibt die installierte Version des moldflow-Pakets aus." + +msgid "Property assignment JSON must be an object with a single 'value' field." +msgstr "JSON für Eigenschaftszuweisungen muss ein Objekt mit genau einem Feld 'value' sein." + +msgid "Property assignment requires exactly one 'value' argument (e.g., value=... or --params-json '{\"value\": ...}')." +msgstr "Eigenschaftszuweisungen erfordern genau ein Argument 'value' (z. B. value=... oder --params-json '{\"value\": ...}')." + +msgid "Property {name} (id={id}, type={prop_type})" +msgstr "Eigenschaft {name} (id={id}, Typ={prop_type})" + +msgid "Read current value:" +msgstr "Aktuellen Wert lesen:" + +msgid "Resolved assignment:" +msgstr "Aufgelöste Zuweisung:" + +msgid "Resolved kwargs:" +msgstr "Aufgelöste kwargs:" + +msgid "Resolved object has no callable attribute '{segment}' when executing target '{target}'" +msgstr "Das aufgelöste Objekt hat beim Ausführen des Ziels '{target}' kein aufrufbares Attribut '{segment}'" + +msgid "Run a Moldflow target with named parameters or JSON input. Bare targets are treated as synergy.." +msgstr "Führt ein Moldflow-Ziel mit benannten Parametern oder JSON-Eingabe aus. Unqualifizierte Ziele werden als synergy. behandelt." msgid "Save Error" msgstr "Speicherfehler" @@ -90,15 +597,171 @@ msgstr "Speicherfehler: {saving} konnte nicht in {file_name} gespeichert werden" msgid "Save Error: Failed to save {saving} to {file_name}" msgstr "Speicherfehler: Speichern von {saving} in {file_name} fehlgeschlagen" +msgid "Segment '{segment}' does not resolve as an attribute on class '{class_name}' when resolving target '{target}'" +msgstr "Segment '{segment}' wird beim Auflösen des Ziels '{target}' nicht als Attribut der Klasse '{class_name}' aufgelöst" + +msgid "Segment '{segment}' is not a callable method on class '{class_name}' when resolving target '{target}'" +msgstr "Segment '{segment}' ist beim Auflösen des Ziels '{target}' keine aufrufbare Methode der Klasse '{class_name}'" + +msgid "Selection" +msgstr "Auswahl" + +msgid "Session:" +msgstr "Sitzung:" + +msgid "Set it with:" +msgstr "Setzen Sie es mit:" + msgid "Setting {name} to {value}" -msgstr "Einstellung {name} auf {value}" +msgstr "Setze {name} auf {value}" + +msgid "Shorter JSON example:" +msgstr "Kürzeres JSON-Beispiel:" + +msgid "Shorter form:" +msgstr "Kürzere Form:" + +msgid "Show detailed help for a command" +msgstr "Detaillierte Hilfe für einen Befehl anzeigen" + +msgid "Show full tracebacks on errors instead of short messages." +msgstr "Bei Fehlern vollständige Tracebacks anstelle von Kurzmeldungen anzeigen." + +msgid "Show this help message" +msgstr "Diese Hilfemeldung anzeigen" + +msgid "Showing the compact table for {count} filtered matches. Narrow the filter or use --json for canonical target strings." +msgstr "Die kompakte Tabelle für {count} gefilterte Treffer wird angezeigt. Grenzen Sie den Filter weiter ein oder verwenden Sie --json für kanonische Zielzeichenfolgen." + +msgid "Shows available commands and usage information." +msgstr "Zeigt verfügbare Befehle und Nutzungsinformationen an." + +msgid "Start an interactive moldflow shell session." +msgstr "Eine interaktive moldflow-Shell-Sitzung starten." + +msgid "Status" +msgstr "Zustand" + +msgid "Step '{step_name}' in target '{target}' has positional-only parameters ({parameters}), which are not supported by CLI named-argument routing. Use the Python API for this target." +msgstr "Schritt '{step_name}' im Ziel '{target}' hat ausschließlich positionsbezogene Parameter ({parameters}), die von der benannten CLI-Argumentzuordnung nicht unterstützt werden. Verwenden Sie für dieses Ziel die Python-API." msgid "Submit" msgstr "Senden" +msgid "Synergy session reset." +msgstr "Synergy-Sitzung zurückgesetzt." + +msgid "TARGET is required unless --batch-file is used." +msgstr "TARGET ist erforderlich, sofern nicht --batch-file verwendet wird." + +msgid "Tab completion targets are refreshed automatically." +msgstr "Die Ziele für die Tab-Vervollständigung werden automatisch aktualisiert." + +msgid "Target" +msgstr "Ziel" + +msgid "Target '{target}' is hidden from the CLI because '{hidden_path}' only creates a transient {wrapper_type} wrapper. The CLI constructs these helper objects internally when needed, so they are not exposed as direct CLI targets." +msgstr "Ziel '{target}' ist in der CLI ausgeblendet, weil '{hidden_path}' nur einen transienten {wrapper_type}-Wrapper erzeugt. Die CLI erstellt diese Hilfsobjekte intern bei Bedarf, daher werden sie nicht als direkte CLI-Ziele offengelegt." + +msgid "Target '{target}' is hidden from the CLI by library metadata on '{hidden_path}'." +msgstr "Ziel '{target}' wird durch Bibliotheksmetadaten auf '{hidden_path}' in der CLI ausgeblendet." + +msgid "Target '{target}' resolves to a property/attribute and does not accept arguments." +msgstr "Ziel '{target}' wird zu einer Eigenschaft/einem Attribut aufgelöst und akzeptiert keine Argumente." + +msgid "Target '{target}' resolves to a {class_name} wrapper property. Continue to one of its members, for example 'describe {target}.'." +msgstr "Ziel '{target}' wird zu einer Wrapper-Eigenschaft vom Typ {class_name} aufgelöst. Fahren Sie mit einem ihrer Member fort, zum Beispiel 'describe {target}.'." + +msgid "Target '{target}' resolves to property '{property_name}' (getter) and does not accept arguments." +msgstr "Ziel '{target}' wird zur Eigenschaft '{property_name}' (Getter) aufgelöst und akzeptiert keine Argumente." + +msgid "Target '{target}' resolves to write-only property '{property_name}' and cannot be read via invoke." +msgstr "Ziel '{target}' wird zur Nur-Schreib-Eigenschaft '{property_name}' aufgelöst und kann nicht per invoke gelesen werden." + +msgid "Target must include a class or function name" +msgstr "Das Ziel muss einen Klassen- oder Funktionsnamen enthalten" + +msgid "Target must include at least one segment" +msgstr "Das Ziel muss mindestens ein Segment enthalten" + +msgid "Target must start with 'synergy' (or 'moldflow.synergy'). All invocations are rooted on the Synergy COM object." +msgstr "Das Ziel muss mit 'synergy' (oder 'moldflow.synergy') beginnen. Alle Aufrufe sind am Synergy‑COM‑Objekt verankert." + +msgid "Target must start with 'synergy' after the optional 'moldflow.' prefix. Bare targets such as 'open_project' are accepted and are interpreted as 'synergy.open_project'." +msgstr "Das Ziel muss nach dem optionalen Präfix 'moldflow.' mit 'synergy' beginnen. Bloße Ziele wie 'open_project' werden akzeptiert und als 'synergy.open_project' interpretiert." + +msgid "Targets are shown without the leading 'synergy.' prefix. Describe and invoke accept either form." +msgstr "Ziele werden ohne das führende Präfix 'synergy.' angezeigt. Describe und invoke akzeptieren beide Formen." + msgid "Test String" msgstr "Testzeichenfolge" +msgid "The Moldflow CLI requires optional dependencies. Install them with: pip install 'moldflow[cli]'" +msgstr "Die Moldflow-CLI benötigt optionale Abhängigkeiten. Installieren Sie sie mit: pip install 'moldflow[cli]'" + +msgid "The target returned False, which indicates a business-level failure." +msgstr "Das Ziel gab False zurück, was auf einen fachlichen Fehler hinweist." + +msgid "This dry run validates a property assignment." +msgstr "Dieser Probelauf validiert eine Eigenschaftszuweisung." + +msgid "This parameter can be null to indicate no value." +msgstr "Dieser Parameter kann null sein, um keinen Wert anzugeben." + +msgid "This property is read-only and takes no arguments." +msgstr "Diese Eigenschaft ist schreibgeschützt und akzeptiert keine Argumente." + +msgid "This property returns a {class_name} wrapper. Continue with describe {target}. or invoke {target}.." +msgstr "Diese Eigenschaft gibt einen Wrapper vom Typ {class_name} zurück. Fahren Sie mit describe {target}. oder invoke {target}. fort." + +msgid "Tip: install pyreadline3 for tab completion support on Windows." +msgstr "Tipp: Installieren Sie pyreadline3 für Tab-Vervollständigung unter Windows." + +msgid "Treat False return values as CLI failures (exit 1). This is enabled by default for automation-friendly behavior." +msgstr "Behandelt False-Rückgabewerte als CLI-Fehler (Exit 1). Dies ist standardmäßig für automationsfreundliches Verhalten aktiviert." + +msgid "Try this:" +msgstr "Versuchen Sie Folgendes:" + +msgid "Type" +msgstr "Typ" + +msgid "Type 'help' for available commands, 'exit' to quit." +msgstr "Geben Sie 'help' für verfügbare Befehle ein, 'exit' zum Beenden." + +msgid "Type help to see available commands." +msgstr "Geben Sie help ein, um verfügbare Befehle anzuzeigen." + +msgid "Unknown batch item field(s): {fields}." +msgstr "Unbekannte Feld(er) im Batch-Eintrag: {fields}." + +msgid "Unknown command:" +msgstr "Unbekannter Befehl:" + +msgid "Unknown parameter '{parameter}' for {target}{signature}.{extra}" +msgstr "Unbekannter Parameter '{parameter}' für {target}{signature}.{extra}" + +msgid "Use 'help ' for detailed help on a specific command." +msgstr "Verwenden Sie 'help ' für detaillierte Hilfe zu einem bestimmten Befehl." + +msgid "Use JSON field '{preferred_field}' for '{param_name}'." +msgstr "Verwenden Sie das JSON-Feld '{preferred_field}' für '{param_name}'." + +msgid "Use JSON field '{preferred_field}'." +msgstr "Verwenden Sie das JSON-Feld '{preferred_field}'." + +msgid "Use a comma-separated list for quick CLI input, or a JSON array string when values contain commas." +msgstr "Verwenden Sie für eine schnelle CLI-Eingabe eine durch Kommas getrennte Liste oder eine JSON-Array-Zeichenfolge, wenn Werte Kommas enthalten." + +msgid "Use a comma-separated triplet for vector shorthand." +msgstr "Verwenden Sie für die Vektor-Kurzform ein durch Kommas getrenntes Tripel." + +msgid "Use describe to inspect parameters, examples, and property behavior before invoking." +msgstr "Verwenden Sie describe , um Parameter, Beispiele und das Verhalten von Eigenschaften vor dem Aufruf zu prüfen." + +msgid "Use semicolon-separated triplets for quick CLI input. Quote the value in shells that treat semicolons specially." +msgstr "Verwenden Sie für eine schnelle CLI-Eingabe durch Semikolons getrennte Tripel. Setzen Sie den Wert in Anführungszeichen, wenn Ihre Shell Semikolons speziell behandelt." + msgid "Using prompts will use pop-up import options and will always show logs" msgstr "Bei Verwendung von Eingabeaufforderungen werden Popup-Importoptionen verwendet und Protokolle stets angezeigt" @@ -112,7 +775,22 @@ msgid "Valid Input" msgstr "Gültige Eingabe" msgid "Valid Input Type" -msgstr "Gültiger Eingangstyp" +msgstr "Gültiger Eingabetyp" + +msgid "Vector" +msgstr "Vektor" + +msgid "When JSON input is provided via --params-json or --params-json-file, no positional key=value args may be given." +msgstr "Wenn JSON‑Eingabe über --params-json oder --params-json-file bereitgestellt wird, dürfen keine positionellen key=value‑Argumente angegeben werden." + +msgid "Write JSON output to the given file path without changing stdout mode." +msgstr "Schreibt die JSON-Ausgabe in den angegebenen Dateipfad, ohne den stdout-Modus zu ändern." + +msgid "Wrote structured output to {path}." +msgstr "Strukturierte Ausgabe wurde nach {path} geschrieben." + +msgid "You are already in the REPL." +msgstr "Sie befinden sich bereits in der REPL." msgid "both {first} and {second} must be provided together" msgstr "Sowohl {first} als auch {second} müssen gemeinsam angegeben werden" @@ -120,6 +798,9 @@ msgstr "Sowohl {first} als auch {second} müssen gemeinsam angegeben werden" msgid "cannot be empty" msgstr "kann nicht leer sein" +msgid "failed" +msgstr "fehlgeschlagen" + msgid "found {min_value} must be less than {max_value}" msgstr "gefunden {min_value}, muss kleiner als {max_value} sein" @@ -133,7 +814,7 @@ msgid "found {value}, must be greater than {min_value}" msgstr "gefunden {value}, muss größer sein als {min_value}" msgid "found {value}, must be less than or equal to {max_value}" -msgstr "gefunden {value}, muss geringer als oder gleich {max_value}" +msgstr "gefunden {value}, muss kleiner oder gleich {max_value} sein" msgid "found {value}, must be less than {max_value}" msgstr "gefunden {value}, muss kleiner als {max_value} sein" @@ -145,11 +826,23 @@ msgid "found {value}, must be non-zero" msgstr "gefunden {value}, muss ungleich Null sein" msgid "found {value}, must be one of {expected_values}" -msgstr "gefunden {value}, muss einer der {expected_values} sein" +msgstr "gefunden {value}, muss einer der folgenden Werte sein: {expected_values}" msgid "found {value}, must be positive" msgstr "gefunden {value}, muss positiv sein" +msgid "interactive shell" +msgstr "interaktive Shell" + +msgid "ok" +msgstr "in Ordnung" + +msgid "settable" +msgstr "setzbar" + +msgid "the owning object" +msgstr "das Besitzobjekt" + msgid "{file_name} does not have a valid file extension, will use {default}" msgstr "{file_name} hat keine gültige Dateierweiterung, es wird {default} verwendet" @@ -159,6 +852,21 @@ msgstr "{name} ist {value}" msgid "{name} parameter will be ignored" msgstr "Parameter {name} wird ignoriert" +msgid "{param} receives the Plot returned by find_plot_by_name" +msgstr "{param} erhält den von find_plot_by_name zurückgegebenen Plot" + +msgid "{type_name} ({size} items): {value}" +msgstr "{type_name} ({size} Elemente): {value}" + +msgid "{type_name} attributes:" +msgstr "Attribute von {type_name}:" + +msgid "{type_name} result:" +msgstr "Ergebnis für {type_name}:" + +msgid "{type_name} values ({count} items):" +msgstr "{type_name}-Werte ({count} Elemente):" + msgid "{value} cannot be found documented in {enum_name}, this may cause function call to fail" msgstr "{value} ist in {enum_name} nicht dokumentiert; dies kann zum Fehlschlagen des Funktionsaufrufs führen" diff --git a/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po b/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po index 9d967cb..5aec908 100644 --- a/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po +++ b/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po @@ -3,9 +3,150 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: en-US\n" +msgid " Did you mean '{parameter}'?" +msgstr " Did you mean '{parameter}'?" + +msgid " Known parameters: {known_params}." +msgstr " Known parameters: {known_params}." + +msgid "'{type_name}' no longer exposes adapter method '{method_name}'." +msgstr "'{type_name}' no longer exposes adapter method '{method_name}'." + +msgid "--yaml requested but PyYAML is not installed: {error}" +msgstr "--yaml requested but PyYAML is not installed: {error}" + +msgid "--yaml requested but PyYAML is not installed: {exc}" +msgstr "--yaml requested but PyYAML is not installed: {exc}" + +msgid "Aborted." +msgstr "Aborted." + +msgid "Advanced fallback only. Use this tagged shape when annotation context is unavailable, when a nested payload is truly generic, or when multiple wrapper families would be ambiguous." +msgstr "Advanced fallback only. Use this tagged shape when annotation context is unavailable, when a nested payload is truly generic, or when multiple wrapper families would be ambiguous." + +msgid "Argument '{argument}' must specify a parameter name after the step (e.g., {step_name}.param=...).{extra}" +msgstr "Argument '{argument}' must specify a parameter name after the step (e.g., {step_name}.param=...).{extra}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}" +msgstr "Argument '{argument}' must start with one of: {valid_steps}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "Argument '{argument}' must start with one of: {valid_steps}.{extra}" + +msgid "Argument '{step_name}' must start with one of: {names}" +msgstr "Argument '{step_name}' must start with one of: {names}" + +msgid "Argument error calling {target}{signature}: {error}" +msgstr "Argument error calling {target}{signature}: {error}" + +msgid "Arguments as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value. Duplicate or conflicting paths are rejected (for example param=1 with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "Arguments as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value. Duplicate or conflicting paths are rejected (for example param=1 with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." + +msgid "Arguments for step '{step_name}' must be a JSON object of parameters." +msgstr "Arguments for step '{step_name}' must be a JSON object of parameters." + +msgid "Array" +msgstr "Array" + +msgid "Attribute '{matched_name}' on class '{class_name}' returns a non-wrapper value{continuation}" +msgstr "Attribute '{matched_name}' on class '{class_name}' returns a non-wrapper value{continuation}" + +msgid "Attribute '{matched_name}' on object '{type_name}' returns a non-wrapper value{continuation}" +msgstr "Attribute '{matched_name}' on object '{type_name}' returns a non-wrapper value{continuation}" + +msgid "Batch file must contain a JSON array of invoke call objects." +msgstr "Batch file must contain a JSON array of invoke call objects." + +msgid "Batch item field 'args' must be a list of strings." +msgstr "Batch item field 'args' must be a list of strings." + +msgid "Batch item field 'params_json_file' must be a string path." +msgstr "Batch item field 'params_json_file' must be a string path." + +msgid "Batch item must be a JSON object." +msgstr "Batch item must be a JSON object." + +msgid "Batch item requires string field 'target'." +msgstr "Batch item requires string field 'target'." + +msgid "Batch item {index} error: {error}" +msgstr "Batch item {index} error: {error}" + +msgid "Batch results" +msgstr "Batch results" + +msgid "Batch summary: {succeeded}/{total} succeeded, {failed} failed." +msgstr "Batch summary: {succeeded}/{total} succeeded, {failed} failed." + +msgid "CLI argument: {value}" +msgstr "CLI argument: {value}" + msgid "Cancel" msgstr "Cancel" +msgid "Cannot assign property '{property_name}' while resolving target '{target}' because the owner object resolved to None." +msgstr "Cannot assign property '{property_name}' while resolving target '{target}' because the owner object resolved to None." + +msgid "Cannot build instance for type 'EntList'. No create_entity_list provider found." +msgstr "Cannot build instance for type 'EntList'. No create_entity_list provider found." + +msgid "Cannot build instance for type '{type_name}'. Not a known Synergy property or factory." +msgstr "Cannot build instance for type '{type_name}'. Not a known Synergy property or factory." + +msgid "Cannot configure field '{key_name}' on '{type_name}': {error}" +msgstr "Cannot configure field '{key_name}' on '{type_name}': {error}" + +msgid "Cannot invoke method '{segment}' for target '{target}' because '{owner}' is unavailable in the current session (it resolved to None). This target only works when that object exists." +msgstr "Cannot invoke method '{segment}' for target '{target}' because '{owner}' is unavailable in the current session (it resolved to None). This target only works when that object exists." + +msgid "Cannot read JSON file '{file}': {exc}" +msgstr "Cannot read JSON file '{file}': {exc}" + +msgid "Cannot read JSON file '{path}': {error}" +msgstr "Cannot read JSON file '{path}': {error}" + +msgid "Cannot read batch file '{path}': {error}" +msgstr "Cannot read batch file '{path}': {error}" + +msgid "Cannot resolve '{first}' on moldflow for introspection" +msgstr "Cannot resolve '{first}' on moldflow for introspection" + +msgid "Cannot resolve attribute '{segment}' on '{class_name}' when executing target '{target}': {error}" +msgstr "Cannot resolve attribute '{segment}' on '{class_name}' when executing target '{target}': {error}" + +msgid "Cannot resolve attribute '{segment}' without an object instance when resolving target '{target}'" +msgstr "Cannot resolve attribute '{segment}' without an object instance when resolving target '{target}'" + +msgid "Cannot resolve segment '{segment}' in target '{target}' without a class context. Use a Synergy-rooted target such as 'synergy.some_method'." +msgstr "Cannot resolve segment '{segment}' in target '{target}' without a class context. Use a Synergy-rooted target such as 'synergy.some_method'." + +msgid "Cannot set nested argument '{path}': {error}" +msgstr "Cannot set nested argument '{path}': {error}" + +msgid "Cannot set nested attributes for '{param_name}' without signature info on '{step_name}'." +msgstr "Cannot set nested attributes for '{param_name}' without signature info on '{step_name}'." + +msgid "Cannot set property '{property_name}' on target '{target}': {error}" +msgstr "Cannot set property '{property_name}' on target '{target}': {error}" + +msgid "Cannot write JSON file '{path}': {error}" +msgstr "Cannot write JSON file '{path}': {error}" + +msgid "Canonical JSON field is derived from the reflected wrapper method signature for {method_name}()." +msgstr "Canonical JSON field is derived from the reflected wrapper method signature for {method_name}()." + +msgid "Canonical list field is derived from the reflected wrapper method {method_name}()." +msgstr "Canonical list field is derived from the reflected wrapper method {method_name}()." + +msgid "Canonical triplet field is derived from the reflected wrapper method {method_name}()." +msgstr "Canonical triplet field is derived from the reflected wrapper method {method_name}()." + +msgid "Canonical vector-array field is derived from the reflected wrapper method {method_name}()." +msgstr "Canonical vector-array field is derived from the reflected wrapper method {method_name}()." + +msgid "Chained targets with repeated method names are ambiguous for argument routing: {duplicate_names}. Please use an equivalent target path where each invoked step name is unique." +msgstr "Chained targets with repeated method names are ambiguous for argument routing: {duplicate_names}. Please use an equivalent target path where each invoked step name is unique." + msgid "Checking file extension {file_name}" msgstr "Checking file extension {file_name}" @@ -42,24 +183,207 @@ msgstr "Checking {value} is positive" msgid "Checking {value} is {name}" msgstr "Checking {value} is {name}" +msgid "Class '{class_name}' has no attribute '{attr_name}'" +msgstr "Class '{class_name}' has no attribute '{attr_name}'" + +msgid "Clear the screen" +msgstr "Clear the screen" + +msgid "Clears the terminal screen and redraws the banner." +msgstr "Clears the terminal screen and redraws the banner." + +msgid "Command exited with status {code}" +msgstr "Command exited with status {code}" + +msgid "Commands:" +msgstr "Commands:" + +msgid "Conflicting argument paths '{left_path}' and '{right_path}' are not allowed." +msgstr "Conflicting argument paths '{left_path}' and '{right_path}' are not allowed." + msgid "Could not initialize with Instance ID: {value}" msgstr "Could not initialize with Instance ID: {value}" +msgid "Ctrl+D also exits." +msgstr "Ctrl+D also exits." + +msgid "Detail" +msgstr "Detail" + +msgid "Direct parameter assignment remains the preferred non-JSON form." +msgstr "Direct parameter assignment remains the preferred non-JSON form." + +msgid "Disable ANSI color/styling in CLI output." +msgstr "Disable ANSI color/styling in CLI output." + +msgid "Discover invokable targets and the next command to run for each one." +msgstr "Discover invokable targets and the next command to run for each one." + +msgid "Do not pass TARGET when --batch-file is used." +msgstr "Do not pass TARGET when --batch-file is used." + +msgid "Do not pass positional args/JSON input with --batch-file." +msgstr "Do not pass positional args/JSON input with --batch-file." + +msgid "Dotted path to a method or function, optionally chained, for example 'synergy.new_project' or 'synergy.plot_manager.find_plot_by_name'." +msgstr "Dotted path to a method or function, optionally chained, for example 'synergy.new_project' or 'synergy.plot_manager.find_plot_by_name'." + +msgid "Dotted path, e.g., synergy.new_project" +msgstr "Dotted path, e.g., synergy.new_project" + +msgid "Dry run for {target}" +msgstr "Dry run for {target}" + +msgid "Duplicate argument path '{path}' is not allowed." +msgstr "Duplicate argument path '{path}' is not allowed." + +msgid "Duplicate/conflicting paths are rejected. Arguments are passed as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value (for example param=1 conflicts with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "Duplicate/conflicting paths are rejected. Arguments are passed as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value (for example param=1 conflicts with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." + +msgid "Emit a JSON schema-like representation of target parameters." +msgstr "Emit a JSON schema-like representation of target parameters." + +msgid "Emit line-delimited JSON trace events to stderr for target resolution and runtime invoke binding." +msgstr "Emit line-delimited JSON trace events to stderr for target resolution and runtime invoke binding." + +msgid "Emit structured JSON for scripting or agent use." +msgstr "Emit structured JSON for scripting or agent use." + +msgid "Emit structured JSON for scripting or agent use." +msgstr "Emit structured JSON for scripting or agent use." + +msgid "Emit structured YAML for scripting or agent use (requires PyYAML)." +msgstr "Emit structured YAML for scripting or agent use (requires PyYAML)." + +msgid "Emit structured YAML for scripting or agent use (requires PyYAML)." +msgstr "Emit structured YAML for scripting or agent use (requires PyYAML)." + +msgid "Emit the structured result as JSON to stdout (useful for automation/LLMs)." +msgstr "Emit the structured result as JSON to stdout (useful for automation/LLMs)." + +msgid "Empty target" +msgstr "Empty target" + +msgid "Empty type tag is not valid for '{primary_expected}'." +msgstr "Empty type tag is not valid for '{primary_expected}'." + +msgid "Error:" +msgstr "Error:" + +msgid "Example object shape: {shape}." +msgstr "Example object shape: {shape}." + msgid "Executing {name}" msgstr "Executing {name}" +msgid "Exit the REPL" +msgstr "Exit the REPL" + +msgid "Exits the REPL." +msgstr "Exits the REPL." + +msgid "Explicit field form: {value}" +msgstr "Explicit field form: {value}" + msgid "Failed to initialize Synergy: Synergy not found" msgstr "Failed to initialize Synergy: Synergy not found" +msgid "Failed to render JSON output for {context}: {error}" +msgstr "Failed to render JSON output for {context}: {error}" + +msgid "Failed to render JSON output for {context}: {exc}" +msgstr "Failed to render JSON output for {context}: {exc}" + +msgid "Failed to render YAML output for {context}: {error}" +msgstr "Failed to render YAML output for {context}: {error}" + +msgid "Failed to render YAML output for {context}: {exc}" +msgstr "Failed to render YAML output for {context}: {exc}" + +msgid "Failed to reset session:" +msgstr "Failed to reset session:" + +msgid "Field '{key_name}' is not valid for '{type_name}'." +msgstr "Field '{key_name}' is not valid for '{type_name}'." + +msgid "Field '{key}' for '{type_name}' must be a 3-item numeric sequence like [0, 0, 1]." +msgstr "Field '{key}' for '{type_name}' must be a 3-item numeric sequence like [0, 0, 1]." + +msgid "Field '{key}' for '{type_name}' must be a JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "Field '{key}' for '{type_name}' must be a JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." + +msgid "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list." +msgstr "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list." + +msgid "Field '{key}' for '{type_name}' must be a comma-separated numeric triplet like '0,0,1'." +msgstr "Field '{key}' for '{type_name}' must be a comma-separated numeric triplet like '0,0,1'." + +msgid "Field '{key}' for '{type_name}' must be a list of numeric triplets." +msgstr "Field '{key}' for '{type_name}' must be a list of numeric triplets." + +msgid "Field '{key}' for '{type_name}' must be a string selection expression. Expected field: {preferred_field}." +msgstr "Field '{key}' for '{type_name}' must be a string selection expression. Expected field: {preferred_field}." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "Field '{key}' for '{type_name}' must be a valid JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array or a comma-separated list." +msgstr "Field '{key}' for '{type_name}' must be a valid JSON array or a comma-separated list." + +msgid "Field '{key}' for '{type_name}' must contain integer values." +msgstr "Field '{key}' for '{type_name}' must contain integer values." + +msgid "Field '{key}' for '{type_name}' must contain numeric values." +msgstr "Field '{key}' for '{type_name}' must contain numeric values." + +msgid "Fields '{previous_key}' and '{key}' both map to the same input for '{type_name}'. Provide only one of: {preferred_field} or direct parameter shorthand." +msgstr "Fields '{previous_key}' and '{key}' both map to the same input for '{type_name}'. Provide only one of: {preferred_field} or direct parameter shorthand." + +msgid "Filter by substring or wildcard pattern (* and ?)." +msgstr "Filter by substring or wildcard pattern (* and ?)." + +msgid "Filter by substring or wildcard pattern (* and ?). Repeat to keep targets matching any filter." +msgstr "Filter by substring or wildcard pattern (* and ?). Repeat to keep targets matching any filter." + +msgid "Filtered matches:" +msgstr "Filtered matches:" + +msgid "For JSON input, group parameters by step name. Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "For JSON input, group parameters by step name. Argument '{argument}' must start with one of: {valid_steps}.{extra}" + +msgid "For multi-step targets, group params-json fields by step name." +msgstr "For multi-step targets, group params-json fields by step name." + msgid "Getting {name}" msgstr "Getting {name}" msgid "Getting {name} at index {value}" msgstr "Getting {name} at index {value}" +msgid "Goodbye!" +msgstr "Goodbye!" + +msgid "If shorthand input is ambiguous, switch to --params-json. {guidance}" +msgstr "If shorthand input is ambiguous, switch to --params-json. {guidance}" + +msgid "In non-JSON mode, prefer direct shorthand like '{preferred_non_json}'." +msgstr "In non-JSON mode, prefer direct shorthand like '{preferred_non_json}'." + +msgid "Index" +msgstr "Index" + msgid "Initializing {name}" msgstr "Initializing {name}" +msgid "Input hints:" +msgstr "Input hints:" + +msgid "Inspect a target's signature, docs, examples, and structured invoke template." +msgstr "Inspect a target's signature, docs, examples, and structured invoke template." + +msgid "Interrupted." +msgstr "Interrupted." + msgid "Invalid Attribute: {attribute} is not supported" msgstr "Invalid Attribute: {attribute} is not supported" @@ -69,18 +393,198 @@ msgstr "Invalid FileType: {file_name}, must be {extensions}" msgid "Invalid Index: out of range" msgstr "Invalid Index: out of range" +msgid "Invalid JSON payload for parameters: {error}" +msgstr "Invalid JSON payload for parameters: {error}" + +msgid "Invalid JSON payload for parameters: {exc}" +msgstr "Invalid JSON payload for parameters: {exc}" + +msgid "Invalid JSON value for parameter '{param_name}': {error}" +msgstr "Invalid JSON value for parameter '{param_name}': {error}" + msgid "Invalid Type: must be {expected_types}, not {variable_type}" msgstr "Invalid Type: must be {expected_types}, not {variable_type}" msgid "Invalid Value: {reason}" msgstr "Invalid Value: {reason}" +msgid "Invalid argument '{item}'. Expected key=value or param.attr=value." +msgstr "Invalid argument '{item}'. Expected key=value or param.attr=value." + +msgid "Invalid argument for step '{step_name}': missing parameter name." +msgstr "Invalid argument for step '{step_name}': missing parameter name." + +msgid "Invalid nested argument path '{path}': attribute '{attr}' does not exist on '{obj_type}'." +msgstr "Invalid nested argument path '{path}': attribute '{attr}' does not exist on '{obj_type}'." + +msgid "Invalid nested argument path '{path}': cannot nest into non-object '{obj_type}'." +msgstr "Invalid nested argument path '{path}': cannot nest into non-object '{obj_type}'." + +msgid "Invalid nested argument path '{path}': cannot set '{final_attr}' on non-object '{obj_type}'." +msgstr "Invalid nested argument path '{path}': cannot set '{final_attr}' on non-object '{obj_type}'." + +msgid "Invalid value for parameter '{param_name}': {error}" +msgstr "Invalid value for parameter '{param_name}': {error}" + +msgid "Invalid {field_name} '{path_text}': empty path segment is not allowed." +msgstr "Invalid {field_name} '{path_text}': empty path segment is not allowed." + +msgid "Invalid {field_name} '{path_text}': segment '{segment}' must be a valid identifier." +msgstr "Invalid {field_name} '{path_text}': segment '{segment}' must be a valid identifier." + +msgid "Invalid {field_name}: value cannot be empty." +msgstr "Invalid {field_name}: value cannot be empty." + +msgid "JSON example:" +msgstr "JSON example:" + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays and scalars are not allowed." +msgstr "JSON object containing parameter mappings (overrides positional args). Top-level arrays and scalars are not allowed." + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays/scalars are not allowed." +msgstr "JSON object containing parameter mappings (overrides positional args). Top-level arrays/scalars are not allowed." + +msgid "JSON parameters must be a JSON object of named arguments. Example: --params-json '{\"param\": 1}' or --params-json '{\"step\": {\"param\": 1}}' for chained targets." +msgstr "JSON parameters must be a JSON object of named arguments. Example: --params-json '{\"param\": 1}' or --params-json '{\"step\": {\"param\": 1}}' for chained targets." + +msgid "JSON type tag '{type_tag}' does not match expected wrapper '{primary_expected}'." +msgstr "JSON type tag '{type_tag}' does not match expected wrapper '{primary_expected}'." + +msgid "JSON value:" +msgstr "JSON value:" + +msgid "List result:" +msgstr "List result:" + msgid "Logger was not setup" msgstr "Logger was not setup" +msgid "Missing required parameter '{parameter}' for {target}{signature}" +msgstr "Missing required parameter '{parameter}' for {target}{signature}" + +msgid "Moldflow command-line interface.\n\nStart with 'list' to discover targets, 'describe ' to inspect usage, then 'invoke ...' to run it." +msgstr "Moldflow command-line interface.\n\nStart with 'list' to discover targets, 'describe ' to inspect usage, then 'invoke ...' to run it." + +msgid "Moldflow invokable targets" +msgstr "Moldflow invokable targets" + +msgid "Nested argument '{path}' is not supported for **kwargs on step '{step_name}'. Use a single key (e.g., {example}=...)." +msgstr "Nested argument '{path}' is not supported for **kwargs on step '{step_name}'. Use a single key (e.g., {example}=...)." + +msgid "No invokable targets matched these filters." +msgstr "No invokable targets matched these filters." + +msgid "No invokable targets matched this filter." +msgstr "No invokable targets matched this filter." + +msgid "Non-public argument path '{key}' is not allowed." +msgstr "Non-public argument path '{key}' is not allowed." + +msgid "Non-public argument path '{left}' is not allowed." +msgstr "Non-public argument path '{left}' is not allowed." + +msgid "Non-public argument path '{path}' is not allowed." +msgstr "Non-public argument path '{path}' is not allowed." + +msgid "Non-public argument path '{step_name}' is not allowed." +msgstr "Non-public argument path '{step_name}' is not allowed." + +msgid "Non-public argument path '{step}.{key}' is not allowed." +msgstr "Non-public argument path '{step}.{key}' is not allowed." + +msgid "Non-public field '{key_name}' is not allowed when constructing '{type_name}' from JSON." +msgstr "Non-public field '{key_name}' is not allowed when constructing '{type_name}' from JSON." + +msgid "Non-public segment '{segment}' is not allowed in target '{target}'." +msgstr "Non-public segment '{segment}' is not allowed in target '{target}'." + +msgid "Non-public segment '{seg}' is not allowed in target '{target}'." +msgstr "Non-public segment '{seg}' is not allowed in target '{target}'." + msgid "OK" msgstr "OK" +msgid "Object" +msgstr "Object" + +msgid "Object '{type_name}' has no attribute '{attr_name}'" +msgstr "Object '{type_name}' has no attribute '{attr_name}'" + +msgid "One or more dotted targets, for example synergy.new_project." +msgstr "One or more dotted targets, for example synergy.new_project." + +msgid "Only one of --json or --yaml may be specified." +msgstr "Only one of --json or --yaml may be specified." + +msgid "Only one of --json, --yaml, or --schema may be specified." +msgstr "Only one of --json, --yaml, or --schema may be specified." + +msgid "Only one of --params-json or --params-json-file may be specified." +msgstr "Only one of --params-json or --params-json-file may be specified." + +msgid "Parameter '{param_name}' contains a null byte which is not allowed." +msgstr "Parameter '{param_name}' contains a null byte which is not allowed." + +msgid "Parameter '{param_name}' contains control characters (newline/tab/carriage return); please provide a single-line value or quote/escape as needed." +msgstr "Parameter '{param_name}' contains control characters (newline/tab/carriage return); please provide a single-line value or quote/escape as needed." + +msgid "Parse error:" +msgstr "Parse error:" + +msgid "Parse/validate/build kwargs and emit a call plan without executing invoke steps." +msgstr "Parse/validate/build kwargs and emit a call plan without executing invoke steps." + +msgid "Parse/validate/build kwargs and emit a template summary call plan without executing invoke steps." +msgstr "Parse/validate/build kwargs and emit a template summary call plan without executing invoke steps." + +msgid "Path to a JSON file containing an array of invoke calls for batch execution." +msgstr "Path to a JSON file containing an array of invoke calls for batch execution." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object, not arrays/scalars." +msgstr "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object, not arrays/scalars." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object." +msgstr "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object." + +msgid "Planned steps:" +msgstr "Planned steps:" + +msgid "Prefer chaining invoke targets so this parameter is produced by a previous step, instead of constructing it manually in JSON." +msgstr "Prefer chaining invoke targets so this parameter is produced by a previous step, instead of constructing it manually in JSON." + +msgid "Print the installed moldflow package version." +msgstr "Print the installed moldflow package version." + +msgid "Property assignment JSON must be an object with a single 'value' field." +msgstr "Property assignment JSON must be an object with a single 'value' field." + +msgid "Property assignment requires exactly one 'value' argument (e.g., value=... or --params-json '{\"value\": ...}')." +msgstr "Property assignment requires exactly one 'value' argument (e.g., value=... or --params-json '{\"value\": ...}')." + +msgid "Property {name} (id={id}, type={prop_type})" +msgstr "Property {name} (id={id}, type={prop_type})" + +msgid "Read current value:" +msgstr "Read current value:" + +msgid "Close Synergy and reset the session" +msgstr "Close Synergy and reset the session" + +msgid "Closes Synergy and resets the session for a fresh start." +msgstr "Closes Synergy and resets the session for a fresh start." + +msgid "Resolved assignment:" +msgstr "Resolved assignment:" + +msgid "Resolved kwargs:" +msgstr "Resolved kwargs:" + +msgid "Resolved object has no callable attribute '{segment}' when executing target '{target}'" +msgstr "Resolved object has no callable attribute '{segment}' when executing target '{target}'" + +msgid "Run a Moldflow target with named parameters or JSON input. Bare targets are treated as synergy.." +msgstr "Run a Moldflow target with named parameters or JSON input. Bare targets are treated as synergy.." + msgid "Save Error" msgstr "Save Error" @@ -90,15 +594,171 @@ msgstr "Save Error: Could not save {saving} to {file_name}" msgid "Save Error: Failed to save {saving} to {file_name}" msgstr "Save Error: Failed to save {saving} to {file_name}" +msgid "Segment '{segment}' does not resolve as an attribute on class '{class_name}' when resolving target '{target}'" +msgstr "Segment '{segment}' does not resolve as an attribute on class '{class_name}' when resolving target '{target}'" + +msgid "Segment '{segment}' is not a callable method on class '{class_name}' when resolving target '{target}'" +msgstr "Segment '{segment}' is not a callable method on class '{class_name}' when resolving target '{target}'" + +msgid "Selection" +msgstr "Selection" + +msgid "Session:" +msgstr "Session:" + +msgid "Set it with:" +msgstr "Set it with:" + msgid "Setting {name} to {value}" msgstr "Setting {name} to {value}" +msgid "Shorter JSON example:" +msgstr "Shorter JSON example:" + +msgid "Shorter form:" +msgstr "Shorter form:" + +msgid "Show detailed help for a command" +msgstr "Show detailed help for a command" + +msgid "Show full tracebacks on errors instead of short messages." +msgstr "Show full tracebacks on errors instead of short messages." + +msgid "Show this help message" +msgstr "Show this help message" + +msgid "Showing the compact table for {count} filtered matches. Narrow the filter or use --json for canonical target strings." +msgstr "Showing the compact table for {count} filtered matches. Narrow the filter or use --json for canonical target strings." + +msgid "Shows available commands and usage information." +msgstr "Shows available commands and usage information." + +msgid "Start an interactive moldflow shell session." +msgstr "Start an interactive moldflow shell session." + +msgid "Status" +msgstr "Status" + +msgid "Step '{step_name}' in target '{target}' has positional-only parameters ({parameters}), which are not supported by CLI named-argument routing. Use the Python API for this target." +msgstr "Step '{step_name}' in target '{target}' has positional-only parameters ({parameters}), which are not supported by CLI named-argument routing. Use the Python API for this target." + msgid "Submit" msgstr "Submit" +msgid "Synergy session reset." +msgstr "Synergy session reset." + +msgid "TARGET is required unless --batch-file is used." +msgstr "TARGET is required unless --batch-file is used." + +msgid "Tab completion targets are refreshed automatically." +msgstr "Tab completion targets are refreshed automatically." + +msgid "Target" +msgstr "Target" + +msgid "Target '{target}' is hidden from the CLI because '{hidden_path}' only creates a transient {wrapper_type} wrapper. The CLI constructs these helper objects internally when needed, so they are not exposed as direct CLI targets." +msgstr "Target '{target}' is hidden from the CLI because '{hidden_path}' only creates a transient {wrapper_type} wrapper. The CLI constructs these helper objects internally when needed, so they are not exposed as direct CLI targets." + +msgid "Target '{target}' is hidden from the CLI by library metadata on '{hidden_path}'." +msgstr "Target '{target}' is hidden from the CLI by library metadata on '{hidden_path}'." + +msgid "Target '{target}' resolves to a property/attribute and does not accept arguments." +msgstr "Target '{target}' resolves to a property/attribute and does not accept arguments." + +msgid "Target '{target}' resolves to a {class_name} wrapper property. Continue to one of its members, for example 'describe {target}.'." +msgstr "Target '{target}' resolves to a {class_name} wrapper property. Continue to one of its members, for example 'describe {target}.'." + +msgid "Target '{target}' resolves to property '{property_name}' (getter) and does not accept arguments." +msgstr "Target '{target}' resolves to property '{property_name}' (getter) and does not accept arguments." + +msgid "Target '{target}' resolves to write-only property '{property_name}' and cannot be read via invoke." +msgstr "Target '{target}' resolves to write-only property '{property_name}' and cannot be read via invoke." + +msgid "Target must include a class or function name" +msgstr "Target must include a class or function name" + +msgid "Target must include at least one segment" +msgstr "Target must include at least one segment" + +msgid "Target must start with 'synergy' (or 'moldflow.synergy'). All invocations are rooted on the Synergy COM object." +msgstr "Target must start with 'synergy' (or 'moldflow.synergy'). All invocations are rooted on the Synergy COM object." + +msgid "Target must start with 'synergy' after the optional 'moldflow.' prefix. Bare targets such as 'open_project' are accepted and are interpreted as 'synergy.open_project'." +msgstr "Target must start with 'synergy' after the optional 'moldflow.' prefix. Bare targets such as 'open_project' are accepted and are interpreted as 'synergy.open_project'." + +msgid "Targets are shown without the leading 'synergy.' prefix. Describe and invoke accept either form." +msgstr "Targets are shown without the leading 'synergy.' prefix. Describe and invoke accept either form." + msgid "Test String" msgstr "Test String" +msgid "The Moldflow CLI requires optional dependencies. Install them with: pip install 'moldflow[cli]'" +msgstr "The Moldflow CLI requires optional dependencies. Install them with: pip install 'moldflow[cli]'" + +msgid "The target returned False, which indicates a business-level failure." +msgstr "The target returned False, which indicates a business-level failure." + +msgid "This dry run validates a property assignment." +msgstr "This dry run validates a property assignment." + +msgid "This parameter can be null to indicate no value." +msgstr "This parameter can be null to indicate no value." + +msgid "This property is read-only and takes no arguments." +msgstr "This property is read-only and takes no arguments." + +msgid "This property returns a {class_name} wrapper. Continue with describe {target}. or invoke {target}.." +msgstr "This property returns a {class_name} wrapper. Continue with describe {target}. or invoke {target}.." + +msgid "Tip: install pyreadline3 for tab completion support on Windows." +msgstr "Tip: install pyreadline3 for tab completion support on Windows." + +msgid "Treat False return values as CLI failures (exit 1). This is enabled by default for automation-friendly behavior." +msgstr "Treat False return values as CLI failures (exit 1). This is enabled by default for automation-friendly behavior." + +msgid "Try this:" +msgstr "Try this:" + +msgid "Type" +msgstr "Type" + +msgid "Type 'help' for available commands, 'exit' to quit." +msgstr "Type 'help' for available commands, 'exit' to quit." + +msgid "Type help to see available commands." +msgstr "Type help to see available commands." + +msgid "Unknown batch item field(s): {fields}." +msgstr "Unknown batch item field(s): {fields}." + +msgid "Unknown command:" +msgstr "Unknown command:" + +msgid "Unknown parameter '{parameter}' for {target}{signature}.{extra}" +msgstr "Unknown parameter '{parameter}' for {target}{signature}.{extra}" + +msgid "Use 'help ' for detailed help on a specific command." +msgstr "Use 'help ' for detailed help on a specific command." + +msgid "Use JSON field '{preferred_field}' for '{param_name}'." +msgstr "Use JSON field '{preferred_field}' for '{param_name}'." + +msgid "Use JSON field '{preferred_field}'." +msgstr "Use JSON field '{preferred_field}'." + +msgid "Use a comma-separated list for quick CLI input, or a JSON array string when values contain commas." +msgstr "Use a comma-separated list for quick CLI input, or a JSON array string when values contain commas." + +msgid "Use a comma-separated triplet for vector shorthand." +msgstr "Use a comma-separated triplet for vector shorthand." + +msgid "Use describe to inspect parameters, examples, and property behavior before invoking." +msgstr "Use describe to inspect parameters, examples, and property behavior before invoking." + +msgid "Use semicolon-separated triplets for quick CLI input. Quote the value in shells that treat semicolons specially." +msgstr "Use semicolon-separated triplets for quick CLI input. Quote the value in shells that treat semicolons specially." + msgid "Using prompts will use pop-up import options and will always show logs" msgstr "Using prompts will use pop-up import options and will always show logs" @@ -114,12 +774,39 @@ msgstr "Valid Input" msgid "Valid Input Type" msgstr "Valid Input Type" +msgid "Vector" +msgstr "Vector" + +msgid "When JSON input is provided via --params-json or --params-json-file, no positional key=value args may be given." +msgstr "When JSON input is provided via --params-json or --params-json-file, no positional key=value args may be given." + +msgid "Write JSON output to the given file path without changing stdout mode." +msgstr "Write JSON output to the given file path without changing stdout mode." + +msgid "Wrote structured output to {path}." +msgstr "Wrote structured output to {path}." + +msgid "You are already in the REPL." +msgstr "You are already in the REPL." + +msgid "\nDid you mean step '{step_name}'?" +msgstr "\nDid you mean step '{step_name}'?" + +msgid "\nFor JSON input on multi-step targets, group parameters by step name, e.g. {example}" +msgstr "\nFor JSON input on multi-step targets, group parameters by step name, e.g. {example}" + +msgid "\nFor JSON input, this step key must map to an object of parameter names, e.g. {example}" +msgstr "\nFor JSON input, this step key must map to an object of parameter names, e.g. {example}" + msgid "both {first} and {second} must be provided together" msgstr "both {first} and {second} must be provided together" msgid "cannot be empty" msgstr "cannot be empty" +msgid "failed" +msgstr "failed" + msgid "found {min_value} must be less than {max_value}" msgstr "found {min_value} must be less than {max_value}" @@ -150,6 +837,18 @@ msgstr "found {value}, must be one of {expected_values}" msgid "found {value}, must be positive" msgstr "found {value}, must be positive" +msgid "interactive shell" +msgstr "interactive shell" + +msgid "ok" +msgstr "ok" + +msgid "settable" +msgstr "settable" + +msgid "the owning object" +msgstr "the owning object" + msgid "{file_name} does not have a valid file extension, will use {default}" msgstr "{file_name} does not have a valid file extension, will use {default}" @@ -159,6 +858,21 @@ msgstr "{name} is {value}" msgid "{name} parameter will be ignored" msgstr "{name} parameter will be ignored" +msgid "{param} receives the Plot returned by find_plot_by_name" +msgstr "{param} receives the Plot returned by find_plot_by_name" + +msgid "{type_name} ({size} items): {value}" +msgstr "{type_name} ({size} items): {value}" + +msgid "{type_name} attributes:" +msgstr "{type_name} attributes:" + +msgid "{type_name} result:" +msgstr "{type_name} result:" + +msgid "{type_name} values ({count} items):" +msgstr "{type_name} values ({count} items):" + msgid "{value} cannot be found documented in {enum_name}, this may cause function call to fail" msgstr "{value} cannot be found documented in {enum_name}, this may cause function call to fail" diff --git a/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po b/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po index a55e0f6..7a89fe8 100644 --- a/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po +++ b/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po @@ -3,9 +3,159 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: es-ES\n" +msgid "\nDid you mean step '{step_name}'?" +msgstr "\n¿Quiso decir el paso '{step_name}'?" + +msgid "\nFor JSON input on multi-step targets, group parameters by step name, e.g. {example}" +msgstr "\nPara la entrada JSON en destinos de varios pasos, agrupe los parámetros por nombre de paso, por ejemplo {example}" + +msgid "\nFor JSON input, this step key must map to an object of parameter names, e.g. {example}" +msgstr "\nPara la entrada JSON, esta clave de paso debe corresponder a un objeto de nombres de parámetros, por ejemplo {example}" + +msgid " Did you mean '{parameter}'?" +msgstr " ¿Quiso decir '{parameter}'?" + +msgid " Known parameters: {known_params}." +msgstr " Parámetros conocidos: {known_params}." + +msgid "'{type_name}' no longer exposes adapter method '{method_name}'." +msgstr "'{type_name}' ya no expone el método adaptador '{method_name}'." + +msgid "--yaml requested but PyYAML is not installed: {error}" +msgstr "Se solicitó --yaml pero PyYAML no está instalado: {error}" + +msgid "--yaml requested but PyYAML is not installed: {exc}" +msgstr "Se solicitó --yaml pero PyYAML no está instalado: {exc}" + +msgid "Aborted." +msgstr "Cancelado." + +msgid "Advanced fallback only. Use this tagged shape when annotation context is unavailable, when a nested payload is truly generic, or when multiple wrapper families would be ambiguous." +msgstr "Solo para casos avanzados. Use esta forma etiquetada cuando el contexto de anotación no esté disponible, cuando una carga anidada sea realmente genérica o cuando varias familias de wrappers puedan resultar ambiguas." + +msgid "Argument '{argument}' must specify a parameter name after the step (e.g., {step_name}.param=...).{extra}" +msgstr "El argumento '{argument}' debe especificar un nombre de parámetro después del paso (por ejemplo, {step_name}.param=...).{extra}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}" +msgstr "El argumento '{argument}' debe comenzar con uno de los siguientes: {valid_steps}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "El argumento '{argument}' debe comenzar con uno de los siguientes: {valid_steps}.{extra}" + +msgid "Argument '{step_name}' must start with one of: {names}" +msgstr "El argumento '{step_name}' debe comenzar con uno de los siguientes: {names}" + +msgid "Argument error calling {target}{signature}: {error}" +msgstr "Error de argumento al llamar a {target}{signature}: {error}" + +msgid "Arguments as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value. Duplicate or conflicting paths are rejected (for example param=1 with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "Argumentos como key=value o param.attr=value. Para destinos encadenados, anteponga al parámetro el nombre del método; por ejemplo, find_plot_by_name.plot_name=\"My Plot\". El enrutamiento anidado usa step.param.attr=value. Se rechazan las rutas duplicadas o en conflicto (por ejemplo param=1 con param.attr=2), y los métodos con parámetros solo posicionales no son compatibles con el enrutamiento por nombre de la CLI." + +msgid "Arguments for step '{step_name}' must be a JSON object of parameters." +msgstr "Los argumentos para el paso '{step_name}' deben ser un objeto JSON con parámetros." + +msgid "Array" +msgstr "Arreglo" + +msgid "Attribute '{matched_name}' on class '{class_name}' returns a non-wrapper value{continuation}" +msgstr "El atributo '{matched_name}' de la clase '{class_name}' devuelve un valor que no es un wrapper{continuation}" + +msgid "Attribute '{matched_name}' on object '{type_name}' returns a non-wrapper value{continuation}" +msgstr "El atributo '{matched_name}' del objeto '{type_name}' devuelve un valor que no es un wrapper{continuation}" + +msgid "Batch file must contain a JSON array of invoke call objects." +msgstr "El archivo por lotes debe contener un arreglo JSON de objetos de llamada de invoke." + +msgid "Batch item field 'args' must be a list of strings." +msgstr "El campo 'args' de un elemento del lote debe ser una lista de cadenas." + +msgid "Batch item field 'params_json_file' must be a string path." +msgstr "El campo 'params_json_file' de un elemento del lote debe ser una cadena con una ruta." + +msgid "Batch item must be a JSON object." +msgstr "Cada elemento del lote debe ser un objeto JSON." + +msgid "Batch item requires string field 'target'." +msgstr "Cada elemento del lote requiere el campo de cadena 'target'." + +msgid "Batch item {index} error: {error}" +msgstr "Error en el elemento del lote {index}: {error}" + +msgid "Batch results" +msgstr "Resultados del lote" + +msgid "Batch summary: {succeeded}/{total} succeeded, {failed} failed." +msgstr "Resumen del lote: {succeeded}/{total} correctos, {failed} fallidos." + +msgid "CLI argument: {value}" +msgstr "Argumento de CLI: {value}" + msgid "Cancel" msgstr "Cancelar" +msgid "Cannot assign property '{property_name}' while resolving target '{target}' because the owner object resolved to None." +msgstr "No se puede asignar la propiedad '{property_name}' al resolver el destino '{target}' porque el objeto propietario se resolvió como None." + +msgid "Cannot build instance for type 'EntList'. No create_entity_list provider found." +msgstr "No se puede crear una instancia del tipo 'EntList'. No se encontró ningún proveedor de create_entity_list." + +msgid "Cannot build instance for type '{type_name}'. Not a known Synergy property or factory." +msgstr "No se puede crear una instancia del tipo '{type_name}'. No es una propiedad ni una factoría conocida de Synergy." + +msgid "Cannot configure field '{key_name}' on '{type_name}': {error}" +msgstr "No se puede configurar el campo '{key_name}' en '{type_name}': {error}" + +msgid "Cannot invoke method '{segment}' for target '{target}' because '{owner}' is unavailable in the current session (it resolved to None). This target only works when that object exists." +msgstr "No se puede invocar el método '{segment}' para el destino '{target}' porque '{owner}' no está disponible en la sesión actual (se resolvió como None). Este destino solo funciona cuando ese objeto existe." + +msgid "Cannot read JSON file '{file}': {exc}" +msgstr "No se puede leer el archivo JSON '{file}': {exc}" + +msgid "Cannot read JSON file '{path}': {error}" +msgstr "No se puede leer el archivo JSON '{path}': {error}" + +msgid "Cannot read batch file '{path}': {error}" +msgstr "No se puede leer el archivo por lotes '{path}': {error}" + +msgid "Cannot resolve '{first}' on moldflow for introspection" +msgstr "No se puede resolver '{first}' en moldflow para la introspección" + +msgid "Cannot resolve attribute '{segment}' on '{class_name}' when executing target '{target}': {error}" +msgstr "No se puede resolver el atributo '{segment}' en '{class_name}' al ejecutar el destino '{target}': {error}" + +msgid "Cannot resolve attribute '{segment}' without an object instance when resolving target '{target}'" +msgstr "No se puede resolver el atributo '{segment}' sin una instancia de objeto al resolver el destino '{target}'" + +msgid "Cannot resolve segment '{segment}' in target '{target}' without a class context. Use a Synergy-rooted target such as 'synergy.some_method'." +msgstr "No se puede resolver el segmento '{segment}' en el destino '{target}' sin un contexto de clase. Use un destino con raíz en Synergy, como 'synergy.some_method'." + +msgid "Cannot set nested argument '{path}': {error}" +msgstr "No se puede establecer el argumento anidado '{path}': {error}" + +msgid "Cannot set nested attributes for '{param_name}' without signature info on '{step_name}'." +msgstr "No se pueden establecer atributos anidados para '{param_name}' sin información de firma en '{step_name}'." + +msgid "Cannot set property '{property_name}' on target '{target}': {error}" +msgstr "No se puede establecer la propiedad '{property_name}' en el destino '{target}': {error}" + +msgid "Cannot write JSON file '{path}': {error}" +msgstr "No se puede escribir el archivo JSON '{path}': {error}" + +msgid "Canonical JSON field is derived from the reflected wrapper method signature for {method_name}()." +msgstr "El campo JSON canónico se deriva de la firma reflejada del método wrapper {method_name}()." + +msgid "Canonical list field is derived from the reflected wrapper method {method_name}()." +msgstr "El campo de lista canónico se deriva del método wrapper reflejado {method_name}()." + +msgid "Canonical triplet field is derived from the reflected wrapper method {method_name}()." +msgstr "El campo de tripleta canónico se deriva del método wrapper reflejado {method_name}()." + +msgid "Canonical vector-array field is derived from the reflected wrapper method {method_name}()." +msgstr "El campo canónico de arreglo de vectores se deriva del método wrapper reflejado {method_name}()." + +msgid "Chained targets with repeated method names are ambiguous for argument routing: {duplicate_names}. Please use an equivalent target path where each invoked step name is unique." +msgstr "Los destinos encadenados con nombres de método repetidos son ambiguos para el enrutamiento de argumentos: {duplicate_names}. Use una ruta de destino equivalente en la que cada nombre de paso invocado sea único." + msgid "Checking file extension {file_name}" msgstr "Comprobando la extensión del archivo {file_name}" @@ -42,24 +192,207 @@ msgstr "Comprobando que {value} es positivo" msgid "Checking {value} is {name}" msgstr "Verificar {value} es {name}" +msgid "Class '{class_name}' has no attribute '{attr_name}'" +msgstr "La clase '{class_name}' no tiene el atributo '{attr_name}'" + +msgid "Clear the screen" +msgstr "Limpiar la pantalla" + +msgid "Clears the terminal screen and redraws the banner." +msgstr "Limpia la pantalla del terminal y vuelve a dibujar el banner." + +msgid "Close Synergy and reset the session" +msgstr "Cerrar Synergy y restablecer la sesión" + +msgid "Closes Synergy and resets the session for a fresh start." +msgstr "Cierra Synergy y restablece la sesión para empezar de nuevo." + +msgid "Command exited with status {code}" +msgstr "El comando finalizó con el estado {code}" + +msgid "Commands:" +msgstr "Comandos:" + +msgid "Conflicting argument paths '{left_path}' and '{right_path}' are not allowed." +msgstr "No se permiten las rutas de argumentos en conflicto '{left_path}' y '{right_path}'." + msgid "Could not initialize with Instance ID: {value}" msgstr "No se pudo inicializar con el ID de instancia: {value}" +msgid "Ctrl+D also exits." +msgstr "Ctrl+D también sale." + +msgid "Detail" +msgstr "Detalle" + +msgid "Direct parameter assignment remains the preferred non-JSON form." +msgstr "La asignación directa de parámetros sigue siendo la forma no JSON preferida." + +msgid "Disable ANSI color/styling in CLI output." +msgstr "Desactivar color/estilo ANSI en la salida de la CLI." + +msgid "Discover invokable targets and the next command to run for each one." +msgstr "Descubra los objetivos invocables y el siguiente comando que debe ejecutar para cada uno." + +msgid "Do not pass TARGET when --batch-file is used." +msgstr "No pase TARGET cuando se use --batch-file." + +msgid "Do not pass positional args/JSON input with --batch-file." +msgstr "No pase argumentos posicionales ni entrada JSON con --batch-file." + +msgid "Dotted path to a method or function, optionally chained, for example 'synergy.new_project' or 'synergy.plot_manager.find_plot_by_name'." +msgstr "Ruta con puntos a un método o función, opcionalmente encadenada, por ejemplo 'synergy.new_project' o 'synergy.plot_manager.find_plot_by_name'." + +msgid "Dotted path, e.g., synergy.new_project" +msgstr "Ruta con puntos, p. ej., synergy.new_project" + +msgid "Dry run for {target}" +msgstr "Simulación para {target}" + +msgid "Duplicate argument path '{path}' is not allowed." +msgstr "No se permite la ruta de argumento duplicada '{path}'." + +msgid "Duplicate/conflicting paths are rejected. Arguments are passed as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value (for example param=1 conflicts with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "Se rechazan rutas duplicadas o en conflicto. Los argumentos se pasan como key=value o param.attr=value. Para destinos encadenados, anteponga el nombre del método al parámetro, por ejemplo find_plot_by_name.plot_name=\"My Plot\". El enrutamiento anidado usa step.param.attr=value (por ejemplo, param=1 entra en conflicto con param.attr=2), y los métodos con parámetros posicionales no son compatibles con el enrutamiento CLI con nombre." + +msgid "Emit a JSON schema-like representation of target parameters." +msgstr "Emite una representación similar a un esquema JSON de los parámetros del destino." + +msgid "Emit line-delimited JSON trace events to stderr for target resolution and runtime invoke binding." +msgstr "Emite eventos de rastreo JSON delimitados por líneas en stderr para la resolución del destino y el enlace de invoke en tiempo de ejecución." + +msgid "Emit structured JSON for scripting or agent use." +msgstr "Emita JSON estructurado para scripts o uso por agentes." + +msgid "Emit structured YAML for scripting or agent use (requires PyYAML)." +msgstr "Emita YAML estructurado para scripts o uso por agentes (requiere PyYAML)." + +msgid "Emit the structured result as JSON to stdout (useful for automation/LLMs)." +msgstr "Emite el resultado estructurado como JSON en stdout (útil para automatización/LLM)." + +msgid "Empty target" +msgstr "Destino vacío" + +msgid "Empty type tag is not valid for '{primary_expected}'." +msgstr "La etiqueta de tipo vacía no es válida para '{primary_expected}'." + +msgid "Error:" +msgstr "Error :" + +msgid "Example object shape: {shape}." +msgstr "Forma de objeto de ejemplo: {shape}." + msgid "Executing {name}" msgstr "Ejecutando {name}" +msgid "Exit the REPL" +msgstr "Salir del REPL" + +msgid "Exits the REPL." +msgstr "Sale del REPL." + +msgid "Explicit field form: {value}" +msgstr "Forma de campo explícita: {value}" + msgid "Failed to initialize Synergy: Synergy not found" msgstr "Error al inicializar Synergy: Synergy no encontrada" +msgid "Failed to render JSON output for {context}: {error}" +msgstr "Error al generar la salida JSON para {context}: {error}" + +msgid "Failed to render JSON output for {context}: {exc}" +msgstr "Error al generar la salida JSON para {context}: {exc}" + +msgid "Failed to render YAML output for {context}: {error}" +msgstr "Error al generar la salida YAML para {context}: {error}" + +msgid "Failed to render YAML output for {context}: {exc}" +msgstr "Error al generar la salida YAML para {context}: {exc}" + +msgid "Failed to reset session:" +msgstr "Error al restablecer la sesión:" + +msgid "Field '{key_name}' is not valid for '{type_name}'." +msgstr "El campo '{key_name}' no es válido para '{type_name}'." + +msgid "Field '{key}' for '{type_name}' must be a 3-item numeric sequence like [0, 0, 1]." +msgstr "El campo '{key}' para '{type_name}' debe ser una secuencia numérica de 3 elementos como [0, 0, 1]." + +msgid "Field '{key}' for '{type_name}' must be a JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "El campo '{key}' para '{type_name}' debe ser un arreglo JSON de tripletas o una lista separada por punto y coma como '0,0,0;1,0,0'." + +msgid "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list." +msgstr "El campo '{key}' para '{type_name}' debe ser un arreglo JSON o una lista separada por comas." + +msgid "Field '{key}' for '{type_name}' must be a comma-separated numeric triplet like '0,0,1'." +msgstr "El campo '{key}' para '{type_name}' debe ser una tripleta numérica separada por comas como '0,0,1'." + +msgid "Field '{key}' for '{type_name}' must be a list of numeric triplets." +msgstr "El campo '{key}' para '{type_name}' debe ser una lista de tripletas numéricas." + +msgid "Field '{key}' for '{type_name}' must be a string selection expression. Expected field: {preferred_field}." +msgstr "El campo '{key}' para '{type_name}' debe ser una expresión de selección de cadena. Campo esperado: {preferred_field}." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "El campo '{key}' para '{type_name}' debe ser un arreglo JSON válido de tripletas o una lista separada por punto y coma como '0,0,0;1,0,0'." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array or a comma-separated list." +msgstr "El campo '{key}' para '{type_name}' debe ser un arreglo JSON válido o una lista separada por comas." + +msgid "Field '{key}' for '{type_name}' must contain integer values." +msgstr "El campo '{key}' para '{type_name}' debe contener valores enteros." + +msgid "Field '{key}' for '{type_name}' must contain numeric values." +msgstr "El campo '{key}' para '{type_name}' debe contener valores numéricos." + +msgid "Fields '{previous_key}' and '{key}' both map to the same input for '{type_name}'. Provide only one of: {preferred_field} or direct parameter shorthand." +msgstr "Los campos '{previous_key}' y '{key}' corresponden a la misma entrada para '{type_name}'. Proporcione solo uno de los siguientes: {preferred_field} o la forma abreviada directa del parámetro." + +msgid "Filter by substring or wildcard pattern (* and ?)." +msgstr "Filtrar por subcadena o patrón comodín (* y ?)." + +msgid "Filter by substring or wildcard pattern (* and ?). Repeat to keep targets matching any filter." +msgstr "Filtre por subcadena o patrón comodín (* y ?). Repita la opción para conservar los objetivos que coincidan con cualquiera de los filtros." + +msgid "Filtered matches:" +msgstr "Coincidencias filtradas:" + +msgid "For JSON input, group parameters by step name. Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "Para la entrada JSON, agrupe los parámetros por nombre de paso. El argumento '{argument}' debe comenzar con uno de los siguientes: {valid_steps}.{extra}" + +msgid "For multi-step targets, group params-json fields by step name." +msgstr "Para destinos de varios pasos, agrupe los campos de params-json por nombre de paso." + msgid "Getting {name}" msgstr "Obteniendo {name}" msgid "Getting {name} at index {value}" msgstr "Obteniendo {name} en el índice {value}" +msgid "Goodbye!" +msgstr "¡Adiós!" + +msgid "If shorthand input is ambiguous, switch to --params-json. {guidance}" +msgstr "Si la entrada abreviada es ambigua, cambie a --params-json. {guidance}" + +msgid "In non-JSON mode, prefer direct shorthand like '{preferred_non_json}'." +msgstr "En modo no JSON, prefiera la forma abreviada directa como '{preferred_non_json}'." + +msgid "Index" +msgstr "Índice" + msgid "Initializing {name}" msgstr "Inicializando {name}" +msgid "Input hints:" +msgstr "Sugerencias de entrada:" + +msgid "Inspect a target's signature, docs, examples, and structured invoke template." +msgstr "Inspeccione la firma, la documentación, los ejemplos y la plantilla estructurada de invoke de un objetivo." + +msgid "Interrupted." +msgstr "Interrumpido." + msgid "Invalid Attribute: {attribute} is not supported" msgstr "Atributo no válido: {attribute} no es compatible" @@ -69,18 +402,192 @@ msgstr "Tipo de archivo no válido: {file_name}, debe ser {extensions}" msgid "Invalid Index: out of range" msgstr "Índice no válido: fuera de rango" +msgid "Invalid JSON payload for parameters: {error}" +msgstr "Carga JSON inválida para parámetros: {error}" + +msgid "Invalid JSON payload for parameters: {exc}" +msgstr "Carga JSON inválida para parámetros: {exc}" + +msgid "Invalid JSON value for parameter '{param_name}': {error}" +msgstr "Valor JSON no válido para el parámetro '{param_name}': {error}" + msgid "Invalid Type: must be {expected_types}, not {variable_type}" msgstr "Tipo no válido: debe ser {expected_types}, no {variable_type}" msgid "Invalid Value: {reason}" msgstr "Valor no válido: {reason}" +msgid "Invalid argument '{item}'. Expected key=value or param.attr=value." +msgstr "Argumento inválido '{item}'. Se esperaba key=value o param.attr=value." + +msgid "Invalid argument for step '{step_name}': missing parameter name." +msgstr "Argumento no válido para el paso '{step_name}': falta el nombre del parámetro." + +msgid "Invalid nested argument path '{path}': attribute '{attr}' does not exist on '{obj_type}'." +msgstr "Ruta de argumento anidado no válida '{path}': el atributo '{attr}' no existe en '{obj_type}'." + +msgid "Invalid nested argument path '{path}': cannot nest into non-object '{obj_type}'." +msgstr "Ruta de argumento anidado no válida '{path}': no se puede anidar dentro de '{obj_type}', que no es un objeto." + +msgid "Invalid nested argument path '{path}': cannot set '{final_attr}' on non-object '{obj_type}'." +msgstr "Ruta de argumento anidado no válida '{path}': no se puede establecer '{final_attr}' en '{obj_type}', que no es un objeto." + +msgid "Invalid value for parameter '{param_name}': {error}" +msgstr "Valor no válido para el parámetro '{param_name}': {error}" + +msgid "Invalid {field_name} '{path_text}': empty path segment is not allowed." +msgstr "Campo inválido {field_name} '{path_text}': no se permite segmento de ruta vacío." + +msgid "Invalid {field_name} '{path_text}': segment '{segment}' must be a valid identifier." +msgstr "Valor no válido de {field_name} '{path_text}': el segmento '{segment}' debe ser un identificador válido." + +msgid "Invalid {field_name}: value cannot be empty." +msgstr "Campo inválido {field_name}: el valor no puede estar vacío." + +msgid "JSON example:" +msgstr "Ejemplo JSON:" + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays and scalars are not allowed." +msgstr "Objeto JSON que contiene asignaciones de parámetros (anula los argumentos posicionales). No se permiten arreglos ni escalares de nivel superior." + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays/scalars are not allowed." +msgstr "Objeto JSON que contiene asignaciones de parámetros (anula los argumentos posicionales). No se permiten arreglos ni escalares de nivel superior." + +msgid "JSON parameters must be a JSON object of named arguments. Example: --params-json '{\"param\": 1}' or --params-json '{\"step\": {\"param\": 1}}' for chained targets." +msgstr "Los parámetros JSON deben ser un objeto JSON con argumentos con nombre. Ejemplo: --params-json '{\"param\": 1}' o --params-json '{\"step\": {\"param\": 1}}' para destinos encadenados." + +msgid "JSON type tag '{type_tag}' does not match expected wrapper '{primary_expected}'." +msgstr "La etiqueta de tipo JSON '{type_tag}' no coincide con el wrapper esperado '{primary_expected}'." + +msgid "JSON value:" +msgstr "Valor JSON:" + +msgid "List result:" +msgstr "Resultado de la lista:" + msgid "Logger was not setup" msgstr "Logger no estaba configurado" +msgid "Missing required parameter '{parameter}' for {target}{signature}" +msgstr "Falta el parámetro obligatorio '{parameter}' para {target}{signature}" + +msgid "Moldflow command-line interface.\n\nStart with 'list' to discover targets, 'describe ' to inspect usage, then 'invoke ...' to run it." +msgstr "Interfaz de línea de comandos de Moldflow.\n\nEmpiece con 'list' para descubrir objetivos, use 'describe ' para inspeccionar su uso y luego ejecute 'invoke ...'." + +msgid "Moldflow invokable targets" +msgstr "Destinos invocables de Moldflow" + +msgid "Nested argument '{path}' is not supported for **kwargs on step '{step_name}'. Use a single key (e.g., {example}=...)." +msgstr "El argumento anidado '{path}' no es compatible con **kwargs en el paso '{step_name}'. Use una sola clave (por ejemplo, {example}=...)." + +msgid "No invokable targets matched these filters." +msgstr "Ningún destino invocable coincide con estos filtros." + +msgid "No invokable targets matched this filter." +msgstr "Ningún destino invocable coincide con este filtro." + +msgid "Non-public argument path '{key}' is not allowed." +msgstr "La ruta de argumento no pública '{key}' no está permitida." + +msgid "Non-public argument path '{left}' is not allowed." +msgstr "La ruta de argumento no pública '{left}' no está permitida." + +msgid "Non-public argument path '{path}' is not allowed." +msgstr "No se permite la ruta de argumento no pública '{path}'." + +msgid "Non-public argument path '{step_name}' is not allowed." +msgstr "La ruta de argumento no pública '{step_name}' no está permitida." + +msgid "Non-public argument path '{step}.{key}' is not allowed." +msgstr "La ruta de argumento no pública '{step}.{key}' no está permitida." + +msgid "Non-public field '{key_name}' is not allowed when constructing '{type_name}' from JSON." +msgstr "No se permite el campo no público '{key_name}' al construir '{type_name}' desde JSON." + +msgid "Non-public segment '{segment}' is not allowed in target '{target}'." +msgstr "No se permite el segmento no público '{segment}' en el destino '{target}'." + +msgid "Non-public segment '{seg}' is not allowed in target '{target}'." +msgstr "El segmento no público '{seg}' no está permitido en el destino '{target}'." + msgid "OK" msgstr "Aceptar" +msgid "Object" +msgstr "Objeto" + +msgid "Object '{type_name}' has no attribute '{attr_name}'" +msgstr "El objeto '{type_name}' no tiene el atributo '{attr_name}'" + +msgid "One or more dotted targets, for example synergy.new_project." +msgstr "Uno o más objetivos con notación de puntos, por ejemplo synergy.new_project." + +msgid "Only one of --json or --yaml may be specified." +msgstr "Solo se puede especificar uno de --json o --yaml." + +msgid "Only one of --json, --yaml, or --schema may be specified." +msgstr "Solo se puede especificar uno de --json, --yaml o --schema." + +msgid "Only one of --params-json or --params-json-file may be specified." +msgstr "Solo se puede especificar uno de --params-json o --params-json-file." + +msgid "Parameter '{param_name}' contains a null byte which is not allowed." +msgstr "El parámetro '{param_name}' contiene un byte nulo, lo cual no está permitido." + +msgid "Parameter '{param_name}' contains control characters (newline/tab/carriage return); please provide a single-line value or quote/escape as needed." +msgstr "El parámetro '{param_name}' contiene caracteres de control (nueva línea/tabulador/retorno de carro); proporcione un valor en una sola línea o use comillas/escape según sea necesario." + +msgid "Parse error:" +msgstr "Error de análisis:" + +msgid "Parse/validate/build kwargs and emit a call plan without executing invoke steps." +msgstr "Analiza, valida y construye kwargs, y emite un plan de llamadas sin ejecutar los pasos de invoke." + +msgid "Parse/validate/build kwargs and emit a template summary call plan without executing invoke steps." +msgstr "Analiza, valida y construye kwargs, y emite un plan de llamada de resumen de plantillas sin ejecutar los pasos de invoke." + +msgid "Path to a JSON file containing an array of invoke calls for batch execution." +msgstr "Ruta a un archivo JSON que contiene un arreglo de llamadas de invoke para su ejecución por lotes." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object, not arrays/scalars." +msgstr "Ruta a un archivo JSON que contiene asignaciones de parámetros (anula los argumentos posicionales). La carga de nivel superior debe ser un objeto." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object." +msgstr "Ruta a un archivo JSON que contiene asignaciones de parámetros (anula los argumentos posicionales). La carga de nivel superior debe ser un objeto." + +msgid "Planned steps:" +msgstr "Pasos planificados:" + +msgid "Prefer chaining invoke targets so this parameter is produced by a previous step, instead of constructing it manually in JSON." +msgstr "Prefiera encadenar destinos de invoke para que este parámetro lo produzca un paso anterior, en lugar de construirlo manualmente en JSON." + +msgid "Print the installed moldflow package version." +msgstr "Imprime la versión instalada del paquete moldflow." + +msgid "Property assignment JSON must be an object with a single 'value' field." +msgstr "El JSON de asignación de propiedades debe ser un objeto con un único campo 'value'." + +msgid "Property assignment requires exactly one 'value' argument (e.g., value=... or --params-json '{\"value\": ...}')." +msgstr "La asignación de propiedades requiere exactamente un argumento 'value' (por ejemplo, value=... o --params-json '{\"value\": ...}')." + +msgid "Property {name} (id={id}, type={prop_type})" +msgstr "Propiedad {name} (id={id}, tipo={prop_type})" + +msgid "Read current value:" +msgstr "Leer valor actual:" + +msgid "Resolved assignment:" +msgstr "Asignación resuelta:" + +msgid "Resolved kwargs:" +msgstr "Kwargs resueltos:" + +msgid "Resolved object has no callable attribute '{segment}' when executing target '{target}'" +msgstr "El objeto resuelto no tiene el atributo invocable '{segment}' al ejecutar el destino '{target}'" + +msgid "Run a Moldflow target with named parameters or JSON input. Bare targets are treated as synergy.." +msgstr "Ejecute un objetivo de Moldflow con parámetros con nombre o entrada JSON. Los objetivos sin prefijo se tratan como synergy.." + msgid "Save Error" msgstr "Error al guardar" @@ -90,15 +597,171 @@ msgstr "Error al guardar: No se pudo guardar {saving} en {file_name}" msgid "Save Error: Failed to save {saving} to {file_name}" msgstr "Error al guardar: No se pudo guardar {saving} en {file_name}" +msgid "Segment '{segment}' does not resolve as an attribute on class '{class_name}' when resolving target '{target}'" +msgstr "El segmento '{segment}' no se resuelve como un atributo en la clase '{class_name}' al resolver el destino '{target}'" + +msgid "Segment '{segment}' is not a callable method on class '{class_name}' when resolving target '{target}'" +msgstr "El segmento '{segment}' no es un método invocable en la clase '{class_name}' al resolver el destino '{target}'" + +msgid "Selection" +msgstr "Selección" + +msgid "Session:" +msgstr "Sesión:" + +msgid "Set it with:" +msgstr "Establézcalo con:" + msgid "Setting {name} to {value}" msgstr "Configurar {name} a {value}" +msgid "Shorter JSON example:" +msgstr "Ejemplo JSON más corto:" + +msgid "Shorter form:" +msgstr "Forma más corta:" + +msgid "Show detailed help for a command" +msgstr "Mostrar ayuda detallada para un comando" + +msgid "Show full tracebacks on errors instead of short messages." +msgstr "Mostrar trazas completas en caso de error en lugar de mensajes cortos." + +msgid "Show this help message" +msgstr "Mostrar este mensaje de ayuda" + +msgid "Showing the compact table for {count} filtered matches. Narrow the filter or use --json for canonical target strings." +msgstr "Mostrando la tabla compacta para {count} coincidencias filtradas. Reduzca el filtro o use --json para obtener las cadenas de destino canónicas." + +msgid "Shows available commands and usage information." +msgstr "Muestra los comandos disponibles e información de uso." + +msgid "Start an interactive moldflow shell session." +msgstr "Iniciar una sesión interactiva de shell de moldflow." + +msgid "Status" +msgstr "Estado" + +msgid "Step '{step_name}' in target '{target}' has positional-only parameters ({parameters}), which are not supported by CLI named-argument routing. Use the Python API for this target." +msgstr "El paso '{step_name}' del destino '{target}' tiene parámetros solo posicionales ({parameters}), que no son compatibles con el enrutamiento de argumentos con nombre de la CLI. Use la API de Python para este destino." + msgid "Submit" msgstr "Aceptar" +msgid "Synergy session reset." +msgstr "Sesión de Synergy restablecida." + +msgid "TARGET is required unless --batch-file is used." +msgstr "TARGET es obligatorio salvo que se use --batch-file." + +msgid "Tab completion targets are refreshed automatically." +msgstr "Los objetivos de autocompletado con tabulador se actualizan automáticamente." + +msgid "Target" +msgstr "Destino" + +msgid "Target '{target}' is hidden from the CLI because '{hidden_path}' only creates a transient {wrapper_type} wrapper. The CLI constructs these helper objects internally when needed, so they are not exposed as direct CLI targets." +msgstr "El destino '{target}' está oculto en la CLI porque '{hidden_path}' solo crea un wrapper {wrapper_type} transitorio. La CLI construye estos objetos auxiliares internamente cuando los necesita, por lo que no se exponen como destinos directos de la CLI." + +msgid "Target '{target}' is hidden from the CLI by library metadata on '{hidden_path}'." +msgstr "El destino '{target}' está oculto en la CLI por los metadatos de la biblioteca en '{hidden_path}'." + +msgid "Target '{target}' resolves to a property/attribute and does not accept arguments." +msgstr "El destino '{target}' se resuelve en una propiedad/atributo y no acepta argumentos." + +msgid "Target '{target}' resolves to a {class_name} wrapper property. Continue to one of its members, for example 'describe {target}.'." +msgstr "El destino '{target}' se resuelve en una propiedad wrapper {class_name}. Continúe con uno de sus miembros, por ejemplo 'describe {target}.'." + +msgid "Target '{target}' resolves to property '{property_name}' (getter) and does not accept arguments." +msgstr "El destino '{target}' se resuelve en la propiedad '{property_name}' (getter) y no acepta argumentos." + +msgid "Target '{target}' resolves to write-only property '{property_name}' and cannot be read via invoke." +msgstr "El destino '{target}' se resuelve en la propiedad de solo escritura '{property_name}' y no se puede leer mediante invoke." + +msgid "Target must include a class or function name" +msgstr "El destino debe incluir un nombre de clase o función" + +msgid "Target must include at least one segment" +msgstr "El destino debe incluir al menos un segmento" + +msgid "Target must start with 'synergy' (or 'moldflow.synergy'). All invocations are rooted on the Synergy COM object." +msgstr "El destino debe comenzar con 'synergy' (o 'moldflow.synergy'). Todas las invocaciones están enraizadas en el objeto COM de Synergy." + +msgid "Target must start with 'synergy' after the optional 'moldflow.' prefix. Bare targets such as 'open_project' are accepted and are interpreted as 'synergy.open_project'." +msgstr "El destino debe comenzar con 'synergy' después del prefijo opcional 'moldflow.'. Se aceptan destinos sin prefijo como 'open_project' y se interpretan como 'synergy.open_project'." + +msgid "Targets are shown without the leading 'synergy.' prefix. Describe and invoke accept either form." +msgstr "Los destinos se muestran sin el prefijo inicial 'synergy.'. Describe e invoke aceptan cualquiera de las dos formas." + msgid "Test String" msgstr "Cadena de prueba" +msgid "The Moldflow CLI requires optional dependencies. Install them with: pip install 'moldflow[cli]'" +msgstr "La CLI de Moldflow requiere dependencias opcionales. Instálelas con: pip install 'moldflow[cli]'" + +msgid "The target returned False, which indicates a business-level failure." +msgstr "El destino devolvió False, lo que indica un error a nivel de negocio." + +msgid "This dry run validates a property assignment." +msgstr "Esta simulación valida una asignación de propiedad." + +msgid "This parameter can be null to indicate no value." +msgstr "Este parámetro puede ser null para indicar que no hay valor." + +msgid "This property is read-only and takes no arguments." +msgstr "Esta propiedad es de solo lectura y no acepta argumentos." + +msgid "This property returns a {class_name} wrapper. Continue with describe {target}. or invoke {target}.." +msgstr "Esta propiedad devuelve un wrapper {class_name}. Continúe con describe {target}. o invoke {target}.." + +msgid "Tip: install pyreadline3 for tab completion support on Windows." +msgstr "Consejo: instale pyreadline3 para autocompletado con tabulador en Windows." + +msgid "Treat False return values as CLI failures (exit 1). This is enabled by default for automation-friendly behavior." +msgstr "Trata los valores de retorno False como fallos de la CLI (salida 1). Esto está habilitado de forma predeterminada para un comportamiento adecuado para la automatización." + +msgid "Try this:" +msgstr "Pruebe esto:" + +msgid "Type" +msgstr "Tipo" + +msgid "Type 'help' for available commands, 'exit' to quit." +msgstr "Escriba 'help' para ver los comandos disponibles, 'exit' para salir." + +msgid "Type help to see available commands." +msgstr "Escriba help para ver los comandos disponibles." + +msgid "Unknown batch item field(s): {fields}." +msgstr "Campo(s) desconocido(s) en el elemento del lote: {fields}." + +msgid "Unknown command:" +msgstr "Comando desconocido:" + +msgid "Unknown parameter '{parameter}' for {target}{signature}.{extra}" +msgstr "Parámetro desconocido '{parameter}' para {target}{signature}.{extra}" + +msgid "Use 'help ' for detailed help on a specific command." +msgstr "Use 'help ' para obtener ayuda detallada sobre un comando específico." + +msgid "Use JSON field '{preferred_field}' for '{param_name}'." +msgstr "Use el campo JSON '{preferred_field}' para '{param_name}'." + +msgid "Use JSON field '{preferred_field}'." +msgstr "Use el campo JSON '{preferred_field}'." + +msgid "Use a comma-separated list for quick CLI input, or a JSON array string when values contain commas." +msgstr "Use una lista separada por comas para una entrada rápida en la CLI, o una cadena de arreglo JSON cuando los valores contengan comas." + +msgid "Use a comma-separated triplet for vector shorthand." +msgstr "Use una tripleta separada por comas como forma abreviada del vector." + +msgid "Use describe to inspect parameters, examples, and property behavior before invoking." +msgstr "Use describe para inspeccionar parámetros, ejemplos y el comportamiento de la propiedad antes de invocar." + +msgid "Use semicolon-separated triplets for quick CLI input. Quote the value in shells that treat semicolons specially." +msgstr "Use tripletas separadas por punto y coma para una entrada rápida en la CLI. Ponga el valor entre comillas en shells que traten el punto y coma de forma especial." + msgid "Using prompts will use pop-up import options and will always show logs" msgstr "Al usar avisos, se usarán las opciones de importación emergentes y siempre se mostrarán los registros" @@ -114,41 +777,71 @@ msgstr "Entrada válida" msgid "Valid Input Type" msgstr "Tipo de entrada válido" +msgid "Vector" +msgstr "Vector" + +msgid "When JSON input is provided via --params-json or --params-json-file, no positional key=value args may be given." +msgstr "Cuando la entrada JSON se proporciona mediante --params-json o --params-json-file, no se pueden proporcionar argumentos posicionales key=value." + +msgid "Write JSON output to the given file path without changing stdout mode." +msgstr "Escribe la salida JSON en la ruta de archivo indicada sin cambiar el modo de stdout." + +msgid "Wrote structured output to {path}." +msgstr "Se escribió la salida estructurada en {path}." + +msgid "You are already in the REPL." +msgstr "Ya se encuentra en el REPL." + msgid "both {first} and {second} must be provided together" msgstr "Se deben proporcionar juntos {first} y {second}" msgid "cannot be empty" msgstr "no puede estar vacío" +msgid "failed" +msgstr "fallido" + msgid "found {min_value} must be less than {max_value}" msgstr "encontrado {min_value}, debe ser menor que {max_value}" msgid "found {value}, must be between {min_value} and {max_value}" -msgstr "encontrado {valor}, debe estar entre {min_value} y {max_value}" +msgstr "encontrado {value}, debe estar entre {min_value} y {max_value}" msgid "found {value}, must be greater than or equal to {min_value}" -msgstr "encontrado {valor}, debe ser mayor o igual a {min_value}" +msgstr "encontrado {value}, debe ser mayor o igual a {min_value}" msgid "found {value}, must be greater than {min_value}" -msgstr "encontrado {valor}, debe ser mayor que {min_value}" +msgstr "encontrado {value}, debe ser mayor que {min_value}" msgid "found {value}, must be less than or equal to {max_value}" -msgstr "encontrado {valor}, debe ser menor o igual a {max_value}" +msgstr "encontrado {value}, debe ser menor o igual a {max_value}" msgid "found {value}, must be less than {max_value}" -msgstr "encontrado {valor}, debe ser menor que {max_value}" +msgstr "encontrado {value}, debe ser menor que {max_value}" msgid "found {value}, must be non-negative" -msgstr "encontrado {valor}, debe ser no negativo" +msgstr "encontrado {value}, debe ser no negativo" msgid "found {value}, must be non-zero" -msgstr "encontrado {valor}, debe ser distinto de cero" +msgstr "encontrado {value}, debe ser distinto de cero" msgid "found {value}, must be one of {expected_values}" msgstr "encontrado {value}, debe ser uno de {expected_values}" msgid "found {value}, must be positive" -msgstr "encontrado {valor}, debe ser positivo" +msgstr "encontrado {value}, debe ser positivo" + +msgid "interactive shell" +msgstr "shell interactivo" + +msgid "ok" +msgstr "correcto" + +msgid "settable" +msgstr "asignable" + +msgid "the owning object" +msgstr "el objeto propietario" msgid "{file_name} does not have a valid file extension, will use {default}" msgstr "{file_name} no tiene una extensión válida; se usará {default}" @@ -159,6 +852,21 @@ msgstr "{name} es {value}" msgid "{name} parameter will be ignored" msgstr "El parámetro {name} se ignorará" +msgid "{param} receives the Plot returned by find_plot_by_name" +msgstr "{param} recibe el Plot devuelto por find_plot_by_name" + +msgid "{type_name} ({size} items): {value}" +msgstr "{type_name} ({size} elementos): {value}" + +msgid "{type_name} attributes:" +msgstr "Atributos de {type_name}:" + +msgid "{type_name} result:" +msgstr "Resultado de {type_name}:" + +msgid "{type_name} values ({count} items):" +msgstr "Valores de {type_name} ({count} elementos):" + msgid "{value} cannot be found documented in {enum_name}, this may cause function call to fail" msgstr "El valor {value} no se encuentra documentado en {enum_name}; esto puede provocar un error en la llamada a la función" diff --git a/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po b/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po index f80be81..f41e637 100644 --- a/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po +++ b/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po @@ -3,9 +3,159 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: fr-FR\n" +msgid "\nDid you mean step '{step_name}'?" +msgstr "\nVouliez-vous dire l'étape '{step_name}' ?" + +msgid "\nFor JSON input on multi-step targets, group parameters by step name, e.g. {example}" +msgstr "\nPour une entrée JSON sur des cibles à plusieurs étapes, regroupez les paramètres par nom d'étape, par ex. {example}" + +msgid "\nFor JSON input, this step key must map to an object of parameter names, e.g. {example}" +msgstr "\nPour une entrée JSON, cette clé d'étape doit correspondre à un objet de noms de paramètres, par ex. {example}" + +msgid " Did you mean '{parameter}'?" +msgstr " Vouliez-vous dire '{parameter}' ?" + +msgid " Known parameters: {known_params}." +msgstr " Paramètres connus : {known_params}." + +msgid "'{type_name}' no longer exposes adapter method '{method_name}'." +msgstr "'{type_name}' n'expose plus la méthode d'adaptateur '{method_name}'." + +msgid "--yaml requested but PyYAML is not installed: {error}" +msgstr "L'option --yaml a été demandée mais PyYAML n'est pas installé : {error}" + +msgid "--yaml requested but PyYAML is not installed: {exc}" +msgstr "L'option --yaml a été demandée mais PyYAML n'est pas installé : {exc}" + +msgid "Aborted." +msgstr "Abandonné." + +msgid "Advanced fallback only. Use this tagged shape when annotation context is unavailable, when a nested payload is truly generic, or when multiple wrapper families would be ambiguous." +msgstr "Repli avancé uniquement. Utilisez cette forme balisée lorsque le contexte d'annotation n'est pas disponible, lorsqu'une charge utile imbriquée est réellement générique ou lorsque plusieurs familles de wrappers seraient ambiguës." + +msgid "Argument '{argument}' must specify a parameter name after the step (e.g., {step_name}.param=...).{extra}" +msgstr "L'argument '{argument}' doit spécifier un nom de paramètre après l'étape (par ex. {step_name}.param=...).{extra}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}" +msgstr "L'argument '{argument}' doit commencer par l'un des éléments suivants : {valid_steps}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "L'argument '{argument}' doit commencer par l'un des éléments suivants : {valid_steps}.{extra}" + +msgid "Argument '{step_name}' must start with one of: {names}" +msgstr "L'argument '{step_name}' doit commencer par l'un des éléments suivants : {names}" + +msgid "Argument error calling {target}{signature}: {error}" +msgstr "Erreur d'argument lors de l'appel de {target}{signature} : {error}" + +msgid "Arguments as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value. Duplicate or conflicting paths are rejected (for example param=1 with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "Arguments sous la forme key=value ou param.attr=value. Pour les cibles chaînées, préfixez le paramètre avec le nom de la méthode, par exemple find_plot_by_name.plot_name=\"My Plot\". Le routage imbriqué utilise step.param.attr=value. Les chemins dupliqués ou en conflit sont rejetés (par exemple param=1 avec param.attr=2), et les méthodes avec des paramètres uniquement positionnels ne sont pas prises en charge par le routage CLI avec arguments nommés." + +msgid "Arguments for step '{step_name}' must be a JSON object of parameters." +msgstr "Les arguments pour l'étape '{step_name}' doivent être un objet JSON contenant les paramètres." + +msgid "Array" +msgstr "Tableau" + +msgid "Attribute '{matched_name}' on class '{class_name}' returns a non-wrapper value{continuation}" +msgstr "L'attribut '{matched_name}' de la classe '{class_name}' renvoie une valeur qui n'est pas un wrapper{continuation}" + +msgid "Attribute '{matched_name}' on object '{type_name}' returns a non-wrapper value{continuation}" +msgstr "L'attribut '{matched_name}' de l'objet '{type_name}' renvoie une valeur qui n'est pas un wrapper{continuation}" + +msgid "Batch file must contain a JSON array of invoke call objects." +msgstr "Le fichier batch doit contenir un tableau JSON d'objets d'appel invoke." + +msgid "Batch item field 'args' must be a list of strings." +msgstr "Le champ 'args' d'un élément batch doit être une liste de chaînes." + +msgid "Batch item field 'params_json_file' must be a string path." +msgstr "Le champ 'params_json_file' d'un élément batch doit être un chemin de chaîne." + +msgid "Batch item must be a JSON object." +msgstr "Un élément batch doit être un objet JSON." + +msgid "Batch item requires string field 'target'." +msgstr "Un élément batch requiert le champ chaîne 'target'." + +msgid "Batch item {index} error: {error}" +msgstr "Erreur de l'élément batch {index} : {error}" + +msgid "Batch results" +msgstr "Résultats batch" + +msgid "Batch summary: {succeeded}/{total} succeeded, {failed} failed." +msgstr "Résumé batch : {succeeded}/{total} réussis, {failed} en échec." + +msgid "CLI argument: {value}" +msgstr "Argument CLI : {value}" + msgid "Cancel" msgstr "Annuler" +msgid "Cannot assign property '{property_name}' while resolving target '{target}' because the owner object resolved to None." +msgstr "Impossible d'affecter la propriété '{property_name}' lors de la résolution de la cible '{target}', car l'objet propriétaire a été résolu à None." + +msgid "Cannot build instance for type 'EntList'. No create_entity_list provider found." +msgstr "Impossible de construire une instance du type 'EntList'. Aucun fournisseur create_entity_list n'a été trouvé." + +msgid "Cannot build instance for type '{type_name}'. Not a known Synergy property or factory." +msgstr "Impossible de construire une instance du type '{type_name}'. Ce n'est ni une propriété Synergy connue ni une fabrique connue." + +msgid "Cannot configure field '{key_name}' on '{type_name}': {error}" +msgstr "Impossible de configurer le champ '{key_name}' sur '{type_name}' : {error}" + +msgid "Cannot invoke method '{segment}' for target '{target}' because '{owner}' is unavailable in the current session (it resolved to None). This target only works when that object exists." +msgstr "Impossible d'invoquer la méthode '{segment}' pour la cible '{target}' car '{owner}' n'est pas disponible dans la session en cours (résolu à None). Cette cible ne fonctionne que lorsque cet objet existe." + +msgid "Cannot read JSON file '{file}': {exc}" +msgstr "Impossible de lire le fichier JSON '{file}' : {exc}" + +msgid "Cannot read JSON file '{path}': {error}" +msgstr "Impossible de lire le fichier JSON '{path}' : {error}" + +msgid "Cannot read batch file '{path}': {error}" +msgstr "Impossible de lire le fichier batch '{path}' : {error}" + +msgid "Cannot resolve '{first}' on moldflow for introspection" +msgstr "Impossible de résoudre '{first}' sur moldflow pour l'introspection" + +msgid "Cannot resolve attribute '{segment}' on '{class_name}' when executing target '{target}': {error}" +msgstr "Impossible de résoudre l'attribut '{segment}' sur '{class_name}' lors de l'exécution de la cible '{target}' : {error}" + +msgid "Cannot resolve attribute '{segment}' without an object instance when resolving target '{target}'" +msgstr "Impossible de résoudre l'attribut '{segment}' sans instance d'objet lors de la résolution de la cible '{target}'" + +msgid "Cannot resolve segment '{segment}' in target '{target}' without a class context. Use a Synergy-rooted target such as 'synergy.some_method'." +msgstr "Impossible de résoudre le segment '{segment}' dans la cible '{target}' sans contexte de classe. Utilisez une cible enracinée sur Synergy telle que 'synergy.some_method'." + +msgid "Cannot set nested argument '{path}': {error}" +msgstr "Impossible de définir l'argument imbriqué '{path}' : {error}" + +msgid "Cannot set nested attributes for '{param_name}' without signature info on '{step_name}'." +msgstr "Impossible de définir des attributs imbriqués pour '{param_name}' sans information de signature sur '{step_name}'." + +msgid "Cannot set property '{property_name}' on target '{target}': {error}" +msgstr "Impossible de définir la propriété '{property_name}' sur la cible '{target}' : {error}" + +msgid "Cannot write JSON file '{path}': {error}" +msgstr "Impossible d'écrire le fichier JSON '{path}' : {error}" + +msgid "Canonical JSON field is derived from the reflected wrapper method signature for {method_name}()." +msgstr "Le champ JSON canonique est dérivé de la signature de méthode du wrapper réfléchie pour {method_name}()." + +msgid "Canonical list field is derived from the reflected wrapper method {method_name}()." +msgstr "Le champ de liste canonique est dérivé de la méthode de wrapper réfléchie {method_name}()." + +msgid "Canonical triplet field is derived from the reflected wrapper method {method_name}()." +msgstr "Le champ de triplet canonique est dérivé de la méthode de wrapper réfléchie {method_name}()." + +msgid "Canonical vector-array field is derived from the reflected wrapper method {method_name}()." +msgstr "Le champ de tableau de vecteurs canonique est dérivé de la méthode de wrapper réfléchie {method_name}()." + +msgid "Chained targets with repeated method names are ambiguous for argument routing: {duplicate_names}. Please use an equivalent target path where each invoked step name is unique." +msgstr "Les cibles chaînées avec des noms de méthode répétés sont ambiguës pour le routage des arguments : {duplicate_names}. Veuillez utiliser un chemin de cible équivalent où chaque nom d'étape invoquée est unique." + msgid "Checking file extension {file_name}" msgstr "Vérification de l'extension du fichier {file_name}" @@ -42,24 +192,207 @@ msgstr "Vérification que {value} est positif" msgid "Checking {value} is {name}" msgstr "Vérification que {value} est {name}" +msgid "Class '{class_name}' has no attribute '{attr_name}'" +msgstr "La classe '{class_name}' n'a pas d'attribut '{attr_name}'" + +msgid "Clear the screen" +msgstr "Effacer l'écran" + +msgid "Clears the terminal screen and redraws the banner." +msgstr "Efface l'écran du terminal et redessine la bannière." + +msgid "Close Synergy and reset the session" +msgstr "Fermer Synergy et réinitialiser la session" + +msgid "Closes Synergy and resets the session for a fresh start." +msgstr "Ferme Synergy et réinitialise la session pour repartir à zéro." + +msgid "Command exited with status {code}" +msgstr "La commande s'est terminée avec le statut {code}" + +msgid "Commands:" +msgstr "Commandes :" + +msgid "Conflicting argument paths '{left_path}' and '{right_path}' are not allowed." +msgstr "Les chemins d'argument en conflit '{left_path}' et '{right_path}' ne sont pas autorisés." + msgid "Could not initialize with Instance ID: {value}" msgstr "Impossible d'initialiser avec l'ID d'instance : {value}" +msgid "Ctrl+D also exits." +msgstr "Ctrl+D permet également de quitter." + +msgid "Detail" +msgstr "Détail" + +msgid "Direct parameter assignment remains the preferred non-JSON form." +msgstr "L'affectation directe de paramètres reste la forme non JSON privilégiée." + +msgid "Disable ANSI color/styling in CLI output." +msgstr "Désactiver la couleur/le style ANSI dans la sortie CLI." + +msgid "Discover invokable targets and the next command to run for each one." +msgstr "Découvrez les cibles invocables et la prochaine commande à exécuter pour chacune." + +msgid "Do not pass TARGET when --batch-file is used." +msgstr "Ne transmettez pas TARGET lorsque --batch-file est utilisé." + +msgid "Do not pass positional args/JSON input with --batch-file." +msgstr "Ne transmettez pas d'arguments positionnels ni d'entrée JSON avec --batch-file." + +msgid "Dotted path to a method or function, optionally chained, for example 'synergy.new_project' or 'synergy.plot_manager.find_plot_by_name'." +msgstr "Chemin pointé vers une méthode ou une fonction, éventuellement chaîné, par exemple 'synergy.new_project' ou 'synergy.plot_manager.find_plot_by_name'." + +msgid "Dotted path, e.g., synergy.new_project" +msgstr "Chemin pointé, par ex. synergy.new_project" + +msgid "Dry run for {target}" +msgstr "Exécution à blanc pour {target}" + +msgid "Duplicate argument path '{path}' is not allowed." +msgstr "Le chemin d'argument en double '{path}' n'est pas autorisé." + +msgid "Duplicate/conflicting paths are rejected. Arguments are passed as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value (for example param=1 conflicts with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "Les chemins dupliqués ou conflictuels sont rejetés. Les arguments sont passés sous forme key=value ou param.attr=value. Pour les cibles chaînées, préfixez le paramètre avec le nom de la méthode, par exemple find_plot_by_name.plot_name=\"My Plot\". Le routage imbriqué utilise step.param.attr=value (par exemple param=1 entre en conflit avec param.attr=2), et les méthodes avec paramètres positionnels uniquement ne sont pas prises en charge par le routage CLI nommé." + +msgid "Emit a JSON schema-like representation of target parameters." +msgstr "Produire une représentation de type schéma JSON des paramètres de la cible." + +msgid "Emit line-delimited JSON trace events to stderr for target resolution and runtime invoke binding." +msgstr "Produire des événements de trace JSON délimités par des lignes sur stderr pour la résolution de la cible et la liaison invoke à l'exécution." + +msgid "Emit structured JSON for scripting or agent use." +msgstr "Produit du JSON structuré pour les scripts ou l'utilisation par des agents." + +msgid "Emit structured YAML for scripting or agent use (requires PyYAML)." +msgstr "Produit du YAML structuré pour les scripts ou l'utilisation par des agents (PyYAML requis)." + +msgid "Emit the structured result as JSON to stdout (useful for automation/LLMs)." +msgstr "Produire le résultat structuré en JSON sur stdout (utile pour l'automatisation/les LLM)." + +msgid "Empty target" +msgstr "Cible vide" + +msgid "Empty type tag is not valid for '{primary_expected}'." +msgstr "Une balise de type vide n'est pas valide pour '{primary_expected}'." + +msgid "Error:" +msgstr "Erreur :" + +msgid "Example object shape: {shape}." +msgstr "Exemple de forme d'objet : {shape}." + msgid "Executing {name}" msgstr "Exécution de {name}" +msgid "Exit the REPL" +msgstr "Quitter le REPL" + +msgid "Exits the REPL." +msgstr "Quitte le REPL." + +msgid "Explicit field form: {value}" +msgstr "Forme de champ explicite : {value}" + msgid "Failed to initialize Synergy: Synergy not found" msgstr "Échec de l'initialisation de Synergy : Synergy introuvable" +msgid "Failed to render JSON output for {context}: {error}" +msgstr "Impossible de générer la sortie JSON pour {context} : {error}" + +msgid "Failed to render JSON output for {context}: {exc}" +msgstr "Impossible de générer la sortie JSON pour {context} : {exc}" + +msgid "Failed to render YAML output for {context}: {error}" +msgstr "Impossible de générer la sortie YAML pour {context} : {error}" + +msgid "Failed to render YAML output for {context}: {exc}" +msgstr "Impossible de générer la sortie YAML pour {context} : {exc}" + +msgid "Failed to reset session:" +msgstr "Échec de la réinitialisation de la session :" + +msgid "Field '{key_name}' is not valid for '{type_name}'." +msgstr "Le champ '{key_name}' n'est pas valide pour '{type_name}'." + +msgid "Field '{key}' for '{type_name}' must be a 3-item numeric sequence like [0, 0, 1]." +msgstr "Le champ '{key}' pour '{type_name}' doit être une séquence numérique de 3 éléments comme [0, 0, 1]." + +msgid "Field '{key}' for '{type_name}' must be a JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "Le champ '{key}' pour '{type_name}' doit être un tableau JSON de triplets ou une liste séparée par des points-virgules comme '0,0,0;1,0,0'." + +msgid "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list." +msgstr "Le champ '{key}' pour '{type_name}' doit être un tableau JSON ou une liste séparée par des virgules." + +msgid "Field '{key}' for '{type_name}' must be a comma-separated numeric triplet like '0,0,1'." +msgstr "Le champ '{key}' pour '{type_name}' doit être un triplet numérique séparé par des virgules comme '0,0,1'." + +msgid "Field '{key}' for '{type_name}' must be a list of numeric triplets." +msgstr "Le champ '{key}' pour '{type_name}' doit être une liste de triplets numériques." + +msgid "Field '{key}' for '{type_name}' must be a string selection expression. Expected field: {preferred_field}." +msgstr "Le champ '{key}' pour '{type_name}' doit être une expression de sélection sous forme de chaîne. Champ attendu : {preferred_field}." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "Le champ '{key}' pour '{type_name}' doit être un tableau JSON valide de triplets ou une liste séparée par des points-virgules comme '0,0,0;1,0,0'." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array or a comma-separated list." +msgstr "Le champ '{key}' pour '{type_name}' doit être un tableau JSON valide ou une liste séparée par des virgules." + +msgid "Field '{key}' for '{type_name}' must contain integer values." +msgstr "Le champ '{key}' pour '{type_name}' doit contenir des valeurs entières." + +msgid "Field '{key}' for '{type_name}' must contain numeric values." +msgstr "Le champ '{key}' pour '{type_name}' doit contenir des valeurs numériques." + +msgid "Fields '{previous_key}' and '{key}' both map to the same input for '{type_name}'. Provide only one of: {preferred_field} or direct parameter shorthand." +msgstr "Les champs '{previous_key}' et '{key}' correspondent tous deux à la même entrée pour '{type_name}'. Fournissez un seul des deux : {preferred_field} ou la forme abrégée de paramètre direct." + +msgid "Filter by substring or wildcard pattern (* and ?)." +msgstr "Filtrer par sous-chaîne ou motif générique (* et ?)." + +msgid "Filter by substring or wildcard pattern (* and ?). Repeat to keep targets matching any filter." +msgstr "Filtrez par sous-chaîne ou motif générique (* et ?). Répétez l'option pour conserver les cibles correspondant à l'un des filtres." + +msgid "Filtered matches:" +msgstr "Correspondances filtrées :" + +msgid "For JSON input, group parameters by step name. Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "Pour une entrée JSON, regroupez les paramètres par nom d'étape. L'argument '{argument}' doit commencer par l'un des éléments suivants : {valid_steps}.{extra}" + +msgid "For multi-step targets, group params-json fields by step name." +msgstr "Pour les cibles à plusieurs étapes, regroupez les champs params-json par nom d'étape." + msgid "Getting {name}" msgstr "Récupération de {name}" msgid "Getting {name} at index {value}" msgstr "Récupération de {name} à l'index {value}" +msgid "Goodbye!" +msgstr "Au revoir !" + +msgid "If shorthand input is ambiguous, switch to --params-json. {guidance}" +msgstr "Si la saisie abrégée est ambiguë, passez à --params-json. {guidance}" + +msgid "In non-JSON mode, prefer direct shorthand like '{preferred_non_json}'." +msgstr "En mode non JSON, préférez une forme abrégée directe comme '{preferred_non_json}'." + +msgid "Index" +msgstr "Indice" + msgid "Initializing {name}" msgstr "Initialisation de {name}" +msgid "Input hints:" +msgstr "Indications de saisie :" + +msgid "Inspect a target's signature, docs, examples, and structured invoke template." +msgstr "Inspectez la signature, la documentation, les exemples et le modèle structuré d'invoke d'une cible." + +msgid "Interrupted." +msgstr "Interrompu." + msgid "Invalid Attribute: {attribute} is not supported" msgstr "Attribut non valide : {attribute} n'est pas pris en charge" @@ -69,17 +402,191 @@ msgstr "Type de fichier non valide : {file_name}, doit être {extensions}" msgid "Invalid Index: out of range" msgstr "Indice non valide : hors plage" +msgid "Invalid JSON payload for parameters: {error}" +msgstr "Charge utile JSON invalide pour les paramètres : {error}" + +msgid "Invalid JSON payload for parameters: {exc}" +msgstr "Charge utile JSON invalide pour les paramètres : {exc}" + +msgid "Invalid JSON value for parameter '{param_name}': {error}" +msgstr "Valeur JSON invalide pour le paramètre '{param_name}' : {error}" + msgid "Invalid Type: must be {expected_types}, not {variable_type}" msgstr "Type non valide: doit être {expected_types}, pas {variable_type}" msgid "Invalid Value: {reason}" msgstr "Valeur non valide: {reason}" +msgid "Invalid argument '{item}'. Expected key=value or param.attr=value." +msgstr "Argument invalide '{item}'. Attendu : key=value ou param.attr=value." + +msgid "Invalid argument for step '{step_name}': missing parameter name." +msgstr "Argument invalide pour l'étape '{step_name}' : nom de paramètre manquant." + +msgid "Invalid nested argument path '{path}': attribute '{attr}' does not exist on '{obj_type}'." +msgstr "Chemin d'argument imbriqué '{path}' invalide : l'attribut '{attr}' n'existe pas sur '{obj_type}'." + +msgid "Invalid nested argument path '{path}': cannot nest into non-object '{obj_type}'." +msgstr "Chemin d'argument imbriqué '{path}' invalide : impossible d'imbriquer dans le non-objet '{obj_type}'." + +msgid "Invalid nested argument path '{path}': cannot set '{final_attr}' on non-object '{obj_type}'." +msgstr "Chemin d'argument imbriqué '{path}' invalide : impossible de définir '{final_attr}' sur le non-objet '{obj_type}'." + +msgid "Invalid value for parameter '{param_name}': {error}" +msgstr "Valeur invalide pour le paramètre '{param_name}' : {error}" + +msgid "Invalid {field_name} '{path_text}': empty path segment is not allowed." +msgstr "Champ {field_name} invalide '{path_text}' : segment de chemin vide non autorisé." + +msgid "Invalid {field_name} '{path_text}': segment '{segment}' must be a valid identifier." +msgstr "Champ {field_name} invalide '{path_text}' : le segment '{segment}' doit être un identifiant valide." + +msgid "Invalid {field_name}: value cannot be empty." +msgstr "Champ {field_name} invalide : la valeur ne peut pas être vide." + +msgid "JSON example:" +msgstr "Exemple JSON :" + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays and scalars are not allowed." +msgstr "Objet JSON contenant des correspondances de paramètres (remplace les arguments positionnels). Les tableaux et scalaires au niveau supérieur ne sont pas autorisés." + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays/scalars are not allowed." +msgstr "Objet JSON contenant des correspondances de paramètres (remplace les arguments positionnels). Les tableaux et scalaires au niveau supérieur ne sont pas autorisés." + +msgid "JSON parameters must be a JSON object of named arguments. Example: --params-json '{\"param\": 1}' or --params-json '{\"step\": {\"param\": 1}}' for chained targets." +msgstr "Les paramètres JSON doivent être un objet JSON d'arguments nommés. Exemple : --params-json '{\"param\": 1}' ou --params-json '{\"step\": {\"param\": 1}}' pour des cibles chaînées." + +msgid "JSON type tag '{type_tag}' does not match expected wrapper '{primary_expected}'." +msgstr "La balise de type JSON '{type_tag}' ne correspond pas au wrapper attendu '{primary_expected}'." + +msgid "JSON value:" +msgstr "Valeur JSON :" + +msgid "List result:" +msgstr "Résultat de la liste :" + msgid "Logger was not setup" msgstr "Logger n'était pas configuré" +msgid "Missing required parameter '{parameter}' for {target}{signature}" +msgstr "Paramètre requis '{parameter}' manquant pour {target}{signature}" + +msgid "Moldflow command-line interface.\n\nStart with 'list' to discover targets, 'describe ' to inspect usage, then 'invoke ...' to run it." +msgstr "Interface en ligne de commande Moldflow.\n\nCommencez par 'list' pour découvrir les cibles, utilisez 'describe ' pour examiner l'utilisation, puis exécutez 'invoke ...'." + +msgid "Moldflow invokable targets" +msgstr "Cibles invocables Moldflow" + +msgid "Nested argument '{path}' is not supported for **kwargs on step '{step_name}'. Use a single key (e.g., {example}=...)." +msgstr "L'argument imbriqué '{path}' n'est pas pris en charge pour les **kwargs à l'étape '{step_name}'. Utilisez une seule clé (par ex. {example}=...)." + +msgid "No invokable targets matched these filters." +msgstr "Aucune cible invocable ne correspond à ces filtres." + +msgid "No invokable targets matched this filter." +msgstr "Aucune cible invocable ne correspond à ce filtre." + +msgid "Non-public argument path '{key}' is not allowed." +msgstr "Le chemin d'argument non public '{key}' n'est pas autorisé." + +msgid "Non-public argument path '{left}' is not allowed." +msgstr "Le chemin d'argument non public '{left}' n'est pas autorisé." + +msgid "Non-public argument path '{path}' is not allowed." +msgstr "Le chemin d'argument non public '{path}' n'est pas autorisé." + +msgid "Non-public argument path '{step_name}' is not allowed." +msgstr "Le chemin d'argument non public '{step_name}' n'est pas autorisé." + +msgid "Non-public argument path '{step}.{key}' is not allowed." +msgstr "Le chemin d'argument non public '{step}.{key}' n'est pas autorisé." + +msgid "Non-public field '{key_name}' is not allowed when constructing '{type_name}' from JSON." +msgstr "Le champ non public '{key_name}' n'est pas autorisé lors de la construction de '{type_name}' à partir de JSON." + +msgid "Non-public segment '{segment}' is not allowed in target '{target}'." +msgstr "Le segment non public '{segment}' n'est pas autorisé dans la cible '{target}'." + +msgid "Non-public segment '{seg}' is not allowed in target '{target}'." +msgstr "Le segment non public '{seg}' n'est pas autorisé dans la cible '{target}'." + msgid "OK" -msgstr "OK" +msgstr "Valider" + +msgid "Object" +msgstr "Objet" + +msgid "Object '{type_name}' has no attribute '{attr_name}'" +msgstr "L'objet '{type_name}' n'a pas d'attribut '{attr_name}'" + +msgid "One or more dotted targets, for example synergy.new_project." +msgstr "Une ou plusieurs cibles en notation pointée, par exemple synergy.new_project." + +msgid "Only one of --json or --yaml may be specified." +msgstr "Un seul de --json ou --yaml peut être spécifié." + +msgid "Only one of --json, --yaml, or --schema may be specified." +msgstr "Un seul de --json, --yaml ou --schema peut être spécifié." + +msgid "Only one of --params-json or --params-json-file may be specified." +msgstr "Un seul de --params-json ou --params-json-file peut être spécifié." + +msgid "Parameter '{param_name}' contains a null byte which is not allowed." +msgstr "Le paramètre '{param_name}' contient un octet nul, ce qui n'est pas autorisé." + +msgid "Parameter '{param_name}' contains control characters (newline/tab/carriage return); please provide a single-line value or quote/escape as needed." +msgstr "Le paramètre '{param_name}' contient des caractères de contrôle (nouvelle ligne/tabulation/retour chariot) ; veuillez fournir une valeur sur une seule ligne ou échapper/mettre entre guillemets si nécessaire." + +msgid "Parse error:" +msgstr "Erreur d'analyse :" + +msgid "Parse/validate/build kwargs and emit a call plan without executing invoke steps." +msgstr "Analyser/valider/construire les kwargs et produire un plan d'appel sans exécuter les étapes invoke." + +msgid "Parse/validate/build kwargs and emit a template summary call plan without executing invoke steps." +msgstr "Analyser/valider/construire les kwargs et produire un plan d'appel récapitulatif de modèle sans exécuter les étapes invoke." + +msgid "Path to a JSON file containing an array of invoke calls for batch execution." +msgstr "Chemin vers un fichier JSON contenant un tableau d'appels invoke pour une exécution batch." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object, not arrays/scalars." +msgstr "Chemin vers un fichier JSON contenant des correspondances de paramètres (remplace les arguments positionnels). La charge utile de niveau supérieur doit être un objet." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object." +msgstr "Chemin vers un fichier JSON contenant des correspondances de paramètres (remplace les arguments positionnels). La charge utile de niveau supérieur doit être un objet." + +msgid "Planned steps:" +msgstr "Étapes planifiées :" + +msgid "Prefer chaining invoke targets so this parameter is produced by a previous step, instead of constructing it manually in JSON." +msgstr "Préférez chaîner les cibles invoke afin que ce paramètre soit produit par une étape précédente, plutôt que de le construire manuellement en JSON." + +msgid "Print the installed moldflow package version." +msgstr "Afficher la version installée du paquet moldflow." + +msgid "Property assignment JSON must be an object with a single 'value' field." +msgstr "Le JSON d'affectation de propriété doit être un objet avec un seul champ 'value'." + +msgid "Property assignment requires exactly one 'value' argument (e.g., value=... or --params-json '{\"value\": ...}')." +msgstr "L'affectation de propriété requiert exactement un argument 'value' (par ex. value=... ou --params-json '{\"value\": ...}')." + +msgid "Property {name} (id={id}, type={prop_type})" +msgstr "Propriété {name} (id={id}, type={prop_type})" + +msgid "Read current value:" +msgstr "Valeur actuelle :" + +msgid "Resolved assignment:" +msgstr "Affectation résolue :" + +msgid "Resolved kwargs:" +msgstr "Kwargs résolus :" + +msgid "Resolved object has no callable attribute '{segment}' when executing target '{target}'" +msgstr "L'objet résolu n'a pas d'attribut appelable '{segment}' lors de l'exécution de la cible '{target}'" + +msgid "Run a Moldflow target with named parameters or JSON input. Bare targets are treated as synergy.." +msgstr "Exécutez une cible Moldflow avec des paramètres nommés ou une entrée JSON. Les cibles non préfixées sont traitées comme synergy.." msgid "Save Error" msgstr "Erreur d'enregistrement" @@ -90,15 +597,171 @@ msgstr "Erreur d'enregistrement : impossible d'enregistrer {saving} dans {file_n msgid "Save Error: Failed to save {saving} to {file_name}" msgstr "Erreur d'enregistrement : échec de l'enregistrement de {saving} dans {file_name}" +msgid "Segment '{segment}' does not resolve as an attribute on class '{class_name}' when resolving target '{target}'" +msgstr "Le segment '{segment}' ne se résout pas en tant qu'attribut de la classe '{class_name}' lors de la résolution de la cible '{target}'" + +msgid "Segment '{segment}' is not a callable method on class '{class_name}' when resolving target '{target}'" +msgstr "Le segment '{segment}' n'est pas une méthode appelable de la classe '{class_name}' lors de la résolution de la cible '{target}'" + +msgid "Selection" +msgstr "Sélection" + +msgid "Session:" +msgstr "Session :" + +msgid "Set it with:" +msgstr "Définissez-la avec :" + msgid "Setting {name} to {value}" msgstr "Définition de {name} sur {value}" +msgid "Shorter JSON example:" +msgstr "Exemple JSON plus court :" + +msgid "Shorter form:" +msgstr "Forme plus courte :" + +msgid "Show detailed help for a command" +msgstr "Afficher l'aide détaillée pour une commande" + +msgid "Show full tracebacks on errors instead of short messages." +msgstr "Afficher les traces complètes en cas d'erreur au lieu de messages courts." + +msgid "Show this help message" +msgstr "Afficher ce message d'aide" + +msgid "Showing the compact table for {count} filtered matches. Narrow the filter or use --json for canonical target strings." +msgstr "Affichage du tableau compact pour {count} correspondances filtrées. Affinez le filtre ou utilisez --json pour les chaînes de cible canoniques." + +msgid "Shows available commands and usage information." +msgstr "Affiche les commandes disponibles et les informations d'utilisation." + +msgid "Start an interactive moldflow shell session." +msgstr "Démarrer une session interactive du shell moldflow." + +msgid "Status" +msgstr "Statut" + +msgid "Step '{step_name}' in target '{target}' has positional-only parameters ({parameters}), which are not supported by CLI named-argument routing. Use the Python API for this target." +msgstr "L'étape '{step_name}' de la cible '{target}' possède des paramètres uniquement positionnels ({parameters}), qui ne sont pas pris en charge par le routage CLI avec arguments nommés. Utilisez l'API Python pour cette cible." + msgid "Submit" msgstr "Valider" +msgid "Synergy session reset." +msgstr "Session Synergy réinitialisée." + +msgid "TARGET is required unless --batch-file is used." +msgstr "TARGET est requis sauf si --batch-file est utilisé." + +msgid "Tab completion targets are refreshed automatically." +msgstr "Les cibles de complétion par tabulation sont actualisées automatiquement." + +msgid "Target" +msgstr "Cible" + +msgid "Target '{target}' is hidden from the CLI because '{hidden_path}' only creates a transient {wrapper_type} wrapper. The CLI constructs these helper objects internally when needed, so they are not exposed as direct CLI targets." +msgstr "La cible '{target}' est masquée de la CLI parce que '{hidden_path}' ne crée qu'un wrapper {wrapper_type} transitoire. La CLI construit ces objets d'assistance en interne lorsque nécessaire ; ils ne sont donc pas exposés comme cibles CLI directes." + +msgid "Target '{target}' is hidden from the CLI by library metadata on '{hidden_path}'." +msgstr "La cible '{target}' est masquée de la CLI par les métadonnées de bibliothèque sur '{hidden_path}'." + +msgid "Target '{target}' resolves to a property/attribute and does not accept arguments." +msgstr "La cible '{target}' se résout en propriété/attribut et n'accepte pas d'arguments." + +msgid "Target '{target}' resolves to a {class_name} wrapper property. Continue to one of its members, for example 'describe {target}.'." +msgstr "La cible '{target}' se résout en propriété wrapper {class_name}. Continuez vers l'un de ses membres, par exemple 'describe {target}.'." + +msgid "Target '{target}' resolves to property '{property_name}' (getter) and does not accept arguments." +msgstr "La cible '{target}' se résout en propriété '{property_name}' (getter) et n'accepte pas d'arguments." + +msgid "Target '{target}' resolves to write-only property '{property_name}' and cannot be read via invoke." +msgstr "La cible '{target}' se résout en propriété en écriture seule '{property_name}' et ne peut pas être lue via invoke." + +msgid "Target must include a class or function name" +msgstr "La cible doit inclure un nom de classe ou de fonction" + +msgid "Target must include at least one segment" +msgstr "La cible doit contenir au moins un segment" + +msgid "Target must start with 'synergy' (or 'moldflow.synergy'). All invocations are rooted on the Synergy COM object." +msgstr "La cible doit commencer par 'synergy' (ou 'moldflow.synergy'). Toutes les invocations sont ancrées sur l'objet COM Synergy." + +msgid "Target must start with 'synergy' after the optional 'moldflow.' prefix. Bare targets such as 'open_project' are accepted and are interpreted as 'synergy.open_project'." +msgstr "La cible doit commencer par 'synergy' après le préfixe optionnel 'moldflow.'. Les cibles nues telles que 'open_project' sont acceptées et interprétées comme 'synergy.open_project'." + +msgid "Targets are shown without the leading 'synergy.' prefix. Describe and invoke accept either form." +msgstr "Les cibles sont affichées sans le préfixe initial 'synergy.'. Describe et invoke acceptent les deux formes." + msgid "Test String" msgstr "Chaîne de test" +msgid "The Moldflow CLI requires optional dependencies. Install them with: pip install 'moldflow[cli]'" +msgstr "La CLI Moldflow requiert des dépendances optionnelles. Installez-les avec : pip install 'moldflow[cli]'" + +msgid "The target returned False, which indicates a business-level failure." +msgstr "La cible a renvoyé False, ce qui indique un échec au niveau métier." + +msgid "This dry run validates a property assignment." +msgstr "Cette exécution à blanc valide une affectation de propriété." + +msgid "This parameter can be null to indicate no value." +msgstr "Ce paramètre peut être null pour indiquer l'absence de valeur." + +msgid "This property is read-only and takes no arguments." +msgstr "Cette propriété est en lecture seule et ne prend aucun argument." + +msgid "This property returns a {class_name} wrapper. Continue with describe {target}. or invoke {target}.." +msgstr "Cette propriété renvoie un wrapper {class_name}. Continuez avec describe {target}. ou invoke {target}.." + +msgid "Tip: install pyreadline3 for tab completion support on Windows." +msgstr "Astuce : installez pyreadline3 pour la complétion par tabulation sous Windows." + +msgid "Treat False return values as CLI failures (exit 1). This is enabled by default for automation-friendly behavior." +msgstr "Traitez les valeurs de retour False comme des échecs CLI (code de sortie 1). Ce comportement est activé par défaut pour être compatible avec l'automatisation." + +msgid "Try this:" +msgstr "Essayez ceci :" + +msgid "Type" +msgstr "Catégorie" + +msgid "Type 'help' for available commands, 'exit' to quit." +msgstr "Tapez 'help' pour les commandes disponibles, 'exit' pour quitter." + +msgid "Type help to see available commands." +msgstr "Tapez help pour voir les commandes disponibles." + +msgid "Unknown batch item field(s): {fields}." +msgstr "Champ(s) d'élément batch inconnu(s) : {fields}." + +msgid "Unknown command:" +msgstr "Commande inconnue :" + +msgid "Unknown parameter '{parameter}' for {target}{signature}.{extra}" +msgstr "Paramètre inconnu '{parameter}' pour {target}{signature}.{extra}" + +msgid "Use 'help ' for detailed help on a specific command." +msgstr "Utilisez 'help ' pour obtenir l'aide détaillée d'une commande spécifique." + +msgid "Use JSON field '{preferred_field}' for '{param_name}'." +msgstr "Utilisez le champ JSON '{preferred_field}' pour '{param_name}'." + +msgid "Use JSON field '{preferred_field}'." +msgstr "Utilisez le champ JSON '{preferred_field}'." + +msgid "Use a comma-separated list for quick CLI input, or a JSON array string when values contain commas." +msgstr "Utilisez une liste séparée par des virgules pour une saisie CLI rapide, ou une chaîne de tableau JSON lorsque les valeurs contiennent des virgules." + +msgid "Use a comma-separated triplet for vector shorthand." +msgstr "Utilisez un triplet séparé par des virgules pour la forme abrégée du vecteur." + +msgid "Use describe to inspect parameters, examples, and property behavior before invoking." +msgstr "Utilisez describe pour inspecter les paramètres, les exemples et le comportement de la propriété avant l'invocation." + +msgid "Use semicolon-separated triplets for quick CLI input. Quote the value in shells that treat semicolons specially." +msgstr "Utilisez des triplets séparés par des points-virgules pour une saisie CLI rapide. Placez la valeur entre guillemets dans les shells qui traitent spécialement les points-virgules." + msgid "Using prompts will use pop-up import options and will always show logs" msgstr "L'utilisation d'invites utilise des options d'importation contextuelles et affiche toujours les journaux" @@ -114,12 +777,30 @@ msgstr "Entrée valide" msgid "Valid Input Type" msgstr "Type d'entrée valide" +msgid "Vector" +msgstr "Vecteur" + +msgid "When JSON input is provided via --params-json or --params-json-file, no positional key=value args may be given." +msgstr "Lorsque l'entrée JSON est fournie via --params-json ou --params-json-file, aucun argument positionnel key=value ne peut être donné." + +msgid "Write JSON output to the given file path without changing stdout mode." +msgstr "Écrire la sortie JSON dans le chemin de fichier donné sans modifier le mode stdout." + +msgid "Wrote structured output to {path}." +msgstr "Sortie structurée écrite dans {path}." + +msgid "You are already in the REPL." +msgstr "Vous êtes déjà dans le REPL." + msgid "both {first} and {second} must be provided together" msgstr "{first} et {second} doivent être fournis ensemble" msgid "cannot be empty" msgstr "ne peut pas être vide" +msgid "failed" +msgstr "échec" + msgid "found {min_value} must be less than {max_value}" msgstr "trouvé {min_value}, doit être inférieur à {max_value}" @@ -150,6 +831,18 @@ msgstr "trouvé {value}, doit être l'un des {expected_values}" msgid "found {value}, must be positive" msgstr "trouvé {value}, doit être positif" +msgid "interactive shell" +msgstr "shell interactif" + +msgid "ok" +msgstr "succès" + +msgid "settable" +msgstr "modifiable" + +msgid "the owning object" +msgstr "l'objet propriétaire" + msgid "{file_name} does not have a valid file extension, will use {default}" msgstr "{file_name} n'a pas d'extension valide ; {default} sera utilisé" @@ -159,6 +852,21 @@ msgstr "{name} est {value}" msgid "{name} parameter will be ignored" msgstr "Le paramètre {name} sera ignoré" +msgid "{param} receives the Plot returned by find_plot_by_name" +msgstr "{param} reçoit le Plot renvoyé par find_plot_by_name" + +msgid "{type_name} ({size} items): {value}" +msgstr "{type_name} ({size} éléments) : {value}" + +msgid "{type_name} attributes:" +msgstr "Attributs de {type_name} :" + +msgid "{type_name} result:" +msgstr "Résultat de {type_name} :" + +msgid "{type_name} values ({count} items):" +msgstr "Valeurs de {type_name} ({count} éléments) :" + msgid "{value} cannot be found documented in {enum_name}, this may cause function call to fail" msgstr "La valeur {value} n'est pas documentée dans {enum_name} ; cela peut entraîner l'échec de l'appel de fonction" @@ -166,4 +874,4 @@ msgid "{value} does not have a valid file extension, must be {extensions}" msgstr "{value} n'a pas d'extension valide ; doit être {extensions}" msgid "{value} is not a valid {enum_name}" -msgstr "{value} n'est pas un {enum_name} valide" +msgstr "{value} n'est pas une valeur valide de {enum_name}" diff --git a/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po b/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po index e8ad04e..9dea95f 100644 --- a/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po +++ b/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po @@ -3,9 +3,159 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: it-IT\n" +msgid "\nDid you mean step '{step_name}'?" +msgstr "\nHai forse inteso il passaggio '{step_name}'?" + +msgid "\nFor JSON input on multi-step targets, group parameters by step name, e.g. {example}" +msgstr "\nPer input JSON su target multi-step, raggruppa i parametri per nome del passaggio, ad es. {example}" + +msgid "\nFor JSON input, this step key must map to an object of parameter names, e.g. {example}" +msgstr "\nPer input JSON, questa chiave di passaggio deve corrispondere a un oggetto di nomi di parametri, ad es. {example}" + +msgid " Did you mean '{parameter}'?" +msgstr " Hai forse inteso '{parameter}'?" + +msgid " Known parameters: {known_params}." +msgstr " Parametri noti: {known_params}." + +msgid "'{type_name}' no longer exposes adapter method '{method_name}'." +msgstr "'{type_name}' non espone più il metodo adattatore '{method_name}'." + +msgid "--yaml requested but PyYAML is not installed: {error}" +msgstr "È stata richiesta l'opzione --yaml ma PyYAML non è installato: {error}" + +msgid "--yaml requested but PyYAML is not installed: {exc}" +msgstr "È stata richiesta l'opzione --yaml ma PyYAML non è installato: {exc}" + +msgid "Aborted." +msgstr "Interrotto." + +msgid "Advanced fallback only. Use this tagged shape when annotation context is unavailable, when a nested payload is truly generic, or when multiple wrapper families would be ambiguous." +msgstr "Solo fallback avanzato. Usa questa forma con tag quando il contesto di annotazione non è disponibile, quando un payload nidificato è realmente generico o quando più famiglie di wrapper sarebbero ambigue." + +msgid "Argument '{argument}' must specify a parameter name after the step (e.g., {step_name}.param=...).{extra}" +msgstr "L'argomento '{argument}' deve specificare un nome di parametro dopo il passaggio (ad es. {step_name}.param=...).{extra}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}" +msgstr "L'argomento '{argument}' deve iniziare con uno dei seguenti: {valid_steps}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "L'argomento '{argument}' deve iniziare con uno dei seguenti: {valid_steps}.{extra}" + +msgid "Argument '{step_name}' must start with one of: {names}" +msgstr "L'argomento '{step_name}' deve iniziare con uno dei seguenti: {names}" + +msgid "Argument error calling {target}{signature}: {error}" +msgstr "Errore di argomento durante la chiamata a {target}{signature}: {error}" + +msgid "Arguments as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value. Duplicate or conflicting paths are rejected (for example param=1 with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "Argomenti come key=value o param.attr=value. Per target concatenati, anteponi al parametro il nome del metodo, ad esempio find_plot_by_name.plot_name=\"My Plot\". Il routing nidificato usa step.param.attr=value. I percorsi duplicati o in conflitto vengono rifiutati (ad esempio param=1 con param.attr=2) e i metodi con parametri solo posizionali non sono supportati dal routing CLI con argomenti nominati." + +msgid "Arguments for step '{step_name}' must be a JSON object of parameters." +msgstr "Gli argomenti per il passaggio '{step_name}' devono essere un oggetto JSON di parametri." + +msgid "Array" +msgstr "Lista" + +msgid "Attribute '{matched_name}' on class '{class_name}' returns a non-wrapper value{continuation}" +msgstr "L'attributo '{matched_name}' sulla classe '{class_name}' restituisce un valore non wrapper{continuation}" + +msgid "Attribute '{matched_name}' on object '{type_name}' returns a non-wrapper value{continuation}" +msgstr "L'attributo '{matched_name}' sull'oggetto '{type_name}' restituisce un valore non wrapper{continuation}" + +msgid "Batch file must contain a JSON array of invoke call objects." +msgstr "Il file batch deve contenere un array JSON di oggetti di chiamata invoke." + +msgid "Batch item field 'args' must be a list of strings." +msgstr "Il campo 'args' dell'elemento batch deve essere un elenco di stringhe." + +msgid "Batch item field 'params_json_file' must be a string path." +msgstr "Il campo 'params_json_file' dell'elemento batch deve essere un percorso stringa." + +msgid "Batch item must be a JSON object." +msgstr "L'elemento batch deve essere un oggetto JSON." + +msgid "Batch item requires string field 'target'." +msgstr "L'elemento batch richiede il campo stringa 'target'." + +msgid "Batch item {index} error: {error}" +msgstr "Errore nell'elemento batch {index}: {error}" + +msgid "Batch results" +msgstr "Risultati batch" + +msgid "Batch summary: {succeeded}/{total} succeeded, {failed} failed." +msgstr "Riepilogo batch: {succeeded}/{total} riusciti, {failed} non riusciti." + +msgid "CLI argument: {value}" +msgstr "Argomento CLI: {value}" + msgid "Cancel" msgstr "Annulla" +msgid "Cannot assign property '{property_name}' while resolving target '{target}' because the owner object resolved to None." +msgstr "Impossibile assegnare la proprietà '{property_name}' durante la risoluzione del target '{target}' perché l'oggetto proprietario è stato risolto in None." + +msgid "Cannot build instance for type 'EntList'. No create_entity_list provider found." +msgstr "Impossibile creare un'istanza per il tipo 'EntList'. Nessun provider create_entity_list trovato." + +msgid "Cannot build instance for type '{type_name}'. Not a known Synergy property or factory." +msgstr "Impossibile creare un'istanza per il tipo '{type_name}'. Non è una proprietà o factory Synergy conosciuta." + +msgid "Cannot configure field '{key_name}' on '{type_name}': {error}" +msgstr "Impossibile configurare il campo '{key_name}' su '{type_name}': {error}" + +msgid "Cannot invoke method '{segment}' for target '{target}' because '{owner}' is unavailable in the current session (it resolved to None). This target only works when that object exists." +msgstr "Impossibile invocare il metodo '{segment}' per il target '{target}' perché '{owner}' non è disponibile nella sessione corrente (è stato risolto in None). Questo target funziona solo quando quell'oggetto esiste." + +msgid "Cannot read JSON file '{file}': {exc}" +msgstr "Impossibile leggere il file JSON '{file}': {exc}" + +msgid "Cannot read JSON file '{path}': {error}" +msgstr "Impossibile leggere il file JSON '{path}': {error}" + +msgid "Cannot read batch file '{path}': {error}" +msgstr "Impossibile leggere il file batch '{path}': {error}" + +msgid "Cannot resolve '{first}' on moldflow for introspection" +msgstr "Impossibile risolvere '{first}' su moldflow per l'introspezione" + +msgid "Cannot resolve attribute '{segment}' on '{class_name}' when executing target '{target}': {error}" +msgstr "Impossibile risolvere l'attributo '{segment}' su '{class_name}' durante l'esecuzione del target '{target}': {error}" + +msgid "Cannot resolve attribute '{segment}' without an object instance when resolving target '{target}'" +msgstr "Impossibile risolvere l'attributo '{segment}' senza un'istanza dell'oggetto durante la risoluzione del target '{target}'" + +msgid "Cannot resolve segment '{segment}' in target '{target}' without a class context. Use a Synergy-rooted target such as 'synergy.some_method'." +msgstr "Impossibile risolvere il segmento '{segment}' nel target '{target}' senza un contesto di classe. Usa un target radicato in Synergy come 'synergy.some_method'." + +msgid "Cannot set nested argument '{path}': {error}" +msgstr "Impossibile impostare l'argomento nidificato '{path}': {error}" + +msgid "Cannot set nested attributes for '{param_name}' without signature info on '{step_name}'." +msgstr "Impossibile impostare attributi nidificati per '{param_name}' senza informazioni di firma su '{step_name}'." + +msgid "Cannot set property '{property_name}' on target '{target}': {error}" +msgstr "Impossibile impostare la proprietà '{property_name}' sul target '{target}': {error}" + +msgid "Cannot write JSON file '{path}': {error}" +msgstr "Impossibile scrivere il file JSON '{path}': {error}" + +msgid "Canonical JSON field is derived from the reflected wrapper method signature for {method_name}()." +msgstr "Il campo JSON canonico deriva dalla firma del metodo wrapper riflesso per {method_name}()." + +msgid "Canonical list field is derived from the reflected wrapper method {method_name}()." +msgstr "Il campo elenco canonico deriva dal metodo wrapper riflesso {method_name}()." + +msgid "Canonical triplet field is derived from the reflected wrapper method {method_name}()." +msgstr "Il campo tripletta canonico deriva dal metodo wrapper riflesso {method_name}()." + +msgid "Canonical vector-array field is derived from the reflected wrapper method {method_name}()." +msgstr "Il campo canonico array-vettore deriva dal metodo wrapper riflesso {method_name}()." + +msgid "Chained targets with repeated method names are ambiguous for argument routing: {duplicate_names}. Please use an equivalent target path where each invoked step name is unique." +msgstr "I target concatenati con nomi di metodo ripetuti sono ambigui per il routing degli argomenti: {duplicate_names}. Usa un percorso target equivalente in cui ogni nome di passaggio invocato sia univoco." + msgid "Checking file extension {file_name}" msgstr "Verifica dell'estensione del file {file_name}" @@ -42,24 +192,207 @@ msgstr "Verifica che {value} sia positivo" msgid "Checking {value} is {name}" msgstr "Verifica che {value} sia {name}" +msgid "Class '{class_name}' has no attribute '{attr_name}'" +msgstr "La classe '{class_name}' non ha l'attributo '{attr_name}'" + +msgid "Clear the screen" +msgstr "Cancella lo schermo" + +msgid "Clears the terminal screen and redraws the banner." +msgstr "Cancella lo schermo del terminale e ridisegna il banner." + +msgid "Close Synergy and reset the session" +msgstr "Chiudi Synergy e reimposta la sessione" + +msgid "Closes Synergy and resets the session for a fresh start." +msgstr "Chiude Synergy e reimposta la sessione per ricominciare da zero." + +msgid "Command exited with status {code}" +msgstr "Il comando è terminato con stato {code}" + +msgid "Commands:" +msgstr "Comandi:" + +msgid "Conflicting argument paths '{left_path}' and '{right_path}' are not allowed." +msgstr "I percorsi di argomento in conflitto '{left_path}' e '{right_path}' non sono consentiti." + msgid "Could not initialize with Instance ID: {value}" msgstr "Impossibile inizializzare con ID istanza: {value}" +msgid "Ctrl+D also exits." +msgstr "Anche Ctrl+D esce." + +msgid "Detail" +msgstr "Dettaglio" + +msgid "Direct parameter assignment remains the preferred non-JSON form." +msgstr "L'assegnazione diretta dei parametri resta la forma non JSON preferita." + +msgid "Disable ANSI color/styling in CLI output." +msgstr "Disabilita colore/stile ANSI nell'output CLI." + +msgid "Discover invokable targets and the next command to run for each one." +msgstr "Individua i target invocabili e il comando successivo da eseguire per ciascuno." + +msgid "Do not pass TARGET when --batch-file is used." +msgstr "Non passare TARGET quando viene usato --batch-file." + +msgid "Do not pass positional args/JSON input with --batch-file." +msgstr "Non passare argomenti posizionali/input JSON con --batch-file." + +msgid "Dotted path to a method or function, optionally chained, for example 'synergy.new_project' or 'synergy.plot_manager.find_plot_by_name'." +msgstr "Percorso puntato verso un metodo o una funzione, facoltativamente concatenato, ad esempio 'synergy.new_project' o 'synergy.plot_manager.find_plot_by_name'." + +msgid "Dotted path, e.g., synergy.new_project" +msgstr "Percorso puntato, ad es. synergy.new_project" + +msgid "Dry run for {target}" +msgstr "Esecuzione simulata per {target}" + +msgid "Duplicate argument path '{path}' is not allowed." +msgstr "Il percorso argomento duplicato '{path}' non è consentito." + +msgid "Duplicate/conflicting paths are rejected. Arguments are passed as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value (for example param=1 conflicts with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "I percorsi duplicati o in conflitto vengono rifiutati. Gli argomenti vengono passati come key=value o param.attr=value. Per le destinazioni concatenate, prefissare il parametro con il nome del metodo, ad esempio find_plot_by_name.plot_name=\"My Plot\". L instradamento annidato usa step.param.attr=value (ad esempio param=1 è in conflitto con param.attr=2) e i metodi con soli parametri posizionali non sono supportati dal routing CLI denominato." + +msgid "Emit a JSON schema-like representation of target parameters." +msgstr "Emette una rappresentazione simile a uno schema JSON dei parametri del target." + +msgid "Emit line-delimited JSON trace events to stderr for target resolution and runtime invoke binding." +msgstr "Emette eventi di traccia JSON delimitati per riga su stderr per la risoluzione del target e il binding invoke a runtime." + +msgid "Emit structured JSON for scripting or agent use." +msgstr "Emette JSON strutturato per script o per l'uso da parte di agenti." + +msgid "Emit structured YAML for scripting or agent use (requires PyYAML)." +msgstr "Emette YAML strutturato per script o per l'uso da parte di agenti (richiede PyYAML)." + +msgid "Emit the structured result as JSON to stdout (useful for automation/LLMs)." +msgstr "Emette il risultato strutturato come JSON su stdout (utile per automazione/LLM)." + +msgid "Empty target" +msgstr "Destinazione vuota" + +msgid "Empty type tag is not valid for '{primary_expected}'." +msgstr "Un tag di tipo vuoto non è valido per '{primary_expected}'." + +msgid "Error:" +msgstr "Errore:" + +msgid "Example object shape: {shape}." +msgstr "Forma dell'oggetto di esempio: {shape}." + msgid "Executing {name}" msgstr "Esecuzione di {name}" +msgid "Exit the REPL" +msgstr "Esci dal REPL" + +msgid "Exits the REPL." +msgstr "Esce dal REPL." + +msgid "Explicit field form: {value}" +msgstr "Forma esplicita del campo: {value}" + msgid "Failed to initialize Synergy: Synergy not found" msgstr "Impossibile inizializzare Synergy: Synergy non trovata" +msgid "Failed to render JSON output for {context}: {error}" +msgstr "Impossibile generare l'output JSON per {context}: {error}" + +msgid "Failed to render JSON output for {context}: {exc}" +msgstr "Impossibile generare l'output JSON per {context}: {exc}" + +msgid "Failed to render YAML output for {context}: {error}" +msgstr "Impossibile generare l'output YAML per {context}: {error}" + +msgid "Failed to render YAML output for {context}: {exc}" +msgstr "Impossibile generare l'output YAML per {context}: {exc}" + +msgid "Failed to reset session:" +msgstr "Impossibile reimpostare la sessione:" + +msgid "Field '{key_name}' is not valid for '{type_name}'." +msgstr "Il campo '{key_name}' non è valido per '{type_name}'." + +msgid "Field '{key}' for '{type_name}' must be a 3-item numeric sequence like [0, 0, 1]." +msgstr "Il campo '{key}' per '{type_name}' deve essere una sequenza numerica di 3 elementi come [0, 0, 1]." + +msgid "Field '{key}' for '{type_name}' must be a JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "Il campo '{key}' per '{type_name}' deve essere un array JSON di triplette o un elenco separato da punto e virgola come '0,0,0;1,0,0'." + +msgid "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list." +msgstr "Il campo '{key}' per '{type_name}' deve essere un array JSON o un elenco separato da virgole." + +msgid "Field '{key}' for '{type_name}' must be a comma-separated numeric triplet like '0,0,1'." +msgstr "Il campo '{key}' per '{type_name}' deve essere una tripletta numerica separata da virgole come '0,0,1'." + +msgid "Field '{key}' for '{type_name}' must be a list of numeric triplets." +msgstr "Il campo '{key}' per '{type_name}' deve essere un elenco di triplette numeriche." + +msgid "Field '{key}' for '{type_name}' must be a string selection expression. Expected field: {preferred_field}." +msgstr "Il campo '{key}' per '{type_name}' deve essere un'espressione di selezione stringa. Campo previsto: {preferred_field}." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "Il campo '{key}' per '{type_name}' deve essere un array JSON valido di triplette o un elenco separato da punto e virgola come '0,0,0;1,0,0'." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array or a comma-separated list." +msgstr "Il campo '{key}' per '{type_name}' deve essere un array JSON valido o un elenco separato da virgole." + +msgid "Field '{key}' for '{type_name}' must contain integer values." +msgstr "Il campo '{key}' per '{type_name}' deve contenere valori interi." + +msgid "Field '{key}' for '{type_name}' must contain numeric values." +msgstr "Il campo '{key}' per '{type_name}' deve contenere valori numerici." + +msgid "Fields '{previous_key}' and '{key}' both map to the same input for '{type_name}'. Provide only one of: {preferred_field} or direct parameter shorthand." +msgstr "I campi '{previous_key}' e '{key}' mappano entrambi lo stesso input per '{type_name}'. Fornisci solo uno tra: {preferred_field} o la forma abbreviata del parametro diretto." + +msgid "Filter by substring or wildcard pattern (* and ?)." +msgstr "Filtra per sottostringa o pattern con caratteri jolly (* e ?)." + +msgid "Filter by substring or wildcard pattern (* and ?). Repeat to keep targets matching any filter." +msgstr "Filtra per sottostringa o modello jolly (* e ?). Ripeti l'opzione per mantenere i target che corrispondono a uno qualsiasi dei filtri." + +msgid "Filtered matches:" +msgstr "Corrispondenze filtrate:" + +msgid "For JSON input, group parameters by step name. Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "Per input JSON, raggruppa i parametri per nome del passaggio. L'argomento '{argument}' deve iniziare con uno dei seguenti: {valid_steps}.{extra}" + +msgid "For multi-step targets, group params-json fields by step name." +msgstr "Per target multi-step, raggruppa i campi params-json per nome del passaggio." + msgid "Getting {name}" msgstr "Recupero di {name}" msgid "Getting {name} at index {value}" msgstr "Recupero di {name} all'indice {value}" +msgid "Goodbye!" +msgstr "Arrivederci!" + +msgid "If shorthand input is ambiguous, switch to --params-json. {guidance}" +msgstr "Se l'input abbreviato è ambiguo, passa a --params-json. {guidance}" + +msgid "In non-JSON mode, prefer direct shorthand like '{preferred_non_json}'." +msgstr "In modalità non JSON, preferisci la forma abbreviata diretta come '{preferred_non_json}'." + +msgid "Index" +msgstr "Indice" + msgid "Initializing {name}" msgstr "Inizializzazione di {name}" +msgid "Input hints:" +msgstr "Suggerimenti di input:" + +msgid "Inspect a target's signature, docs, examples, and structured invoke template." +msgstr "Esamina la firma, la documentazione, gli esempi e il modello strutturato di invoke di un target." + +msgid "Interrupted." +msgstr "Interrotto." + msgid "Invalid Attribute: {attribute} is not supported" msgstr "Attributo non valido: {attribute} non è supportato" @@ -69,17 +402,191 @@ msgstr "Tipo di file non valido: {file_name}, deve essere {extensions}" msgid "Invalid Index: out of range" msgstr "Indice non valido: fuori intervallo" +msgid "Invalid JSON payload for parameters: {error}" +msgstr "Payload JSON non valido per i parametri: {error}" + +msgid "Invalid JSON payload for parameters: {exc}" +msgstr "Payload JSON non valido per i parametri: {exc}" + +msgid "Invalid JSON value for parameter '{param_name}': {error}" +msgstr "Valore JSON non valido per il parametro '{param_name}': {error}" + msgid "Invalid Type: must be {expected_types}, not {variable_type}" msgstr "Tipo non valido: deve essere {expected_types}, non {variable_type}" msgid "Invalid Value: {reason}" msgstr "Valore non valido: {reason}" +msgid "Invalid argument '{item}'. Expected key=value or param.attr=value." +msgstr "Argomento non valido '{item}'. Previsto key=value o param.attr=value." + +msgid "Invalid argument for step '{step_name}': missing parameter name." +msgstr "Argomento non valido per il passaggio '{step_name}': nome parametro mancante." + +msgid "Invalid nested argument path '{path}': attribute '{attr}' does not exist on '{obj_type}'." +msgstr "Percorso di argomento nidificato non valido '{path}': l'attributo '{attr}' non esiste su '{obj_type}'." + +msgid "Invalid nested argument path '{path}': cannot nest into non-object '{obj_type}'." +msgstr "Percorso di argomento nidificato non valido '{path}': impossibile annidare in un non-oggetto '{obj_type}'." + +msgid "Invalid nested argument path '{path}': cannot set '{final_attr}' on non-object '{obj_type}'." +msgstr "Percorso di argomento nidificato non valido '{path}': impossibile impostare '{final_attr}' su un non-oggetto '{obj_type}'." + +msgid "Invalid value for parameter '{param_name}': {error}" +msgstr "Valore non valido per il parametro '{param_name}': {error}" + +msgid "Invalid {field_name} '{path_text}': empty path segment is not allowed." +msgstr "Campo {field_name} '{path_text}' non valido: segmento di percorso vuoto non consentito." + +msgid "Invalid {field_name} '{path_text}': segment '{segment}' must be a valid identifier." +msgstr "Campo {field_name} '{path_text}' non valido: il segmento '{segment}' deve essere un identificatore valido." + +msgid "Invalid {field_name}: value cannot be empty." +msgstr "{field_name} non valido: il valore non può essere vuoto." + +msgid "JSON example:" +msgstr "Esempio JSON:" + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays and scalars are not allowed." +msgstr "Oggetto JSON contenente le mappature dei parametri (sostituisce gli argomenti posizionali). Array e scalari di primo livello non sono consentiti." + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays/scalars are not allowed." +msgstr "Oggetto JSON contenente le mappature dei parametri (sostituisce gli argomenti posizionali). Array e scalari di primo livello non sono consentiti." + +msgid "JSON parameters must be a JSON object of named arguments. Example: --params-json '{\"param\": 1}' or --params-json '{\"step\": {\"param\": 1}}' for chained targets." +msgstr "I parametri JSON devono essere un oggetto JSON di argomenti nominati. Esempio: --params-json '{\"param\": 1}' o --params-json '{\"step\": {\"param\": 1}}' per destinazioni concatenate." + +msgid "JSON type tag '{type_tag}' does not match expected wrapper '{primary_expected}'." +msgstr "Il tag di tipo JSON '{type_tag}' non corrisponde al wrapper previsto '{primary_expected}'." + +msgid "JSON value:" +msgstr "Valore JSON:" + +msgid "List result:" +msgstr "Risultato elenco:" + msgid "Logger was not setup" msgstr "Logger non è stato configurato" +msgid "Missing required parameter '{parameter}' for {target}{signature}" +msgstr "Parametro obbligatorio mancante '{parameter}' per {target}{signature}" + +msgid "Moldflow command-line interface.\n\nStart with 'list' to discover targets, 'describe ' to inspect usage, then 'invoke ...' to run it." +msgstr "Interfaccia a riga di comando di Moldflow.\n\nInizia con 'list' per individuare i target, usa 'describe ' per esaminarne l'utilizzo, quindi esegui 'invoke ...'." + +msgid "Moldflow invokable targets" +msgstr "Target Moldflow invocabili" + +msgid "Nested argument '{path}' is not supported for **kwargs on step '{step_name}'. Use a single key (e.g., {example}=...)." +msgstr "L'argomento nidificato '{path}' non è supportato per **kwargs nel passaggio '{step_name}'. Usa una singola chiave (ad es. {example}=...)." + +msgid "No invokable targets matched these filters." +msgstr "Nessun target invocabile corrisponde a questi filtri." + +msgid "No invokable targets matched this filter." +msgstr "Nessun target invocabile corrisponde a questo filtro." + +msgid "Non-public argument path '{key}' is not allowed." +msgstr "Il percorso dell'argomento non pubblico '{key}' non è consentito." + +msgid "Non-public argument path '{left}' is not allowed." +msgstr "Il percorso dell'argomento non pubblico '{left}' non è consentito." + +msgid "Non-public argument path '{path}' is not allowed." +msgstr "Il percorso dell'argomento non pubblico '{path}' non è consentito." + +msgid "Non-public argument path '{step_name}' is not allowed." +msgstr "Il percorso dell'argomento non pubblico '{step_name}' non è consentito." + +msgid "Non-public argument path '{step}.{key}' is not allowed." +msgstr "Il percorso dell'argomento non pubblico '{step}.{key}' non è consentito." + +msgid "Non-public field '{key_name}' is not allowed when constructing '{type_name}' from JSON." +msgstr "Il campo non pubblico '{key_name}' non è consentito durante la costruzione di '{type_name}' da JSON." + +msgid "Non-public segment '{segment}' is not allowed in target '{target}'." +msgstr "Il segmento non pubblico '{segment}' non è consentito nel target '{target}'." + +msgid "Non-public segment '{seg}' is not allowed in target '{target}'." +msgstr "Il segmento non pubblico '{seg}' non è consentito nel target '{target}'." + msgid "OK" -msgstr "OK" +msgstr "Conferma" + +msgid "Object" +msgstr "Oggetto" + +msgid "Object '{type_name}' has no attribute '{attr_name}'" +msgstr "L'oggetto '{type_name}' non ha l'attributo '{attr_name}'" + +msgid "One or more dotted targets, for example synergy.new_project." +msgstr "Uno o più target in notazione puntata, ad esempio synergy.new_project." + +msgid "Only one of --json or --yaml may be specified." +msgstr "È possibile specificare solo uno tra --json o --yaml." + +msgid "Only one of --json, --yaml, or --schema may be specified." +msgstr "È possibile specificare solo uno tra --json, --yaml o --schema." + +msgid "Only one of --params-json or --params-json-file may be specified." +msgstr "È possibile specificare solo uno tra --params-json o --params-json-file." + +msgid "Parameter '{param_name}' contains a null byte which is not allowed." +msgstr "Il parametro '{param_name}' contiene un byte nullo, non consentito." + +msgid "Parameter '{param_name}' contains control characters (newline/tab/carriage return); please provide a single-line value or quote/escape as needed." +msgstr "Il parametro '{param_name}' contiene caratteri di controllo (newline/tab/ritorno a capo); fornisci un valore su una sola riga o usa quote/escape secondo necessità." + +msgid "Parse error:" +msgstr "Errore di analisi:" + +msgid "Parse/validate/build kwargs and emit a call plan without executing invoke steps." +msgstr "Analizza/valida/costruisci kwargs ed emetti un piano di chiamata senza eseguire i passaggi invoke." + +msgid "Parse/validate/build kwargs and emit a template summary call plan without executing invoke steps." +msgstr "Analizza/valida/costruisci kwargs ed emetti un piano di chiamata riepilogativo del modello senza eseguire i passaggi invoke." + +msgid "Path to a JSON file containing an array of invoke calls for batch execution." +msgstr "Percorso di un file JSON contenente un array di chiamate invoke per l'esecuzione batch." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object, not arrays/scalars." +msgstr "Percorso di un file JSON contenente mappature di parametri (sostituisce gli argomenti posizionali). Il payload di primo livello deve essere un oggetto." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object." +msgstr "Percorso di un file JSON contenente mappature di parametri (sostituisce gli argomenti posizionali). Il payload di primo livello deve essere un oggetto." + +msgid "Planned steps:" +msgstr "Passaggi pianificati:" + +msgid "Prefer chaining invoke targets so this parameter is produced by a previous step, instead of constructing it manually in JSON." +msgstr "Preferisci concatenare i target invoke in modo che questo parametro sia prodotto da un passaggio precedente, invece di costruirlo manualmente in JSON." + +msgid "Print the installed moldflow package version." +msgstr "Stampa la versione installata del pacchetto moldflow." + +msgid "Property assignment JSON must be an object with a single 'value' field." +msgstr "Il JSON di assegnazione proprietà deve essere un oggetto con un solo campo 'value'." + +msgid "Property assignment requires exactly one 'value' argument (e.g., value=... or --params-json '{\"value\": ...}')." +msgstr "L'assegnazione della proprietà richiede esattamente un argomento 'value' (ad es. value=... o --params-json '{\"value\": ...}')." + +msgid "Property {name} (id={id}, type={prop_type})" +msgstr "Proprietà {name} (id={id}, tipo={prop_type})" + +msgid "Read current value:" +msgstr "Leggi il valore corrente:" + +msgid "Resolved assignment:" +msgstr "Assegnazione risolta:" + +msgid "Resolved kwargs:" +msgstr "Kwargs risolti:" + +msgid "Resolved object has no callable attribute '{segment}' when executing target '{target}'" +msgstr "L'oggetto risolto non ha alcun attributo chiamabile '{segment}' durante l'esecuzione del target '{target}'" + +msgid "Run a Moldflow target with named parameters or JSON input. Bare targets are treated as synergy.." +msgstr "Esegue un target Moldflow con parametri con nome o input JSON. I target senza prefisso sono trattati come synergy.." msgid "Save Error" msgstr "Errore di salvataggio" @@ -90,15 +597,171 @@ msgstr "Errore di salvataggio: impossibile salvare {saving} in {file_name}" msgid "Save Error: Failed to save {saving} to {file_name}" msgstr "Errore di salvataggio: salvataggio di {saving} in {file_name} non riuscito" +msgid "Segment '{segment}' does not resolve as an attribute on class '{class_name}' when resolving target '{target}'" +msgstr "Il segmento '{segment}' non si risolve come attributo sulla classe '{class_name}' durante la risoluzione del target '{target}'" + +msgid "Segment '{segment}' is not a callable method on class '{class_name}' when resolving target '{target}'" +msgstr "Il segmento '{segment}' non è un metodo chiamabile sulla classe '{class_name}' durante la risoluzione del target '{target}'" + +msgid "Selection" +msgstr "Selezione" + +msgid "Session:" +msgstr "Sessione:" + +msgid "Set it with:" +msgstr "Impostalo con:" + msgid "Setting {name} to {value}" msgstr "Impostazione di {name} su {value}" +msgid "Shorter JSON example:" +msgstr "Esempio JSON più breve:" + +msgid "Shorter form:" +msgstr "Forma più breve:" + +msgid "Show detailed help for a command" +msgstr "Mostra la guida dettagliata per un comando" + +msgid "Show full tracebacks on errors instead of short messages." +msgstr "Mostra i traceback completi in caso di errore invece di messaggi brevi." + +msgid "Show this help message" +msgstr "Mostra questo messaggio di aiuto" + +msgid "Showing the compact table for {count} filtered matches. Narrow the filter or use --json for canonical target strings." +msgstr "Visualizzazione della tabella compatta per {count} corrispondenze filtrate. Restringi il filtro o usa --json per le stringhe target canoniche." + +msgid "Shows available commands and usage information." +msgstr "Mostra i comandi disponibili e le informazioni sull'utilizzo." + +msgid "Start an interactive moldflow shell session." +msgstr "Avvia una sessione interattiva della shell moldflow." + +msgid "Status" +msgstr "Stato" + +msgid "Step '{step_name}' in target '{target}' has positional-only parameters ({parameters}), which are not supported by CLI named-argument routing. Use the Python API for this target." +msgstr "Il passaggio '{step_name}' nel target '{target}' ha parametri solo posizionali ({parameters}), che non sono supportati dal routing CLI con argomenti nominati. Usa l'API Python per questo target." + msgid "Submit" msgstr "Conferma" +msgid "Synergy session reset." +msgstr "Sessione Synergy reimpostata." + +msgid "TARGET is required unless --batch-file is used." +msgstr "TARGET è obbligatorio salvo quando viene usato --batch-file." + +msgid "Tab completion targets are refreshed automatically." +msgstr "I target del completamento con tabulazione vengono aggiornati automaticamente." + +msgid "Target" +msgstr "Destinazione" + +msgid "Target '{target}' is hidden from the CLI because '{hidden_path}' only creates a transient {wrapper_type} wrapper. The CLI constructs these helper objects internally when needed, so they are not exposed as direct CLI targets." +msgstr "Il target '{target}' è nascosto dalla CLI perché '{hidden_path}' crea solo un wrapper {wrapper_type} transitorio. La CLI costruisce internamente questi oggetti di supporto quando necessario, quindi non sono esposti come target CLI diretti." + +msgid "Target '{target}' is hidden from the CLI by library metadata on '{hidden_path}'." +msgstr "Il target '{target}' è nascosto dalla CLI dai metadati della libreria su '{hidden_path}'." + +msgid "Target '{target}' resolves to a property/attribute and does not accept arguments." +msgstr "Il target '{target}' si risolve in una proprietà/attributo e non accetta argomenti." + +msgid "Target '{target}' resolves to a {class_name} wrapper property. Continue to one of its members, for example 'describe {target}.'." +msgstr "Il target '{target}' si risolve in una proprietà wrapper {class_name}. Prosegui con uno dei suoi membri, ad esempio 'describe {target}.'." + +msgid "Target '{target}' resolves to property '{property_name}' (getter) and does not accept arguments." +msgstr "Il target '{target}' si risolve nella proprietà '{property_name}' (getter) e non accetta argomenti." + +msgid "Target '{target}' resolves to write-only property '{property_name}' and cannot be read via invoke." +msgstr "Il target '{target}' si risolve nella proprietà di sola scrittura '{property_name}' e non può essere letto tramite invoke." + +msgid "Target must include a class or function name" +msgstr "Il target deve includere un nome di classe o funzione" + +msgid "Target must include at least one segment" +msgstr "Il target deve includere almeno un segmento" + +msgid "Target must start with 'synergy' (or 'moldflow.synergy'). All invocations are rooted on the Synergy COM object." +msgstr "Il target deve iniziare con 'synergy' (o 'moldflow.synergy'). Tutte le invocazioni sono radicate sull'oggetto COM Synergy." + +msgid "Target must start with 'synergy' after the optional 'moldflow.' prefix. Bare targets such as 'open_project' are accepted and are interpreted as 'synergy.open_project'." +msgstr "Il target deve iniziare con 'synergy' dopo il prefisso facoltativo 'moldflow.'. I target senza prefisso come 'open_project' sono accettati e interpretati come 'synergy.open_project'." + +msgid "Targets are shown without the leading 'synergy.' prefix. Describe and invoke accept either form." +msgstr "I target sono mostrati senza il prefisso iniziale 'synergy.'. Describe e invoke accettano entrambe le forme." + msgid "Test String" msgstr "Stringa di prova" +msgid "The Moldflow CLI requires optional dependencies. Install them with: pip install 'moldflow[cli]'" +msgstr "La CLI Moldflow richiede dipendenze facoltative. Installale con: pip install 'moldflow[cli]'" + +msgid "The target returned False, which indicates a business-level failure." +msgstr "Il target ha restituito False, che indica un errore a livello di business." + +msgid "This dry run validates a property assignment." +msgstr "Questa esecuzione simulata valida un'assegnazione di proprietà." + +msgid "This parameter can be null to indicate no value." +msgstr "Questo parametro può essere null per indicare l'assenza di valore." + +msgid "This property is read-only and takes no arguments." +msgstr "Questa proprietà è di sola lettura e non accetta argomenti." + +msgid "This property returns a {class_name} wrapper. Continue with describe {target}. or invoke {target}.." +msgstr "Questa proprietà restituisce un wrapper {class_name}. Prosegui con describe {target}. o invoke {target}.." + +msgid "Tip: install pyreadline3 for tab completion support on Windows." +msgstr "Suggerimento: installa pyreadline3 per il completamento con tabulazione su Windows." + +msgid "Treat False return values as CLI failures (exit 1). This is enabled by default for automation-friendly behavior." +msgstr "Tratta i valori di ritorno False come errori CLI (uscita 1). Questo comportamento è abilitato per impostazione predefinita per favorire l'automazione." + +msgid "Try this:" +msgstr "Prova questo:" + +msgid "Type" +msgstr "Tipo" + +msgid "Type 'help' for available commands, 'exit' to quit." +msgstr "Digita 'help' per i comandi disponibili, 'exit' per uscire." + +msgid "Type help to see available commands." +msgstr "Digita help per vedere i comandi disponibili." + +msgid "Unknown batch item field(s): {fields}." +msgstr "Campo/i sconosciuto/i nell'elemento batch: {fields}." + +msgid "Unknown command:" +msgstr "Comando sconosciuto:" + +msgid "Unknown parameter '{parameter}' for {target}{signature}.{extra}" +msgstr "Parametro sconosciuto '{parameter}' per {target}{signature}.{extra}" + +msgid "Use 'help ' for detailed help on a specific command." +msgstr "Usa 'help ' per la guida dettagliata su un comando specifico." + +msgid "Use JSON field '{preferred_field}' for '{param_name}'." +msgstr "Usa il campo JSON '{preferred_field}' per '{param_name}'." + +msgid "Use JSON field '{preferred_field}'." +msgstr "Usa il campo JSON '{preferred_field}'." + +msgid "Use a comma-separated list for quick CLI input, or a JSON array string when values contain commas." +msgstr "Usa un elenco separato da virgole per un input CLI rapido, oppure una stringa array JSON quando i valori contengono virgole." + +msgid "Use a comma-separated triplet for vector shorthand." +msgstr "Usa una tripletta separata da virgole per la forma abbreviata del vettore." + +msgid "Use describe to inspect parameters, examples, and property behavior before invoking." +msgstr "Usa describe per controllare parametri, esempi e comportamento della proprietà prima di invocare." + +msgid "Use semicolon-separated triplets for quick CLI input. Quote the value in shells that treat semicolons specially." +msgstr "Usa triplette separate da punto e virgola per un input CLI rapido. Metti tra virgolette il valore nelle shell che trattano in modo speciale i punti e virgola." + msgid "Using prompts will use pop-up import options and will always show logs" msgstr "Usando le richieste verranno utilizzate le opzioni di importazione a comparsa e verranno sempre mostrati i registri" @@ -114,12 +777,30 @@ msgstr "Input valido" msgid "Valid Input Type" msgstr "Tipo di input valido" +msgid "Vector" +msgstr "Vettore" + +msgid "When JSON input is provided via --params-json or --params-json-file, no positional key=value args may be given." +msgstr "Quando l'input JSON è fornito tramite --params-json o --params-json-file, non possono essere forniti argomenti posizionali key=value." + +msgid "Write JSON output to the given file path without changing stdout mode." +msgstr "Scrivi l'output JSON nel percorso file indicato senza cambiare la modalità stdout." + +msgid "Wrote structured output to {path}." +msgstr "Output strutturato scritto in {path}." + +msgid "You are already in the REPL." +msgstr "Sei già nel REPL." + msgid "both {first} and {second} must be provided together" msgstr "{first} e {second} devono essere forniti insieme" msgid "cannot be empty" msgstr "non può essere vuoto" +msgid "failed" +msgstr "fallito" + msgid "found {min_value} must be less than {max_value}" msgstr "trovato {min_value}, deve essere minore di {max_value}" @@ -150,6 +831,18 @@ msgstr "trovato {value}, deve essere uno di {expected_values}" msgid "found {value}, must be positive" msgstr "trovato {value}, deve essere positivo" +msgid "interactive shell" +msgstr "shell interattiva" + +msgid "ok" +msgstr "riuscito" + +msgid "settable" +msgstr "impostabile" + +msgid "the owning object" +msgstr "l'oggetto proprietario" + msgid "{file_name} does not have a valid file extension, will use {default}" msgstr "{file_name} non ha un'estensione valida, verrà utilizzato {default}" @@ -159,6 +852,21 @@ msgstr "{name} è {value}" msgid "{name} parameter will be ignored" msgstr "Il parametro {name} verrà ignorato" +msgid "{param} receives the Plot returned by find_plot_by_name" +msgstr "{param} riceve il Plot restituito da find_plot_by_name" + +msgid "{type_name} ({size} items): {value}" +msgstr "{type_name} ({size} elementi): {value}" + +msgid "{type_name} attributes:" +msgstr "Attributi di {type_name}:" + +msgid "{type_name} result:" +msgstr "Risultato di {type_name}:" + +msgid "{type_name} values ({count} items):" +msgstr "Valori di {type_name} ({count} elementi):" + msgid "{value} cannot be found documented in {enum_name}, this may cause function call to fail" msgstr "Il valore {value} non è documentato in {enum_name}; ciò può causare l'errore della chiamata di funzione" diff --git a/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po b/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po index 9680773..d6d9108 100644 --- a/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po +++ b/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po @@ -3,9 +3,150 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: ja-JP\n" +msgid " Did you mean '{parameter}'?" +msgstr " '{parameter}' のことですか?" + +msgid " Known parameters: {known_params}." +msgstr " 使用可能なパラメータ: {known_params}." + +msgid "'{type_name}' no longer exposes adapter method '{method_name}'." +msgstr "'{type_name}' はアダプターメソッド '{method_name}' を公開しなくなりました。" + +msgid "--yaml requested but PyYAML is not installed: {error}" +msgstr "--yaml が要求されましたが、PyYAML がインストールされていません: {error}" + +msgid "--yaml requested but PyYAML is not installed: {exc}" +msgstr "--yaml が要求されましたが、PyYAML がインストールされていません: {exc}" + +msgid "Aborted." +msgstr "中止されました。" + +msgid "Advanced fallback only. Use this tagged shape when annotation context is unavailable, when a nested payload is truly generic, or when multiple wrapper families would be ambiguous." +msgstr "高度なフォールバック専用です。アノテーションコンテキストを利用できない場合、ネストしたペイロードが本当に汎用的な場合、または複数のラッパーファミリーで曖昧になる場合にのみ、このタグ付き形状を使用してください。" + +msgid "Argument '{argument}' must specify a parameter name after the step (e.g., {step_name}.param=...).{extra}" +msgstr "引数 '{argument}' では、ステップ名の後にパラメータ名を指定する必要があります(例: {step_name}.param=...)。{extra}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}" +msgstr "引数 '{argument}' は次のいずれかで始まる必要があります: {valid_steps}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "引数 '{argument}' は次のいずれかで始まる必要があります: {valid_steps}.{extra}" + +msgid "Argument '{step_name}' must start with one of: {names}" +msgstr "引数 '{step_name}' は次のいずれかで始まる必要があります: {names}" + +msgid "Argument error calling {target}{signature}: {error}" +msgstr "{target}{signature} の呼び出し中に引数エラーが発生しました: {error}" + +msgid "Arguments as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value. Duplicate or conflicting paths are rejected (for example param=1 with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "引数は key=value または param.attr=value の形式で指定します。チェインされたターゲットでは、たとえば find_plot_by_name.plot_name=\"My Plot\" のように、メソッド名をパラメータの接頭辞に付けてください。ネストしたルーティングには step.param.attr=value を使います。重複または競合するパス(例: param=1 と param.attr=2)は拒否され、位置専用パラメータを持つメソッドは名前付き CLI ルーティングではサポートされません。" + +msgid "Arguments for step '{step_name}' must be a JSON object of parameters." +msgstr "ステップ '{step_name}' の引数はパラメータの JSON オブジェクトである必要があります。" + +msgid "Array" +msgstr "配列" + +msgid "Attribute '{matched_name}' on class '{class_name}' returns a non-wrapper value{continuation}" +msgstr "クラス '{class_name}' の属性 '{matched_name}' はラッパー以外の値を返します{continuation}" + +msgid "Attribute '{matched_name}' on object '{type_name}' returns a non-wrapper value{continuation}" +msgstr "オブジェクト '{type_name}' の属性 '{matched_name}' はラッパー以外の値を返します{continuation}" + +msgid "Batch file must contain a JSON array of invoke call objects." +msgstr "バッチファイルには invoke 呼び出しオブジェクトの JSON 配列が含まれている必要があります。" + +msgid "Batch item field 'args' must be a list of strings." +msgstr "バッチ項目のフィールド 'args' は文字列のリストである必要があります。" + +msgid "Batch item field 'params_json_file' must be a string path." +msgstr "バッチ項目のフィールド 'params_json_file' は文字列パスである必要があります。" + +msgid "Batch item must be a JSON object." +msgstr "バッチ項目は JSON オブジェクトである必要があります。" + +msgid "Batch item requires string field 'target'." +msgstr "バッチ項目には文字列フィールド 'target' が必要です。" + +msgid "Batch item {index} error: {error}" +msgstr "バッチ項目 {index} エラー: {error}" + +msgid "Batch results" +msgstr "バッチ結果" + +msgid "Batch summary: {succeeded}/{total} succeeded, {failed} failed." +msgstr "バッチ概要: {succeeded}/{total} 件成功、{failed} 件失敗。" + +msgid "CLI argument: {value}" +msgstr "CLI 引数: {value}" + msgid "Cancel" msgstr "キャンセル" +msgid "Cannot assign property '{property_name}' while resolving target '{target}' because the owner object resolved to None." +msgstr "所有オブジェクトが None に解決されたため、ターゲット '{target}' の解決中にプロパティ '{property_name}' を割り当てられません。" + +msgid "Cannot build instance for type 'EntList'. No create_entity_list provider found." +msgstr "型 'EntList' のインスタンスを構築できません。create_entity_list プロバイダーが見つかりません。" + +msgid "Cannot build instance for type '{type_name}'. Not a known Synergy property or factory." +msgstr "型 '{type_name}' のインスタンスを構築できません。既知の Synergy プロパティまたはファクトリーではありません。" + +msgid "Cannot configure field '{key_name}' on '{type_name}': {error}" +msgstr "'{type_name}' のフィールド '{key_name}' を設定できません: {error}" + +msgid "Cannot invoke method '{segment}' for target '{target}' because '{owner}' is unavailable in the current session (it resolved to None). This target only works when that object exists." +msgstr "現在のセッションで '{owner}' を利用できないため(None に解決されました)、ターゲット '{target}' に対してメソッド '{segment}' を呼び出せません。このターゲットはそのオブジェクトが存在する場合にのみ動作します。" + +msgid "Cannot read JSON file '{file}': {exc}" +msgstr "JSON ファイル '{file}' を読み取れません: {exc}" + +msgid "Cannot read JSON file '{path}': {error}" +msgstr "JSON ファイル '{path}' を読み取れません: {error}" + +msgid "Cannot read batch file '{path}': {error}" +msgstr "バッチファイル '{path}' を読み取れません: {error}" + +msgid "Cannot resolve '{first}' on moldflow for introspection" +msgstr "イントロスペクションのために moldflow 上の '{first}' を解決できません" + +msgid "Cannot resolve attribute '{segment}' on '{class_name}' when executing target '{target}': {error}" +msgstr "ターゲット '{target}' の実行時に '{class_name}' 上の属性 '{segment}' を解決できません: {error}" + +msgid "Cannot resolve attribute '{segment}' without an object instance when resolving target '{target}'" +msgstr "ターゲット '{target}' の解決時に、オブジェクトインスタンスなしで属性 '{segment}' を解決できません" + +msgid "Cannot resolve segment '{segment}' in target '{target}' without a class context. Use a Synergy-rooted target such as 'synergy.some_method'." +msgstr "クラスコンテキストがないため、ターゲット '{target}' のセグメント '{segment}' を解決できません。'synergy.some_method' のような Synergy ルートのターゲットを使用してください。" + +msgid "Cannot set nested argument '{path}': {error}" +msgstr "ネストした引数 '{path}' を設定できません: {error}" + +msgid "Cannot set nested attributes for '{param_name}' without signature info on '{step_name}'." +msgstr "シグネチャ情報がないため、'{step_name}' の '{param_name}' に対するネスト属性を設定できません。" + +msgid "Cannot set property '{property_name}' on target '{target}': {error}" +msgstr "ターゲット '{target}' のプロパティ '{property_name}' を設定できません: {error}" + +msgid "Cannot write JSON file '{path}': {error}" +msgstr "JSON ファイル '{path}' に書き込めません: {error}" + +msgid "Canonical JSON field is derived from the reflected wrapper method signature for {method_name}()." +msgstr "正規の JSON フィールドは、{method_name}() の反映されたラッパーメソッドシグネチャから導出されます。" + +msgid "Canonical list field is derived from the reflected wrapper method {method_name}()." +msgstr "正規のリストフィールドは、反映されたラッパーメソッド {method_name}() から導出されます。" + +msgid "Canonical triplet field is derived from the reflected wrapper method {method_name}()." +msgstr "正規の三つ組フィールドは、反映されたラッパーメソッド {method_name}() から導出されます。" + +msgid "Canonical vector-array field is derived from the reflected wrapper method {method_name}()." +msgstr "正規のベクトル配列フィールドは、反映されたラッパーメソッド {method_name}() から導出されます。" + +msgid "Chained targets with repeated method names are ambiguous for argument routing: {duplicate_names}. Please use an equivalent target path where each invoked step name is unique." +msgstr "同じメソッド名を繰り返すチェインターゲットは、引数ルーティングで曖昧になります: {duplicate_names}。呼び出す各ステップ名が一意になる等価なターゲットパスを使用してください。" + msgid "Checking file extension {file_name}" msgstr "ファイル拡張子 {file_name} を確認しています" @@ -42,24 +183,207 @@ msgstr "{value} が正であることを確認しています" msgid "Checking {value} is {name}" msgstr "{value} が {name} であることを確認しています" +msgid "Class '{class_name}' has no attribute '{attr_name}'" +msgstr "クラス '{class_name}' に属性 '{attr_name}' はありません" + +msgid "Clear the screen" +msgstr "画面をクリアする" + +msgid "Clears the terminal screen and redraws the banner." +msgstr "ターミナル画面をクリアしてバナーを再描画します。" + +msgid "Command exited with status {code}" +msgstr "コマンドがステータス {code} で終了しました" + +msgid "Commands:" +msgstr "コマンド:" + +msgid "Conflicting argument paths '{left_path}' and '{right_path}' are not allowed." +msgstr "競合する引数パス '{left_path}' と '{right_path}' は許可されていません。" + msgid "Could not initialize with Instance ID: {value}" msgstr "インスタンス ID {value} で初期化できませんでした" +msgid "Ctrl+D also exits." +msgstr "Ctrl+D でも終了できます。" + +msgid "Detail" +msgstr "詳細" + +msgid "Direct parameter assignment remains the preferred non-JSON form." +msgstr "非 JSON 形式では、直接のパラメータ代入が引き続き推奨されます。" + +msgid "Disable ANSI color/styling in CLI output." +msgstr "CLI 出力で ANSI カラー/スタイリングを無効にします。" + +msgid "Discover invokable targets and the next command to run for each one." +msgstr "呼び出し可能なターゲットと、それぞれ次に実行すべきコマンドを確認します。" + +msgid "Do not pass TARGET when --batch-file is used." +msgstr "--batch-file 使用時は TARGET を渡さないでください。" + +msgid "Do not pass positional args/JSON input with --batch-file." +msgstr "--batch-file と位置引数/JSON 入力を併用しないでください。" + +msgid "Dotted path to a method or function, optionally chained, for example 'synergy.new_project' or 'synergy.plot_manager.find_plot_by_name'." +msgstr "メソッドまたは関数へのドット区切りパスです。必要に応じてチェインでき、例: 'synergy.new_project' または 'synergy.plot_manager.find_plot_by_name'。" + +msgid "Dotted path, e.g., synergy.new_project" +msgstr "ドット区切りパス。例: synergy.new_project" + +msgid "Dry run for {target}" +msgstr "{target} のドライラン" + +msgid "Duplicate argument path '{path}' is not allowed." +msgstr "重複する引数パス '{path}' は許可されていません。" + +msgid "Duplicate/conflicting paths are rejected. Arguments are passed as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value (for example param=1 conflicts with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "重複または競合するパスは拒否されます。引数は key=value または param.attr=value として渡します。チェインされたターゲットでは、たとえば find_plot_by_name.plot_name=\"My Plot\" のようにメソッド名を接頭辞として付けてください。ネストされたルーティングは step.param.attr=value を使用し(たとえば param=1 は param.attr=2 と競合します)、位置専用パラメータを持つメソッドは名前付き CLI ルーティングではサポートされません。" + +msgid "Emit a JSON schema-like representation of target parameters." +msgstr "ターゲットパラメータの JSON スキーマ風表現を出力します。" + +msgid "Emit line-delimited JSON trace events to stderr for target resolution and runtime invoke binding." +msgstr "ターゲット解決と実行時 invoke バインディングのために、行区切り JSON トレースイベントを stderr に出力します。" + +msgid "Emit structured JSON for scripting or agent use." +msgstr "スクリプトまたはエージェント利用向けに構造化 JSON を出力します。" + +msgid "Emit structured JSON for scripting or agent use." +msgstr "スクリプトまたはエージェント利用向けに構造化 JSON を出力します。" + +msgid "Emit structured YAML for scripting or agent use (requires PyYAML)." +msgstr "スクリプトまたはエージェント利用向けに構造化 YAML を出力します(PyYAML が必要です)。" + +msgid "Emit structured YAML for scripting or agent use (requires PyYAML)." +msgstr "スクリプトまたはエージェント利用向けに構造化 YAML を出力します(PyYAML が必要です)。" + +msgid "Emit the structured result as JSON to stdout (useful for automation/LLMs)." +msgstr "構造化された結果を JSON として stdout に出力します(自動化/LLM に有用です)。" + +msgid "Empty target" +msgstr "ターゲットが空です" + +msgid "Empty type tag is not valid for '{primary_expected}'." +msgstr "空の型タグは '{primary_expected}' では無効です。" + +msgid "Error:" +msgstr "エラー:" + +msgid "Example object shape: {shape}." +msgstr "オブジェクト形状の例: {shape}." + msgid "Executing {name}" msgstr "{name} を実行しています" +msgid "Exit the REPL" +msgstr "REPL を終了する" + +msgid "Exits the REPL." +msgstr "REPL を終了します。" + +msgid "Explicit field form: {value}" +msgstr "明示的なフィールド形式: {value}" + msgid "Failed to initialize Synergy: Synergy not found" msgstr "Synergy の初期化に失敗しました: Synergy が見つかりません" +msgid "Failed to render JSON output for {context}: {error}" +msgstr "{context} の JSON 出力を生成できませんでした: {error}" + +msgid "Failed to render JSON output for {context}: {exc}" +msgstr "{context} の JSON 出力の生成に失敗しました: {exc}" + +msgid "Failed to render YAML output for {context}: {error}" +msgstr "{context} の YAML 出力を生成できませんでした: {error}" + +msgid "Failed to render YAML output for {context}: {exc}" +msgstr "{context} の YAML 出力の生成に失敗しました: {exc}" + +msgid "Failed to reset session:" +msgstr "セッションのリセットに失敗しました:" + +msgid "Field '{key_name}' is not valid for '{type_name}'." +msgstr "フィールド '{key_name}' は '{type_name}' では無効です。" + +msgid "Field '{key}' for '{type_name}' must be a 3-item numeric sequence like [0, 0, 1]." +msgstr "'{type_name}' のフィールド '{key}' は [0, 0, 1] のような 3 項目の数値シーケンスである必要があります。" + +msgid "Field '{key}' for '{type_name}' must be a JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "'{type_name}' のフィールド '{key}' は、三つ組の JSON 配列または '0,0,0;1,0,0' のようなセミコロン区切りリストである必要があります。" + +msgid "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list." +msgstr "'{type_name}' のフィールド '{key}' は、JSON 配列またはカンマ区切りリストである必要があります。" + +msgid "Field '{key}' for '{type_name}' must be a comma-separated numeric triplet like '0,0,1'." +msgstr "'{type_name}' のフィールド '{key}' は、'0,0,1' のようなカンマ区切りの数値三つ組である必要があります。" + +msgid "Field '{key}' for '{type_name}' must be a list of numeric triplets." +msgstr "'{type_name}' のフィールド '{key}' は、数値三つ組のリストである必要があります。" + +msgid "Field '{key}' for '{type_name}' must be a string selection expression. Expected field: {preferred_field}." +msgstr "'{type_name}' のフィールド '{key}' は、文字列の選択式である必要があります。想定されるフィールド: {preferred_field}." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "'{type_name}' のフィールド '{key}' は、有効な三つ組の JSON 配列または '0,0,0;1,0,0' のようなセミコロン区切りリストである必要があります。" + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array or a comma-separated list." +msgstr "'{type_name}' のフィールド '{key}' は、有効な JSON 配列またはカンマ区切りリストである必要があります。" + +msgid "Field '{key}' for '{type_name}' must contain integer values." +msgstr "'{type_name}' のフィールド '{key}' には整数値を含める必要があります。" + +msgid "Field '{key}' for '{type_name}' must contain numeric values." +msgstr "'{type_name}' のフィールド '{key}' には数値を含める必要があります。" + +msgid "Fields '{previous_key}' and '{key}' both map to the same input for '{type_name}'. Provide only one of: {preferred_field} or direct parameter shorthand." +msgstr "フィールド '{previous_key}' と '{key}' はどちらも '{type_name}' の同じ入力に対応しています。{preferred_field} または直接パラメータ短縮形のどちらか一方だけを指定してください。" + +msgid "Filter by substring or wildcard pattern (* and ?)." +msgstr "部分文字列またはワイルドカードパターン(* と ?)で絞り込みます。" + +msgid "Filter by substring or wildcard pattern (* and ?). Repeat to keep targets matching any filter." +msgstr "部分文字列またはワイルドカード パターン(* と ?)でフィルタリングします。いずれかのフィルターに一致するターゲットを残すには、このオプションを繰り返してください。" + +msgid "Filtered matches:" +msgstr "絞り込み結果:" + +msgid "For JSON input, group parameters by step name. Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "JSON 入力では、パラメータをステップ名ごとにまとめてください。引数 '{argument}' は次のいずれかで始まる必要があります: {valid_steps}.{extra}" + +msgid "For multi-step targets, group params-json fields by step name." +msgstr "複数ステップのターゲットでは、params-json のフィールドをステップ名ごとにまとめてください。" + msgid "Getting {name}" msgstr "{name} を取得しています" msgid "Getting {name} at index {value}" msgstr "インデックス {value} の {name} を取得しています" +msgid "Goodbye!" +msgstr "さようなら!" + +msgid "If shorthand input is ambiguous, switch to --params-json. {guidance}" +msgstr "短縮入力が曖昧な場合は、--params-json に切り替えてください。{guidance}" + +msgid "In non-JSON mode, prefer direct shorthand like '{preferred_non_json}'." +msgstr "非 JSON モードでは、'{preferred_non_json}' のような直接の短縮形を優先してください。" + +msgid "Index" +msgstr "インデックス" + msgid "Initializing {name}" msgstr "{name} を初期化しています" +msgid "Input hints:" +msgstr "入力ヒント:" + +msgid "Inspect a target's signature, docs, examples, and structured invoke template." +msgstr "ターゲットのシグネチャ、ドキュメント、例、および構造化された invoke テンプレートを確認します。" + +msgid "Interrupted." +msgstr "中断されました。" + msgid "Invalid Attribute: {attribute} is not supported" msgstr "無効な属性: {attribute} はサポートされていません" @@ -69,18 +393,198 @@ msgstr "無効なファイルタイプ: {file_name}、{extensions} である必 msgid "Invalid Index: out of range" msgstr "無効なインデックス: 範囲外です" +msgid "Invalid JSON payload for parameters: {error}" +msgstr "パラメータの JSON ペイロードが無効です: {error}" + +msgid "Invalid JSON payload for parameters: {exc}" +msgstr "パラメータの JSON ペイロードが無効です: {exc}" + +msgid "Invalid JSON value for parameter '{param_name}': {error}" +msgstr "パラメータ '{param_name}' の JSON 値が無効です: {error}" + msgid "Invalid Type: must be {expected_types}, not {variable_type}" msgstr "無効な型: {variable_type} ではなく {expected_types} である必要があります" msgid "Invalid Value: {reason}" msgstr "無効な値: {reason}" +msgid "Invalid argument '{item}'. Expected key=value or param.attr=value." +msgstr "無効な引数 '{item}'。key=value または param.attr=value を指定してください。" + +msgid "Invalid argument for step '{step_name}': missing parameter name." +msgstr "ステップ '{step_name}' の引数が無効です: パラメータ名がありません。" + +msgid "Invalid nested argument path '{path}': attribute '{attr}' does not exist on '{obj_type}'." +msgstr "ネストした引数パス '{path}' が無効です: 属性 '{attr}' は '{obj_type}' に存在しません。" + +msgid "Invalid nested argument path '{path}': cannot nest into non-object '{obj_type}'." +msgstr "ネストした引数パス '{path}' が無効です: 非オブジェクト '{obj_type}' にはネストできません。" + +msgid "Invalid nested argument path '{path}': cannot set '{final_attr}' on non-object '{obj_type}'." +msgstr "ネストした引数パス '{path}' が無効です: 非オブジェクト '{obj_type}' に '{final_attr}' を設定できません。" + +msgid "Invalid value for parameter '{param_name}': {error}" +msgstr "パラメータ '{param_name}' の値が無効です: {error}" + +msgid "Invalid {field_name} '{path_text}': empty path segment is not allowed." +msgstr "無効な {field_name} '{path_text}': 空のパスセグメントは許可されていません。" + +msgid "Invalid {field_name} '{path_text}': segment '{segment}' must be a valid identifier." +msgstr "無効な {field_name} '{path_text}': セグメント '{segment}' は有効な識別子である必要があります。" + +msgid "Invalid {field_name}: value cannot be empty." +msgstr "無効な {field_name}:値を空にすることはできません。" + +msgid "JSON example:" +msgstr "JSON の例:" + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays and scalars are not allowed." +msgstr "パラメータの対応付けを含む JSON オブジェクトです(位置引数を上書きします)。トップレベルの配列やスカラーは使用できません。" + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays/scalars are not allowed." +msgstr "パラメータ対応を含む JSON オブジェクトです(位置引数を上書きします)。トップレベルの配列/スカラーは許可されません。" + +msgid "JSON parameters must be a JSON object of named arguments. Example: --params-json '{\"param\": 1}' or --params-json '{\"step\": {\"param\": 1}}' for chained targets." +msgstr "JSON パラメータは名前付き引数の JSON オブジェクトである必要があります。例: --params-json '{\"param\": 1}' またはチェインされたターゲットの場合 --params-json '{\"step\": {\"param\": 1}}'" + +msgid "JSON type tag '{type_tag}' does not match expected wrapper '{primary_expected}'." +msgstr "JSON 型タグ '{type_tag}' が想定されるラッパー '{primary_expected}' と一致しません。" + +msgid "JSON value:" +msgstr "JSON 値:" + +msgid "List result:" +msgstr "一覧結果:" + msgid "Logger was not setup" msgstr "ロガーが設定されていませんでした" +msgid "Missing required parameter '{parameter}' for {target}{signature}" +msgstr "{target}{signature} に必要なパラメータ '{parameter}' が不足しています" + +msgid "Moldflow command-line interface.\n\nStart with 'list' to discover targets, 'describe ' to inspect usage, then 'invoke ...' to run it." +msgstr "Moldflow コマンドライン インターフェース。\n\nまず 'list' でターゲットを見つけ、'describe ' で使い方を確認してから、'invoke ...' を実行します。" + +msgid "Moldflow invokable targets" +msgstr "Moldflow の呼び出し可能ターゲット" + +msgid "Nested argument '{path}' is not supported for **kwargs on step '{step_name}'. Use a single key (e.g., {example}=...)." +msgstr "ネストした引数 '{path}' は、ステップ '{step_name}' の **kwargs ではサポートされていません。単一キーを使用してください(例: {example}=...)。" + +msgid "No invokable targets matched these filters." +msgstr "これらのフィルターに一致する呼び出し可能ターゲットはありません。" + +msgid "No invokable targets matched this filter." +msgstr "このフィルターに一致する呼び出し可能ターゲットはありません。" + +msgid "Non-public argument path '{key}' is not allowed." +msgstr "非公開の引数パス '{key}' は許可されていません。" + +msgid "Non-public argument path '{left}' is not allowed." +msgstr "非公開の引数パス '{left}' は許可されていません。" + +msgid "Non-public argument path '{path}' is not allowed." +msgstr "非公開の引数パス '{path}' は許可されていません。" + +msgid "Non-public argument path '{step_name}' is not allowed." +msgstr "非公開の引数パス '{step_name}' は許可されていません。" + +msgid "Non-public argument path '{step}.{key}' is not allowed." +msgstr "非公開の引数パス '{step}.{key}' は許可されていません。" + +msgid "Non-public field '{key_name}' is not allowed when constructing '{type_name}' from JSON." +msgstr "JSON から '{type_name}' を構築する際、非公開フィールド '{key_name}' は許可されていません。" + +msgid "Non-public segment '{segment}' is not allowed in target '{target}'." +msgstr "ターゲット '{target}' に非公開セグメント '{segment}' は許可されていません。" + +msgid "Non-public segment '{seg}' is not allowed in target '{target}'." +msgstr "ターゲット '{target}' に非公開のセグメント '{seg}' は許可されていません。" + msgid "OK" msgstr "OK" +msgid "Object" +msgstr "オブジェクト" + +msgid "Object '{type_name}' has no attribute '{attr_name}'" +msgstr "オブジェクト '{type_name}' に属性 '{attr_name}' はありません" + +msgid "One or more dotted targets, for example synergy.new_project." +msgstr "1 つ以上のドット区切りターゲット。例: synergy.new_project。" + +msgid "Only one of --json or --yaml may be specified." +msgstr "--json と --yaml は同時に指定できません。" + +msgid "Only one of --json, --yaml, or --schema may be specified." +msgstr "--json、--yaml、--schema のうち一つだけ指定できます。" + +msgid "Only one of --params-json or --params-json-file may be specified." +msgstr "--params-json と --params-json-file は同時に指定できません。" + +msgid "Parameter '{param_name}' contains a null byte which is not allowed." +msgstr "パラメータ '{param_name}' にヌルバイトが含まれています(許可されていません)。" + +msgid "Parameter '{param_name}' contains control characters (newline/tab/carriage return); please provide a single-line value or quote/escape as needed." +msgstr "パラメータ '{param_name}' に制御文字(改行/タブ/復帰)が含まれています。単一行の値を指定するか、必要に応じて引用/エスケープしてください。" + +msgid "Parse error:" +msgstr "解析エラー:" + +msgid "Parse/validate/build kwargs and emit a call plan without executing invoke steps." +msgstr "kwargs を解析、検証、構築し、invoke ステップを実行せずに呼び出し計画を出力します。" + +msgid "Parse/validate/build kwargs and emit a template summary call plan without executing invoke steps." +msgstr "invoke ステップを実行せずに、kwargs を解析・検証・構築し、テンプレート要約の呼び出し計画を出力します。" + +msgid "Path to a JSON file containing an array of invoke calls for batch execution." +msgstr "バッチ実行用の invoke 呼び出し配列を含む JSON ファイルへのパスです。" + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object, not arrays/scalars." +msgstr "パラメータ対応を含む JSON ファイルへのパスです(位置引数を上書きします)。トップレベルのペイロードは配列/スカラーではなくオブジェクトである必要があります。" + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object." +msgstr "パラメータの対応付けを含む JSON ファイルへのパスです(位置引数を上書きします)。トップレベルのペイロードはオブジェクトである必要があります。" + +msgid "Planned steps:" +msgstr "計画されたステップ:" + +msgid "Prefer chaining invoke targets so this parameter is produced by a previous step, instead of constructing it manually in JSON." +msgstr "このパラメータを JSON で手動構築するのではなく、前のステップで生成されるように invoke ターゲットをチェインすることを推奨します。" + +msgid "Print the installed moldflow package version." +msgstr "インストールされている moldflow パッケージのバージョンを表示します。" + +msgid "Property assignment JSON must be an object with a single 'value' field." +msgstr "プロパティ代入用の JSON は、単一の 'value' フィールドを持つオブジェクトである必要があります。" + +msgid "Property assignment requires exactly one 'value' argument (e.g., value=... or --params-json '{\"value\": ...}')." +msgstr "プロパティ代入には、ちょうど 1 つの 'value' 引数が必要です(例: value=... または --params-json '{\"value\": ...}')。" + +msgid "Property {name} (id={id}, type={prop_type})" +msgstr "プロパティ {name} (id={id}, type={prop_type})" + +msgid "Read current value:" +msgstr "現在の値を読み取る:" + +msgid "Close Synergy and reset the session" +msgstr "Synergy を終了してセッションをリセット" + +msgid "Closes Synergy and resets the session for a fresh start." +msgstr "Synergy を終了し、セッションをリセットして最初からやり直します。" + +msgid "Resolved assignment:" +msgstr "解決された代入:" + +msgid "Resolved kwargs:" +msgstr "解決された kwargs:" + +msgid "Resolved object has no callable attribute '{segment}' when executing target '{target}'" +msgstr "ターゲット '{target}' の実行時に、解決されたオブジェクトには呼び出し可能な属性 '{segment}' がありません" + +msgid "Run a Moldflow target with named parameters or JSON input. Bare targets are treated as synergy.." +msgstr "名前付きパラメーターまたは JSON 入力を使って Moldflow ターゲットを実行します。プレフィックスのないターゲットは synergy. として扱われます。" + msgid "Save Error" msgstr "保存エラー" @@ -90,15 +594,171 @@ msgstr "保存エラー: {saving} を {file_name} に保存できませんでし msgid "Save Error: Failed to save {saving} to {file_name}" msgstr "保存エラー: {saving} を {file_name} に保存できませんでした" +msgid "Segment '{segment}' does not resolve as an attribute on class '{class_name}' when resolving target '{target}'" +msgstr "ターゲット '{target}' の解決時に、セグメント '{segment}' はクラス '{class_name}' の属性として解決されません" + +msgid "Segment '{segment}' is not a callable method on class '{class_name}' when resolving target '{target}'" +msgstr "ターゲット '{target}' の解決時に、セグメント '{segment}' はクラス '{class_name}' 上の呼び出し可能メソッドではありません" + +msgid "Selection" +msgstr "選択" + +msgid "Session:" +msgstr "セッション:" + +msgid "Set it with:" +msgstr "次で設定します:" + msgid "Setting {name} to {value}" msgstr "{name} を {value} に設定しています" +msgid "Shorter JSON example:" +msgstr "短い JSON 例:" + +msgid "Shorter form:" +msgstr "短縮形:" + +msgid "Show detailed help for a command" +msgstr "コマンドの詳細なヘルプを表示する" + +msgid "Show full tracebacks on errors instead of short messages." +msgstr "エラー時に短いメッセージではなく完全なトレースバックを表示します。" + +msgid "Show this help message" +msgstr "このヘルプメッセージを表示する" + +msgid "Showing the compact table for {count} filtered matches. Narrow the filter or use --json for canonical target strings." +msgstr "絞り込み結果 {count} 件のコンパクトテーブルを表示しています。フィルターをさらに絞り込むか、正規のターゲット文字列を得るには --json を使用してください。" + +msgid "Shows available commands and usage information." +msgstr "利用可能なコマンドと使用方法を表示します。" + +msgid "Start an interactive moldflow shell session." +msgstr "対話型の moldflow シェルセッションを開始します。" + +msgid "Status" +msgstr "状態" + +msgid "Step '{step_name}' in target '{target}' has positional-only parameters ({parameters}), which are not supported by CLI named-argument routing. Use the Python API for this target." +msgstr "ターゲット '{target}' のステップ '{step_name}' には位置専用パラメータ ({parameters}) があり、CLI の名前付き引数ルーティングではサポートされていません。このターゲットには Python API を使用してください。" + msgid "Submit" msgstr "OK" +msgid "Synergy session reset." +msgstr "Synergy セッションがリセットされました。" + +msgid "TARGET is required unless --batch-file is used." +msgstr "--batch-file を使用しない限り、TARGET は必須です。" + +msgid "Tab completion targets are refreshed automatically." +msgstr "タブ補完のターゲットは自動的に更新されます。" + +msgid "Target" +msgstr "ターゲット" + +msgid "Target '{target}' is hidden from the CLI because '{hidden_path}' only creates a transient {wrapper_type} wrapper. The CLI constructs these helper objects internally when needed, so they are not exposed as direct CLI targets." +msgstr "'{hidden_path}' は一時的な {wrapper_type} ラッパーしか作成しないため、ターゲット '{target}' は CLI から隠されています。CLI は必要に応じてこれらのヘルパーオブジェクトを内部で構築するため、直接の CLI ターゲットとしては公開されません。" + +msgid "Target '{target}' is hidden from the CLI by library metadata on '{hidden_path}'." +msgstr "ターゲット '{target}' は '{hidden_path}' 上のライブラリメタデータにより CLI から隠されています。" + +msgid "Target '{target}' resolves to a property/attribute and does not accept arguments." +msgstr "ターゲット '{target}' はプロパティ/属性に解決されるため、引数を受け付けません。" + +msgid "Target '{target}' resolves to a {class_name} wrapper property. Continue to one of its members, for example 'describe {target}.'." +msgstr "ターゲット '{target}' は {class_name} ラッパープロパティに解決されます。そのメンバーのいずれかに進んでください。例: 'describe {target}.'。" + +msgid "Target '{target}' resolves to property '{property_name}' (getter) and does not accept arguments." +msgstr "ターゲット '{target}' はプロパティ '{property_name}'(getter)に解決されるため、引数を受け付けません。" + +msgid "Target '{target}' resolves to write-only property '{property_name}' and cannot be read via invoke." +msgstr "ターゲット '{target}' は書き込み専用プロパティ '{property_name}' に解決されるため、invoke で読み取ることはできません。" + +msgid "Target must include a class or function name" +msgstr "ターゲットにはクラス名または関数名を含める必要があります" + +msgid "Target must include at least one segment" +msgstr "ターゲットには少なくとも1つのセグメントが必要です" + +msgid "Target must start with 'synergy' (or 'moldflow.synergy'). All invocations are rooted on the Synergy COM object." +msgstr "ターゲットは 'synergy'(または 'moldflow.synergy')で始まる必要があります。すべての呼び出しは Synergy の COM オブジェクトをルートとします。" + +msgid "Target must start with 'synergy' after the optional 'moldflow.' prefix. Bare targets such as 'open_project' are accepted and are interpreted as 'synergy.open_project'." +msgstr "ターゲットは、任意の 'moldflow.' 接頭辞の後に 'synergy' で始まる必要があります。'open_project' のような裸のターゲットも受け付けられ、その場合は 'synergy.open_project' と解釈されます。" + +msgid "Targets are shown without the leading 'synergy.' prefix. Describe and invoke accept either form." +msgstr "ターゲットは先頭の 'synergy.' 接頭辞を除いて表示されます。describe と invoke はどちらの形式も受け付けます。" + msgid "Test String" msgstr "テスト文字列" +msgid "The Moldflow CLI requires optional dependencies. Install them with: pip install 'moldflow[cli]'" +msgstr "Moldflow CLI にはオプション依存関係が必要です。次のコマンドでインストールしてください: pip install 'moldflow[cli]'" + +msgid "The target returned False, which indicates a business-level failure." +msgstr "ターゲットが False を返しました。これは業務レベルの失敗を示します。" + +msgid "This dry run validates a property assignment." +msgstr "このドライランはプロパティ代入を検証します。" + +msgid "This parameter can be null to indicate no value." +msgstr "このパラメータは、値がないことを示すために null にできます。" + +msgid "This property is read-only and takes no arguments." +msgstr "このプロパティは読み取り専用で、引数を取りません。" + +msgid "This property returns a {class_name} wrapper. Continue with describe {target}. or invoke {target}.." +msgstr "このプロパティは {class_name} ラッパーを返します。describe {target}. または invoke {target}. に進んでください。" + +msgid "Tip: install pyreadline3 for tab completion support on Windows." +msgstr "ヒント: Windows でタブ補完を使用するには pyreadline3 をインストールしてください。" + +msgid "Treat False return values as CLI failures (exit 1). This is enabled by default for automation-friendly behavior." +msgstr "False の戻り値を CLI の失敗(終了コード 1)として扱います。これは自動化しやすい挙動として既定で有効です。" + +msgid "Try this:" +msgstr "次を試してください:" + +msgid "Type" +msgstr "型" + +msgid "Type 'help' for available commands, 'exit' to quit." +msgstr "'help' で利用可能なコマンドを表示、'exit' で終了します。" + +msgid "Type help to see available commands." +msgstr "help と入力して利用可能なコマンドを確認してください。" + +msgid "Unknown batch item field(s): {fields}." +msgstr "不明なバッチ項目フィールド: {fields}." + +msgid "Unknown command:" +msgstr "不明なコマンド:" + +msgid "Unknown parameter '{parameter}' for {target}{signature}.{extra}" +msgstr "{target}{signature} に不明なパラメータ '{parameter}' があります。{extra}" + +msgid "Use 'help ' for detailed help on a specific command." +msgstr "'help <コマンド>' で特定のコマンドの詳細なヘルプを表示できます。" + +msgid "Use JSON field '{preferred_field}' for '{param_name}'." +msgstr "'{param_name}' には JSON フィールド '{preferred_field}' を使用してください。" + +msgid "Use JSON field '{preferred_field}'." +msgstr "JSON フィールド '{preferred_field}' を使用してください。" + +msgid "Use a comma-separated list for quick CLI input, or a JSON array string when values contain commas." +msgstr "CLI で素早く入力するにはカンマ区切りリストを使用し、値にカンマが含まれる場合は JSON 配列文字列を使用してください。" + +msgid "Use a comma-separated triplet for vector shorthand." +msgstr "ベクトルの短縮形にはカンマ区切りの三つ組を使用してください。" + +msgid "Use describe to inspect parameters, examples, and property behavior before invoking." +msgstr "呼び出す前に、describe を使ってパラメータ、例、およびプロパティの挙動を確認してください。" + +msgid "Use semicolon-separated triplets for quick CLI input. Quote the value in shells that treat semicolons specially." +msgstr "CLI で素早く入力するにはセミコロン区切りの三つ組を使用してください。セミコロンを特別扱いするシェルでは値を引用してください。" + msgid "Using prompts will use pop-up import options and will always show logs" msgstr "プロンプトを使用するとポップアップのインポートオプションが使用され、常にログが表示されます" @@ -114,12 +774,39 @@ msgstr "有効な入力" msgid "Valid Input Type" msgstr "有効な入力タイプ" +msgid "Vector" +msgstr "ベクトル" + +msgid "When JSON input is provided via --params-json or --params-json-file, no positional key=value args may be given." +msgstr "--params-json または --params-json-file による JSON 入力が提供されている場合、位置指定の key=value 引数は指定できません。" + +msgid "Write JSON output to the given file path without changing stdout mode." +msgstr "stdout モードを変更せずに、指定されたファイルパスへ JSON 出力を書き込みます。" + +msgid "Wrote structured output to {path}." +msgstr "構造化出力を {path} に書き込みました。" + +msgid "You are already in the REPL." +msgstr "すでに REPL 内にいます。" + +msgid "\nDid you mean step '{step_name}'?" +msgstr "\nステップ '{step_name}' のことですか?" + +msgid "\nFor JSON input on multi-step targets, group parameters by step name, e.g. {example}" +msgstr "\n複数ステップのターゲットで JSON 入力を使う場合は、パラメータをステップ名ごとにまとめてください。例: {example}" + +msgid "\nFor JSON input, this step key must map to an object of parameter names, e.g. {example}" +msgstr "\nJSON 入力では、このステップキーはパラメータ名のオブジェクトに対応している必要があります。例: {example}" + msgid "both {first} and {second} must be provided together" msgstr "{first} と {second} は同時に指定する必要があります" msgid "cannot be empty" msgstr "空にすることはできません" +msgid "failed" +msgstr "失敗" + msgid "found {min_value} must be less than {max_value}" msgstr "見つかった {min_value} は {max_value} より小さくなければなりません" @@ -150,6 +837,18 @@ msgstr "見つかった {value} は {expected_values} のいずれかでなけ msgid "found {value}, must be positive" msgstr "見つかった {value} は正でなければなりません" +msgid "interactive shell" +msgstr "対話型シェル" + +msgid "ok" +msgstr "成功" + +msgid "settable" +msgstr "設定可" + +msgid "the owning object" +msgstr "所有オブジェクト" + msgid "{file_name} does not have a valid file extension, will use {default}" msgstr "{file_name} には有効なファイル拡張子がありません。{default} を使用します" @@ -159,6 +858,21 @@ msgstr "{name} は {value} です" msgid "{name} parameter will be ignored" msgstr "パラメータ {name} は無視されます" +msgid "{param} receives the Plot returned by find_plot_by_name" +msgstr "{param} は find_plot_by_name が返した Plot を受け取ります" + +msgid "{type_name} ({size} items): {value}" +msgstr "{type_name} ({size} 件): {value}" + +msgid "{type_name} attributes:" +msgstr "{type_name} の属性:" + +msgid "{type_name} result:" +msgstr "{type_name} の結果:" + +msgid "{type_name} values ({count} items):" +msgstr "{type_name} の値({count} 件):" + msgid "{value} cannot be found documented in {enum_name}, this may cause function call to fail" msgstr "{value} は {enum_name} に文書化されていません。これにより関数呼び出しが失敗する可能性があります" @@ -166,4 +880,4 @@ msgid "{value} does not have a valid file extension, must be {extensions}" msgstr "{value} には有効なファイル拡張子がありません。{extensions} である必要があります" msgid "{value} is not a valid {enum_name}" -msgstr "{value}は有効な{enum_name}ではありません" +msgstr "{value} は有効な {enum_name} ではありません" diff --git a/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po b/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po index 630b372..80be462 100644 --- a/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po +++ b/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po @@ -3,9 +3,159 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: ko-KR\n" +msgid "\nDid you mean step '{step_name}'?" +msgstr "\n'{step_name}' 단계를 말씀하신 건가요?" + +msgid "\nFor JSON input on multi-step targets, group parameters by step name, e.g. {example}" +msgstr "\n다단계 대상에 JSON 입력을 사용할 때는 매개변수를 단계 이름별로 묶으세요. 예: {example}" + +msgid "\nFor JSON input, this step key must map to an object of parameter names, e.g. {example}" +msgstr "\nJSON 입력에서는 이 단계 키가 매개변수 이름 객체에 매핑되어야 합니다. 예: {example}" + +msgid " Did you mean '{parameter}'?" +msgstr " '{parameter}'을(를) 말씀하신 건가요?" + +msgid " Known parameters: {known_params}." +msgstr " 알려진 매개변수: {known_params}." + +msgid "'{type_name}' no longer exposes adapter method '{method_name}'." +msgstr "'{type_name}'은(는) 더 이상 어댑터 메서드 '{method_name}'을(를) 노출하지 않습니다." + +msgid "--yaml requested but PyYAML is not installed: {error}" +msgstr "--yaml 옵션이 요청되었지만 PyYAML이 설치되어 있지 않습니다: {error}" + +msgid "--yaml requested but PyYAML is not installed: {exc}" +msgstr "--yaml 옵션이 요청되었으나 PyYAML이 설치되어 있지 않습니다: {exc}" + +msgid "Aborted." +msgstr "중단되었습니다." + +msgid "Advanced fallback only. Use this tagged shape when annotation context is unavailable, when a nested payload is truly generic, or when multiple wrapper families would be ambiguous." +msgstr "고급 대체 방식 전용입니다. 주석 컨텍스트를 사용할 수 없거나, 중첩 페이로드가 실제로 제네릭이거나, 여러 래퍼 계열이 모호할 때 이 태그된 형태를 사용하세요." + +msgid "Argument '{argument}' must specify a parameter name after the step (e.g., {step_name}.param=...).{extra}" +msgstr "인수 '{argument}'는 단계 뒤에 매개변수 이름을 지정해야 합니다(예: {step_name}.param=...).{extra}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}" +msgstr "인수 '{argument}'는 다음 중 하나로 시작해야 합니다: {valid_steps}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "인수 '{argument}'는 다음 중 하나로 시작해야 합니다: {valid_steps}.{extra}" + +msgid "Argument '{step_name}' must start with one of: {names}" +msgstr "인수 '{step_name}'는 다음 중 하나로 시작해야 합니다: {names}" + +msgid "Argument error calling {target}{signature}: {error}" +msgstr "{target}{signature} 호출 중 인수 오류: {error}" + +msgid "Arguments as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value. Duplicate or conflicting paths are rejected (for example param=1 with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "인수는 key=value 또는 param.attr=value 형식으로 지정합니다. 체인 대상의 경우 예를 들어 find_plot_by_name.plot_name=\"My Plot\"처럼 메서드 이름을 매개변수 앞에 붙이세요. 중첩 라우팅은 step.param.attr=value를 사용합니다. 중복되거나 충돌하는 경로(예: param=1과 param.attr=2)는 거부되며, 위치 전용 매개변수가 있는 메서드는 이름 기반 CLI 라우팅을 지원하지 않습니다." + +msgid "Arguments for step '{step_name}' must be a JSON object of parameters." +msgstr "단계 '{step_name}'의 인수는 매개변수의 JSON 객체여야 합니다." + +msgid "Array" +msgstr "배열" + +msgid "Attribute '{matched_name}' on class '{class_name}' returns a non-wrapper value{continuation}" +msgstr "클래스 '{class_name}'의 속성 '{matched_name}'은(는) 래퍼가 아닌 값을 반환합니다{continuation}" + +msgid "Attribute '{matched_name}' on object '{type_name}' returns a non-wrapper value{continuation}" +msgstr "객체 '{type_name}'의 속성 '{matched_name}'은(는) 래퍼가 아닌 값을 반환합니다{continuation}" + +msgid "Batch file must contain a JSON array of invoke call objects." +msgstr "배치 파일에는 invoke 호출 객체의 JSON 배열이 포함되어야 합니다." + +msgid "Batch item field 'args' must be a list of strings." +msgstr "배치 항목 필드 'args'는 문자열 목록이어야 합니다." + +msgid "Batch item field 'params_json_file' must be a string path." +msgstr "배치 항목 필드 'params_json_file'은 문자열 경로여야 합니다." + +msgid "Batch item must be a JSON object." +msgstr "배치 항목은 JSON 객체여야 합니다." + +msgid "Batch item requires string field 'target'." +msgstr "배치 항목에는 문자열 필드 'target'이 필요합니다." + +msgid "Batch item {index} error: {error}" +msgstr "배치 항목 {index} 오류: {error}" + +msgid "Batch results" +msgstr "배치 결과" + +msgid "Batch summary: {succeeded}/{total} succeeded, {failed} failed." +msgstr "배치 요약: 총 {total}개 중 {succeeded}개 성공, {failed}개 실패." + +msgid "CLI argument: {value}" +msgstr "CLI 인수: {value}" + msgid "Cancel" msgstr "취소" +msgid "Cannot assign property '{property_name}' while resolving target '{target}' because the owner object resolved to None." +msgstr "소유자 객체가 None으로 확인되었기 때문에 대상 '{target}'을(를) 확인하는 동안 속성 '{property_name}'을(를) 할당할 수 없습니다." + +msgid "Cannot build instance for type 'EntList'. No create_entity_list provider found." +msgstr "유형 'EntList'의 인스턴스를 만들 수 없습니다. create_entity_list 제공자를 찾지 못했습니다." + +msgid "Cannot build instance for type '{type_name}'. Not a known Synergy property or factory." +msgstr "유형 '{type_name}'의 인스턴스를 만들 수 없습니다. 알려진 Synergy 속성 또는 팩토리가 아닙니다." + +msgid "Cannot configure field '{key_name}' on '{type_name}': {error}" +msgstr "'{type_name}'에서 필드 '{key_name}'을(를) 구성할 수 없습니다: {error}" + +msgid "Cannot invoke method '{segment}' for target '{target}' because '{owner}' is unavailable in the current session (it resolved to None). This target only works when that object exists." +msgstr "현재 세션에서 '{owner}'을(를) 사용할 수 없어(None으로 확인됨) 대상 '{target}'에 대해 메서드 '{segment}'을(를) 호출할 수 없습니다. 이 대상은 해당 객체가 존재할 때만 작동합니다." + +msgid "Cannot read JSON file '{file}': {exc}" +msgstr "JSON 파일 '{file}'을(를) 읽을 수 없습니다: {exc}" + +msgid "Cannot read JSON file '{path}': {error}" +msgstr "JSON 파일 '{path}'을(를) 읽을 수 없습니다: {error}" + +msgid "Cannot read batch file '{path}': {error}" +msgstr "배치 파일 '{path}'을(를) 읽을 수 없습니다: {error}" + +msgid "Cannot resolve '{first}' on moldflow for introspection" +msgstr "인트로스펙션을 위해 moldflow에서 '{first}'을(를) 확인할 수 없습니다" + +msgid "Cannot resolve attribute '{segment}' on '{class_name}' when executing target '{target}': {error}" +msgstr "대상 '{target}'을(를) 실행할 때 '{class_name}'에서 속성 '{segment}'을(를) 확인할 수 없습니다: {error}" + +msgid "Cannot resolve attribute '{segment}' without an object instance when resolving target '{target}'" +msgstr "대상 '{target}'을(를) 확인할 때 객체 인스턴스 없이 속성 '{segment}'을(를) 확인할 수 없습니다" + +msgid "Cannot resolve segment '{segment}' in target '{target}' without a class context. Use a Synergy-rooted target such as 'synergy.some_method'." +msgstr "클래스 컨텍스트 없이 대상 '{target}'의 세그먼트 '{segment}'을(를) 확인할 수 없습니다. 'synergy.some_method'와 같은 Synergy 루트 대상을 사용하세요." + +msgid "Cannot set nested argument '{path}': {error}" +msgstr "중첩 인수 '{path}'을(를) 설정할 수 없습니다: {error}" + +msgid "Cannot set nested attributes for '{param_name}' without signature info on '{step_name}'." +msgstr "'{step_name}'의 시그니처 정보 없이 '{param_name}'의 중첩 속성을 설정할 수 없습니다." + +msgid "Cannot set property '{property_name}' on target '{target}': {error}" +msgstr "대상 '{target}'에 속성 '{property_name}'을(를) 설정할 수 없습니다: {error}" + +msgid "Cannot write JSON file '{path}': {error}" +msgstr "JSON 파일 '{path}'에 쓸 수 없습니다: {error}" + +msgid "Canonical JSON field is derived from the reflected wrapper method signature for {method_name}()." +msgstr "정규 JSON 필드는 반영된 래퍼 메서드 {method_name}()의 시그니처에서 파생됩니다." + +msgid "Canonical list field is derived from the reflected wrapper method {method_name}()." +msgstr "정규 목록 필드는 반영된 래퍼 메서드 {method_name}()에서 파생됩니다." + +msgid "Canonical triplet field is derived from the reflected wrapper method {method_name}()." +msgstr "정규 3중값 필드는 반영된 래퍼 메서드 {method_name}()에서 파생됩니다." + +msgid "Canonical vector-array field is derived from the reflected wrapper method {method_name}()." +msgstr "정규 벡터 배열 필드는 반영된 래퍼 메서드 {method_name}()에서 파생됩니다." + +msgid "Chained targets with repeated method names are ambiguous for argument routing: {duplicate_names}. Please use an equivalent target path where each invoked step name is unique." +msgstr "반복된 메서드 이름이 있는 체인 대상은 인수 라우팅이 모호합니다: {duplicate_names}. 호출되는 각 단계 이름이 고유한 동등한 대상 경로를 사용하세요." + msgid "Checking file extension {file_name}" msgstr "파일 확장자 {file_name} 확인 중" @@ -42,24 +192,207 @@ msgstr "{value}가 양수인지 확인 중" msgid "Checking {value} is {name}" msgstr "{value}가 {name}인지 확인 중" +msgid "Class '{class_name}' has no attribute '{attr_name}'" +msgstr "클래스 '{class_name}'에 속성 '{attr_name}'이(가) 없습니다" + +msgid "Clear the screen" +msgstr "화면 지우기" + +msgid "Clears the terminal screen and redraws the banner." +msgstr "터미널 화면을 지우고 배너를 다시 그립니다." + +msgid "Close Synergy and reset the session" +msgstr "Synergy를 종료하고 세션 재설정" + +msgid "Closes Synergy and resets the session for a fresh start." +msgstr "Synergy를 종료하고 세션을 재설정하여 새로 시작합니다." + +msgid "Command exited with status {code}" +msgstr "명령이 상태 {code}(으)로 종료되었습니다" + +msgid "Commands:" +msgstr "명령어:" + +msgid "Conflicting argument paths '{left_path}' and '{right_path}' are not allowed." +msgstr "충돌하는 인수 경로 '{left_path}'와 '{right_path}'는 허용되지 않습니다." + msgid "Could not initialize with Instance ID: {value}" msgstr "인스턴스 ID {value}로 초기화할 수 없습니다" +msgid "Ctrl+D also exits." +msgstr "Ctrl+D로도 종료할 수 있습니다." + +msgid "Detail" +msgstr "세부 정보" + +msgid "Direct parameter assignment remains the preferred non-JSON form." +msgstr "직접 매개변수 할당이 여전히 기본으로 권장되는 비 JSON 형식입니다." + +msgid "Disable ANSI color/styling in CLI output." +msgstr "CLI 출력에서 ANSI 색상/스타일을 비활성화합니다." + +msgid "Discover invokable targets and the next command to run for each one." +msgstr "호출 가능한 대상과 각 대상에 대해 다음에 실행할 명령을 확인합니다." + +msgid "Do not pass TARGET when --batch-file is used." +msgstr "--batch-file을 사용할 때는 TARGET을 전달하지 마세요." + +msgid "Do not pass positional args/JSON input with --batch-file." +msgstr "--batch-file과 함께 위치 인수/JSON 입력을 전달하지 마세요." + +msgid "Dotted path to a method or function, optionally chained, for example 'synergy.new_project' or 'synergy.plot_manager.find_plot_by_name'." +msgstr "메서드 또는 함수까지의 점 표기 경로입니다. 선택적으로 체인할 수 있으며, 예를 들면 'synergy.new_project' 또는 'synergy.plot_manager.find_plot_by_name'입니다." + +msgid "Dotted path, e.g., synergy.new_project" +msgstr "점 표기 경로, 예: synergy.new_project" + +msgid "Dry run for {target}" +msgstr "{target}에 대한 드라이런" + +msgid "Duplicate argument path '{path}' is not allowed." +msgstr "중복된 인수 경로 '{path}'는 허용되지 않습니다." + +msgid "Duplicate/conflicting paths are rejected. Arguments are passed as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value (for example param=1 conflicts with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "중복/충돌 경로는 거부됩니다. 인수는 key=value 또는 param.attr=value로 전달됩니다. 연결된 대상의 경우 find_plot_by_name.plot_name=\"My Plot\"처럼 매개변수 앞에 메서드 이름을 붙입니다. 중첩 라우팅은 step.param.attr=value를 사용하며(예: param=1은 param.attr=2와 충돌), 위치 전용 매개변수를 가진 메서드는 명명된 CLI 라우팅에서 지원되지 않습니다." + +msgid "Emit a JSON schema-like representation of target parameters." +msgstr "대상 매개변수의 JSON 스키마 유사 표현을 출력합니다." + +msgid "Emit line-delimited JSON trace events to stderr for target resolution and runtime invoke binding." +msgstr "대상 확인 및 런타임 invoke 바인딩을 위해 줄 단위 JSON 추적 이벤트를 stderr로 출력합니다." + +msgid "Emit structured JSON for scripting or agent use." +msgstr "스크립트나 에이전트 사용을 위해 구조화된 JSON을 출력합니다." + +msgid "Emit structured YAML for scripting or agent use (requires PyYAML)." +msgstr "스크립트나 에이전트 사용을 위해 구조화된 YAML을 출력합니다(PyYAML 필요)." + +msgid "Emit the structured result as JSON to stdout (useful for automation/LLMs)." +msgstr "구조화된 결과를 stdout으로 JSON 출력합니다(자동화/LLM에 유용)." + +msgid "Empty target" +msgstr "대상 비어 있음" + +msgid "Empty type tag is not valid for '{primary_expected}'." +msgstr "빈 타입 태그는 '{primary_expected}'에 유효하지 않습니다." + +msgid "Error:" +msgstr "오류:" + +msgid "Example object shape: {shape}." +msgstr "예시 객체 형태: {shape}." + msgid "Executing {name}" msgstr "{name} 실행 중" +msgid "Exit the REPL" +msgstr "REPL 종료" + +msgid "Exits the REPL." +msgstr "REPL을 종료합니다." + +msgid "Explicit field form: {value}" +msgstr "명시적 필드 형식: {value}" + msgid "Failed to initialize Synergy: Synergy not found" msgstr "Synergy 초기화 실패: Synergy를 찾을 수 없음" +msgid "Failed to render JSON output for {context}: {error}" +msgstr "{context}에 대한 JSON 출력 렌더링에 실패했습니다: {error}" + +msgid "Failed to render JSON output for {context}: {exc}" +msgstr "{context}에 대한 JSON 출력 생성에 실패했습니다: {exc}" + +msgid "Failed to render YAML output for {context}: {error}" +msgstr "{context}에 대한 YAML 출력 렌더링에 실패했습니다: {error}" + +msgid "Failed to render YAML output for {context}: {exc}" +msgstr "{context}에 대한 YAML 출력 생성에 실패했습니다: {exc}" + +msgid "Failed to reset session:" +msgstr "세션 재설정에 실패했습니다:" + +msgid "Field '{key_name}' is not valid for '{type_name}'." +msgstr "필드 '{key_name}'은(는) '{type_name}'에 유효하지 않습니다." + +msgid "Field '{key}' for '{type_name}' must be a 3-item numeric sequence like [0, 0, 1]." +msgstr "'{type_name}'의 필드 '{key}'은(는) [0, 0, 1]과 같은 3개 항목의 숫자 시퀀스여야 합니다." + +msgid "Field '{key}' for '{type_name}' must be a JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "'{type_name}'의 필드 '{key}'은(는) 3중값의 JSON 배열이거나 '0,0,0;1,0,0' 같은 세미콜론 구분 목록이어야 합니다." + +msgid "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list." +msgstr "'{type_name}'의 필드 '{key}'은(는) JSON 배열 또는 쉼표로 구분된 목록이어야 합니다." + +msgid "Field '{key}' for '{type_name}' must be a comma-separated numeric triplet like '0,0,1'." +msgstr "'{type_name}'의 필드 '{key}'은(는) '0,0,1'과 같은 쉼표로 구분된 숫자 3중값이어야 합니다." + +msgid "Field '{key}' for '{type_name}' must be a list of numeric triplets." +msgstr "'{type_name}'의 필드 '{key}'은(는) 숫자 3중값 목록이어야 합니다." + +msgid "Field '{key}' for '{type_name}' must be a string selection expression. Expected field: {preferred_field}." +msgstr "'{type_name}'의 필드 '{key}'은(는) 문자열 선택 식이어야 합니다. 예상 필드: {preferred_field}." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "'{type_name}'의 필드 '{key}'은(는) 유효한 3중값 JSON 배열이거나 '0,0,0;1,0,0' 같은 세미콜론 구분 목록이어야 합니다." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array or a comma-separated list." +msgstr "'{type_name}'의 필드 '{key}'은(는) 유효한 JSON 배열 또는 쉼표로 구분된 목록이어야 합니다." + +msgid "Field '{key}' for '{type_name}' must contain integer values." +msgstr "'{type_name}'의 필드 '{key}'은(는) 정수 값을 포함해야 합니다." + +msgid "Field '{key}' for '{type_name}' must contain numeric values." +msgstr "'{type_name}'의 필드 '{key}'은(는) 숫자 값을 포함해야 합니다." + +msgid "Fields '{previous_key}' and '{key}' both map to the same input for '{type_name}'. Provide only one of: {preferred_field} or direct parameter shorthand." +msgstr "필드 '{previous_key}'와 '{key}'은(는) 모두 '{type_name}'의 동일한 입력에 매핑됩니다. {preferred_field} 또는 직접 매개변수 축약형 중 하나만 제공하세요." + +msgid "Filter by substring or wildcard pattern (* and ?)." +msgstr "부분 문자열 또는 와일드카드 패턴(* 및 ?)으로 필터링합니다." + +msgid "Filter by substring or wildcard pattern (* and ?). Repeat to keep targets matching any filter." +msgstr "부분 문자열 또는 와일드카드 패턴(* 및 ?)으로 필터링합니다. 필터 중 하나라도 일치하는 대상을 유지하려면 이 옵션을 반복하세요." + +msgid "Filtered matches:" +msgstr "필터된 일치 항목:" + +msgid "For JSON input, group parameters by step name. Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "JSON 입력에서는 매개변수를 단계 이름별로 묶으세요. 인수 '{argument}'는 다음 중 하나로 시작해야 합니다: {valid_steps}.{extra}" + +msgid "For multi-step targets, group params-json fields by step name." +msgstr "다단계 대상의 경우 params-json 필드를 단계 이름별로 묶으세요." + msgid "Getting {name}" msgstr "{name} 가져오는 중" msgid "Getting {name} at index {value}" msgstr "인덱스 {value}에서 {name} 가져오는 중" +msgid "Goodbye!" +msgstr "안녕히 가세요!" + +msgid "If shorthand input is ambiguous, switch to --params-json. {guidance}" +msgstr "축약 입력이 모호하면 --params-json으로 전환하세요. {guidance}" + +msgid "In non-JSON mode, prefer direct shorthand like '{preferred_non_json}'." +msgstr "비 JSON 모드에서는 '{preferred_non_json}'과 같은 직접 축약형을 우선 사용하세요." + +msgid "Index" +msgstr "인덱스" + msgid "Initializing {name}" msgstr "{name} 초기화 중" +msgid "Input hints:" +msgstr "입력 힌트:" + +msgid "Inspect a target's signature, docs, examples, and structured invoke template." +msgstr "대상의 시그니처, 문서, 예제 및 구조화된 invoke 템플릿을 확인합니다." + +msgid "Interrupted." +msgstr "중단되었습니다." + msgid "Invalid Attribute: {attribute} is not supported" msgstr "잘못된 속성: {attribute}는 지원되지 않습니다" @@ -69,18 +402,192 @@ msgstr "잘못된 파일 형식: {file_name}, {extensions}여야 합니다" msgid "Invalid Index: out of range" msgstr "잘못된 인덱스: 범위를 벗어났습니다" +msgid "Invalid JSON payload for parameters: {error}" +msgstr "매개변수에 대한 JSON 페이로드가 유효하지 않습니다: {error}" + +msgid "Invalid JSON payload for parameters: {exc}" +msgstr "매개변수에 대한 JSON 페이로드가 유효하지 않습니다: {exc}" + +msgid "Invalid JSON value for parameter '{param_name}': {error}" +msgstr "매개변수 '{param_name}'의 JSON 값이 유효하지 않습니다: {error}" + msgid "Invalid Type: must be {expected_types}, not {variable_type}" msgstr "잘못된 유형: {variable_type}가 아닌 {expected_types}여야 합니다" msgid "Invalid Value: {reason}" msgstr "잘못된 값: {reason}" +msgid "Invalid argument '{item}'. Expected key=value or param.attr=value." +msgstr "잘못된 인수 '{item}'입니다. key=value 또는 param.attr=value를 지정하세요." + +msgid "Invalid argument for step '{step_name}': missing parameter name." +msgstr "단계 '{step_name}'의 인수가 잘못되었습니다: 매개변수 이름이 없습니다." + +msgid "Invalid nested argument path '{path}': attribute '{attr}' does not exist on '{obj_type}'." +msgstr "중첩 인수 경로 '{path}'가 잘못되었습니다: '{obj_type}'에 속성 '{attr}'이(가) 없습니다." + +msgid "Invalid nested argument path '{path}': cannot nest into non-object '{obj_type}'." +msgstr "중첩 인수 경로 '{path}'가 잘못되었습니다: 객체가 아닌 '{obj_type}' 안으로 중첩할 수 없습니다." + +msgid "Invalid nested argument path '{path}': cannot set '{final_attr}' on non-object '{obj_type}'." +msgstr "중첩 인수 경로 '{path}'가 잘못되었습니다: 객체가 아닌 '{obj_type}'에 '{final_attr}'을(를) 설정할 수 없습니다." + +msgid "Invalid value for parameter '{param_name}': {error}" +msgstr "매개변수 '{param_name}'의 값이 유효하지 않습니다: {error}" + +msgid "Invalid {field_name} '{path_text}': empty path segment is not allowed." +msgstr "잘못된 {field_name} '{path_text}': 빈 경로 세그먼트는 허용되지 않습니다." + +msgid "Invalid {field_name} '{path_text}': segment '{segment}' must be a valid identifier." +msgstr "잘못된 {field_name} '{path_text}': 세그먼트 '{segment}'는 유효한 식별자여야 합니다." + +msgid "Invalid {field_name}: value cannot be empty." +msgstr "잘못된 {field_name}: 값은 비어 있을 수 없습니다." + +msgid "JSON example:" +msgstr "JSON 예:" + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays and scalars are not allowed." +msgstr "매개변수 매핑을 포함하는 JSON 객체입니다(위치 인수를 재정의함). 최상위 배열과 스칼라는 허용되지 않습니다." + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays/scalars are not allowed." +msgstr "매개변수 매핑을 포함하는 JSON 객체입니다(위치 인수를 재정의함). 최상위 배열과 스칼라는 허용되지 않습니다." + +msgid "JSON parameters must be a JSON object of named arguments. Example: --params-json '{\"param\": 1}' or --params-json '{\"step\": {\"param\": 1}}' for chained targets." +msgstr "JSON 매개변수는 이름이 지정된 인수의 JSON 객체여야 합니다. 예: --params-json '{\"param\": 1}' 또는 체인된 대상의 경우 --params-json '{\"step\": {\"param\": 1}}'" + +msgid "JSON type tag '{type_tag}' does not match expected wrapper '{primary_expected}'." +msgstr "JSON 타입 태그 '{type_tag}'가 예상 래퍼 '{primary_expected}'와 일치하지 않습니다." + +msgid "JSON value:" +msgstr "JSON 값:" + +msgid "List result:" +msgstr "목록 결과:" + msgid "Logger was not setup" msgstr "로거가 설정되지 않았습니다" +msgid "Missing required parameter '{parameter}' for {target}{signature}" +msgstr "{target}{signature}에 필요한 매개변수 '{parameter}'이(가) 없습니다" + +msgid "Moldflow command-line interface.\n\nStart with 'list' to discover targets, 'describe ' to inspect usage, then 'invoke ...' to run it." +msgstr "Moldflow 명령줄 인터페이스입니다.\n\n먼저 'list'로 대상을 찾고, 'describe '로 사용법을 확인한 다음, 'invoke ...'를 실행하세요." + +msgid "Moldflow invokable targets" +msgstr "Moldflow 호출 가능한 대상" + +msgid "Nested argument '{path}' is not supported for **kwargs on step '{step_name}'. Use a single key (e.g., {example}=...)." +msgstr "중첩 인수 '{path}'은(는) 단계 '{step_name}'의 **kwargs에서 지원되지 않습니다. 단일 키를 사용하세요(예: {example}=...)." + +msgid "No invokable targets matched these filters." +msgstr "이 필터들과 일치하는 호출 가능 대상이 없습니다." + +msgid "No invokable targets matched this filter." +msgstr "이 필터와 일치하는 호출 가능 대상이 없습니다." + +msgid "Non-public argument path '{key}' is not allowed." +msgstr "비공개 인수 경로 '{key}'는 허용되지 않습니다." + +msgid "Non-public argument path '{left}' is not allowed." +msgstr "비공개 인수 경로 '{left}'는 허용되지 않습니다." + +msgid "Non-public argument path '{path}' is not allowed." +msgstr "비공개 인수 경로 '{path}'는 허용되지 않습니다." + +msgid "Non-public argument path '{step_name}' is not allowed." +msgstr "비공개 인수 경로 '{step_name}'는 허용되지 않습니다." + +msgid "Non-public argument path '{step}.{key}' is not allowed." +msgstr "비공개 인수 경로 '{step}.{key}'는 허용되지 않습니다." + +msgid "Non-public field '{key_name}' is not allowed when constructing '{type_name}' from JSON." +msgstr "JSON에서 '{type_name}'을(를) 구성할 때 비공개 필드 '{key_name}'는 허용되지 않습니다." + +msgid "Non-public segment '{segment}' is not allowed in target '{target}'." +msgstr "대상 '{target}'에서 비공개 세그먼트 '{segment}'는 허용되지 않습니다." + +msgid "Non-public segment '{seg}' is not allowed in target '{target}'." +msgstr "대상 '{target}'에 비공개 세그먼트 '{seg}'는 허용되지 않습니다." + msgid "OK" msgstr "확인" +msgid "Object" +msgstr "객체" + +msgid "Object '{type_name}' has no attribute '{attr_name}'" +msgstr "객체 '{type_name}'에 속성 '{attr_name}'이(가) 없습니다" + +msgid "One or more dotted targets, for example synergy.new_project." +msgstr "점으로 구분된 대상 하나 이상(예: synergy.new_project)." + +msgid "Only one of --json or --yaml may be specified." +msgstr "--json 또는 --yaml 중 하나만 지정할 수 있습니다." + +msgid "Only one of --json, --yaml, or --schema may be specified." +msgstr "--json, --yaml 또는 --schema 중 하나만 지정할 수 있습니다." + +msgid "Only one of --params-json or --params-json-file may be specified." +msgstr "--params-json 또는 --params-json-file 중 하나만 지정할 수 있습니다." + +msgid "Parameter '{param_name}' contains a null byte which is not allowed." +msgstr "매개변수 '{param_name}'에 널 바이트가 포함되어 있습니다. 허용되지 않습니다." + +msgid "Parameter '{param_name}' contains control characters (newline/tab/carriage return); please provide a single-line value or quote/escape as needed." +msgstr "매개변수 '{param_name}'에 제어 문자(줄바꿈/탭/캐리지 리턴)가 포함되어 있습니다. 한 줄 값으로 제공하거나 필요에 따라 따옴표/이스케이프하세요." + +msgid "Parse error:" +msgstr "구문 분석 오류:" + +msgid "Parse/validate/build kwargs and emit a call plan without executing invoke steps." +msgstr "invoke 단계를 실행하지 않고 kwargs를 구문 분석/검증/구성한 뒤 호출 계획을 출력합니다." + +msgid "Parse/validate/build kwargs and emit a template summary call plan without executing invoke steps." +msgstr "invoke 단계를 실행하지 않고 kwargs를 구문 분석/검증/구성한 뒤 템플릿 요약 호출 계획을 출력합니다." + +msgid "Path to a JSON file containing an array of invoke calls for batch execution." +msgstr "배치 실행용 invoke 호출 배열이 들어 있는 JSON 파일 경로입니다." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object, not arrays/scalars." +msgstr "매개변수 매핑이 들어 있는 JSON 파일 경로입니다(위치 인수를 재정의함). 최상위 페이로드는 객체여야 합니다." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object." +msgstr "매개변수 매핑이 들어 있는 JSON 파일 경로입니다(위치 인수를 재정의함). 최상위 페이로드는 객체여야 합니다." + +msgid "Planned steps:" +msgstr "계획된 단계:" + +msgid "Prefer chaining invoke targets so this parameter is produced by a previous step, instead of constructing it manually in JSON." +msgstr "이 매개변수를 JSON에서 수동으로 구성하는 대신, 이전 단계에서 생성되도록 invoke 대상을 체인하는 방식을 권장합니다." + +msgid "Print the installed moldflow package version." +msgstr "설치된 moldflow 패키지 버전을 출력합니다." + +msgid "Property assignment JSON must be an object with a single 'value' field." +msgstr "속성 할당 JSON은 단일 'value' 필드가 있는 객체여야 합니다." + +msgid "Property assignment requires exactly one 'value' argument (e.g., value=... or --params-json '{\"value\": ...}')." +msgstr "속성 할당에는 정확히 하나의 'value' 인수가 필요합니다(예: value=... 또는 --params-json '{\"value\": ...}')." + +msgid "Property {name} (id={id}, type={prop_type})" +msgstr "속성 {name} (id={id}, 유형={prop_type})" + +msgid "Read current value:" +msgstr "현재 값 읽기:" + +msgid "Resolved assignment:" +msgstr "확인된 할당:" + +msgid "Resolved kwargs:" +msgstr "확인된 kwargs:" + +msgid "Resolved object has no callable attribute '{segment}' when executing target '{target}'" +msgstr "대상 '{target}'을(를) 실행할 때 확인된 객체에 호출 가능한 속성 '{segment}'이(가) 없습니다" + +msgid "Run a Moldflow target with named parameters or JSON input. Bare targets are treated as synergy.." +msgstr "이름이 지정된 매개변수 또는 JSON 입력으로 Moldflow 대상을 실행합니다. 접두사가 없는 대상은 synergy.으로 처리됩니다." + msgid "Save Error" msgstr "저장 오류" @@ -90,15 +597,171 @@ msgstr "저장 오류: {saving}을(를) {file_name}에 저장할 수 없습니 msgid "Save Error: Failed to save {saving} to {file_name}" msgstr "저장 오류: {saving}을(를) {file_name}에 저장하지 못했습니다" +msgid "Segment '{segment}' does not resolve as an attribute on class '{class_name}' when resolving target '{target}'" +msgstr "대상 '{target}'을(를) 확인할 때 세그먼트 '{segment}'는 클래스 '{class_name}'의 속성으로 확인되지 않습니다" + +msgid "Segment '{segment}' is not a callable method on class '{class_name}' when resolving target '{target}'" +msgstr "대상 '{target}'을(를) 확인할 때 세그먼트 '{segment}'는 클래스 '{class_name}'에서 호출 가능한 메서드가 아닙니다" + +msgid "Selection" +msgstr "선택" + +msgid "Session:" +msgstr "세션:" + +msgid "Set it with:" +msgstr "다음으로 설정하세요:" + msgid "Setting {name} to {value}" msgstr "{name}을(를) {value}(으)로 설정 중" +msgid "Shorter JSON example:" +msgstr "더 짧은 JSON 예:" + +msgid "Shorter form:" +msgstr "더 짧은 형식:" + +msgid "Show detailed help for a command" +msgstr "명령에 대한 자세한 도움말 표시" + +msgid "Show full tracebacks on errors instead of short messages." +msgstr "오류 시 짧은 메시지 대신 전체 트레이스백을 표시합니다." + +msgid "Show this help message" +msgstr "이 도움말 메시지 표시" + +msgid "Showing the compact table for {count} filtered matches. Narrow the filter or use --json for canonical target strings." +msgstr "필터된 일치 항목 {count}개에 대해 축약 표를 표시합니다. 필터를 더 좁히거나 정식 대상 문자열을 보려면 --json을 사용하세요." + +msgid "Shows available commands and usage information." +msgstr "사용 가능한 명령어 및 사용법을 표시합니다." + +msgid "Start an interactive moldflow shell session." +msgstr "대화형 moldflow 셸 세션을 시작합니다." + +msgid "Status" +msgstr "상태" + +msgid "Step '{step_name}' in target '{target}' has positional-only parameters ({parameters}), which are not supported by CLI named-argument routing. Use the Python API for this target." +msgstr "대상 '{target}'의 단계 '{step_name}'에는 위치 전용 매개변수({parameters})가 있으며, 이는 CLI 이름 기반 인수 라우팅에서 지원되지 않습니다. 이 대상에는 Python API를 사용하세요." + msgid "Submit" msgstr "확인" +msgid "Synergy session reset." +msgstr "Synergy 세션이 재설정되었습니다." + +msgid "TARGET is required unless --batch-file is used." +msgstr "--batch-file을 사용하지 않는 한 TARGET이 필요합니다." + +msgid "Tab completion targets are refreshed automatically." +msgstr "탭 완성 대상이 자동으로 새로 고침됩니다." + +msgid "Target" +msgstr "대상" + +msgid "Target '{target}' is hidden from the CLI because '{hidden_path}' only creates a transient {wrapper_type} wrapper. The CLI constructs these helper objects internally when needed, so they are not exposed as direct CLI targets." +msgstr "'{hidden_path}'는 일시적인 {wrapper_type} 래퍼만 생성하므로 대상 '{target}'은(는) CLI에서 숨겨집니다. CLI는 필요할 때 이러한 도우미 객체를 내부적으로 구성하므로 직접 CLI 대상으로 노출되지 않습니다." + +msgid "Target '{target}' is hidden from the CLI by library metadata on '{hidden_path}'." +msgstr "'{hidden_path}'의 라이브러리 메타데이터에 의해 대상 '{target}'은(는) CLI에서 숨겨집니다." + +msgid "Target '{target}' resolves to a property/attribute and does not accept arguments." +msgstr "대상 '{target}'은(는) 속성/어트리뷰트로 확인되며 인수를 받지 않습니다." + +msgid "Target '{target}' resolves to a {class_name} wrapper property. Continue to one of its members, for example 'describe {target}.'." +msgstr "대상 '{target}'은(는) {class_name} 래퍼 속성으로 확인됩니다. 예를 들어 'describe {target}.'처럼 해당 멤버 중 하나로 계속 진행하세요." + +msgid "Target '{target}' resolves to property '{property_name}' (getter) and does not accept arguments." +msgstr "대상 '{target}'은(는) 속성 '{property_name}'(getter)으로 확인되며 인수를 받지 않습니다." + +msgid "Target '{target}' resolves to write-only property '{property_name}' and cannot be read via invoke." +msgstr "대상 '{target}'은(는) 쓰기 전용 속성 '{property_name}'으로 확인되며 invoke로 읽을 수 없습니다." + +msgid "Target must include a class or function name" +msgstr "대상에는 클래스 또는 함수 이름이 포함되어야 합니다" + +msgid "Target must include at least one segment" +msgstr "대상에는 적어도 하나의 세그먼트가 포함되어야 합니다" + +msgid "Target must start with 'synergy' (or 'moldflow.synergy'). All invocations are rooted on the Synergy COM object." +msgstr "대상은 'synergy'(또는 'moldflow.synergy')로 시작해야 합니다. 모든 호출은 Synergy COM 객체를 루트로 합니다." + +msgid "Target must start with 'synergy' after the optional 'moldflow.' prefix. Bare targets such as 'open_project' are accepted and are interpreted as 'synergy.open_project'." +msgstr "선택적 'moldflow.' 접두사 뒤에서 대상은 'synergy'로 시작해야 합니다. 'open_project'와 같은 루트 없는 대상도 허용되며 'synergy.open_project'로 해석됩니다." + +msgid "Targets are shown without the leading 'synergy.' prefix. Describe and invoke accept either form." +msgstr "대상은 앞의 'synergy.' 접두사 없이 표시됩니다. describe와 invoke는 두 형식을 모두 허용합니다." + msgid "Test String" msgstr "테스트 문자열" +msgid "The Moldflow CLI requires optional dependencies. Install them with: pip install 'moldflow[cli]'" +msgstr "Moldflow CLI에는 선택적 종속성이 필요합니다. 다음으로 설치하세요: pip install 'moldflow[cli]'" + +msgid "The target returned False, which indicates a business-level failure." +msgstr "대상이 False를 반환했습니다. 이는 비즈니스 수준 실패를 의미합니다." + +msgid "This dry run validates a property assignment." +msgstr "이 드라이런은 속성 할당을 검증합니다." + +msgid "This parameter can be null to indicate no value." +msgstr "이 매개변수는 값이 없음을 나타내기 위해 null일 수 있습니다." + +msgid "This property is read-only and takes no arguments." +msgstr "이 속성은 읽기 전용이며 인수를 받지 않습니다." + +msgid "This property returns a {class_name} wrapper. Continue with describe {target}. or invoke {target}.." +msgstr "이 속성은 {class_name} 래퍼를 반환합니다. describe {target}. 또는 invoke {target}.로 계속 진행하세요." + +msgid "Tip: install pyreadline3 for tab completion support on Windows." +msgstr "팁: Windows에서 탭 완성을 사용하려면 pyreadline3을 설치하세요." + +msgid "Treat False return values as CLI failures (exit 1). This is enabled by default for automation-friendly behavior." +msgstr "False 반환값을 CLI 실패(exit 1)로 처리합니다. 자동화 친화적 동작을 위해 기본적으로 활성화되어 있습니다." + +msgid "Try this:" +msgstr "다음을 시도해 보세요:" + +msgid "Type" +msgstr "유형" + +msgid "Type 'help' for available commands, 'exit' to quit." +msgstr "사용 가능한 명령어는 'help', 종료하려면 'exit'를 입력하세요." + +msgid "Type help to see available commands." +msgstr "사용 가능한 명령어를 보려면 help를 입력하세요." + +msgid "Unknown batch item field(s): {fields}." +msgstr "알 수 없는 배치 항목 필드: {fields}." + +msgid "Unknown command:" +msgstr "알 수 없는 명령어:" + +msgid "Unknown parameter '{parameter}' for {target}{signature}.{extra}" +msgstr "{target}{signature}에 알 수 없는 매개변수 '{parameter}'이(가) 있습니다.{extra}" + +msgid "Use 'help ' for detailed help on a specific command." +msgstr "특정 명령어에 대한 자세한 도움말은 'help <명령어>'를 사용하세요." + +msgid "Use JSON field '{preferred_field}' for '{param_name}'." +msgstr "'{param_name}'에는 JSON 필드 '{preferred_field}'를 사용하세요." + +msgid "Use JSON field '{preferred_field}'." +msgstr "JSON 필드 '{preferred_field}'를 사용하세요." + +msgid "Use a comma-separated list for quick CLI input, or a JSON array string when values contain commas." +msgstr "빠른 CLI 입력에는 쉼표로 구분된 목록을 사용하고, 값에 쉼표가 포함되면 JSON 배열 문자열을 사용하세요." + +msgid "Use a comma-separated triplet for vector shorthand." +msgstr "벡터 축약형에는 쉼표로 구분된 3중값을 사용하세요." + +msgid "Use describe to inspect parameters, examples, and property behavior before invoking." +msgstr "invoke 전에 describe 을 사용해 매개변수, 예제, 속성 동작을 확인하세요." + +msgid "Use semicolon-separated triplets for quick CLI input. Quote the value in shells that treat semicolons specially." +msgstr "빠른 CLI 입력에는 세미콜론으로 구분된 3중값을 사용하세요. 세미콜론을 특별하게 처리하는 셸에서는 값을 인용하세요." + msgid "Using prompts will use pop-up import options and will always show logs" msgstr "프롬프트를 사용하면 팝업 가져오기 옵션이 사용되며 항상 로그가 표시됩니다" @@ -114,14 +777,32 @@ msgstr "유효한 입력" msgid "Valid Input Type" msgstr "유효한 입력 유형" +msgid "Vector" +msgstr "벡터" + +msgid "When JSON input is provided via --params-json or --params-json-file, no positional key=value args may be given." +msgstr "--params-json 또는 --params-json-file로 JSON 입력이 제공된 경우 위치 기반 key=value 인수를 사용할 수 없습니다." + +msgid "Write JSON output to the given file path without changing stdout mode." +msgstr "stdout 모드를 변경하지 않고 JSON 출력을 지정된 파일 경로에 씁니다." + +msgid "Wrote structured output to {path}." +msgstr "구조화된 출력을 {path}에 기록했습니다." + +msgid "You are already in the REPL." +msgstr "이미 REPL 안에 있습니다." + msgid "both {first} and {second} must be provided together" msgstr "{first}와 {second}는 함께 제공되어야 합니다" msgid "cannot be empty" msgstr "비워 둘 수 없습니다" +msgid "failed" +msgstr "실패" + msgid "found {min_value} must be less than {max_value}" -msgstr "찾은 {min_value}는 {max_value}보다 작아야 합니다" +msgstr "찾은 {min_value는 {max_value}보다 작아야 합니다" msgid "found {value}, must be between {min_value} and {max_value}" msgstr "찾은 {value}는 {min_value}와 {max_value} 사이여야 합니다" @@ -150,6 +831,18 @@ msgstr "찾은 {value}는 {expected_values} 중 하나여야 합니다" msgid "found {value}, must be positive" msgstr "찾은 {value}는 양수여야 합니다" +msgid "interactive shell" +msgstr "대화형 셸" + +msgid "ok" +msgstr "정상" + +msgid "settable" +msgstr "설정 가능" + +msgid "the owning object" +msgstr "소유 객체" + msgid "{file_name} does not have a valid file extension, will use {default}" msgstr "{file_name}에는 유효한 파일 확장자가 없습니다. {default}를 사용합니다" @@ -159,6 +852,21 @@ msgstr "{name}은(는) {value}입니다" msgid "{name} parameter will be ignored" msgstr "{name} 매개변수는 무시됩니다" +msgid "{param} receives the Plot returned by find_plot_by_name" +msgstr "{param}은(는) find_plot_by_name이 반환한 Plot을 받습니다" + +msgid "{type_name} ({size} items): {value}" +msgstr "{type_name} ({size}개 항목): {value}" + +msgid "{type_name} attributes:" +msgstr "{type_name} 속성:" + +msgid "{type_name} result:" +msgstr "{type_name} 결과:" + +msgid "{type_name} values ({count} items):" +msgstr "{type_name} 값 ({count}개 항목):" + msgid "{value} cannot be found documented in {enum_name}, this may cause function call to fail" msgstr "{value}는 {enum_name}에 문서화되어 있지 않습니다. 이로 인해 함수 호출이 실패할 수 있습니다" @@ -166,4 +874,4 @@ msgid "{value} does not have a valid file extension, must be {extensions}" msgstr "{value}에는 유효한 파일 확장자가 없습니다. {extensions}여야 합니다" msgid "{value} is not a valid {enum_name}" -msgstr "{value}는 유효한 {enum_name}이 아닙니다" +msgstr "{value}은(는) 유효한 {enum_name}이(가) 아닙니다" diff --git a/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po b/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po index 4a9f8ee..12c0ba5 100644 --- a/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po +++ b/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po @@ -3,9 +3,159 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: pt-PT\n" +msgid "\nDid you mean step '{step_name}'?" +msgstr "\nQuis dizer a etapa '{step_name}'?" + +msgid "\nFor JSON input on multi-step targets, group parameters by step name, e.g. {example}" +msgstr "\nPara entrada JSON em destinos com varias etapas, agrupe os parametros pelo nome da etapa, por exemplo {example}" + +msgid "\nFor JSON input, this step key must map to an object of parameter names, e.g. {example}" +msgstr "\nPara entrada JSON, esta chave de etapa tem de corresponder a um objeto de nomes de parametros, por exemplo {example}" + +msgid " Did you mean '{parameter}'?" +msgstr " Quis dizer '{parameter}'?" + +msgid " Known parameters: {known_params}." +msgstr " Parametros conhecidos: {known_params}." + +msgid "'{type_name}' no longer exposes adapter method '{method_name}'." +msgstr "'{type_name}' ja nao expoe o metodo adaptador '{method_name}'." + +msgid "--yaml requested but PyYAML is not installed: {error}" +msgstr "Foi solicitado --yaml, mas o PyYAML nao esta instalado: {error}" + +msgid "--yaml requested but PyYAML is not installed: {exc}" +msgstr "Foi solicitado --yaml, mas o PyYAML não está instalado: {exc}" + +msgid "Aborted." +msgstr "Cancelado." + +msgid "Advanced fallback only. Use this tagged shape when annotation context is unavailable, when a nested payload is truly generic, or when multiple wrapper families would be ambiguous." +msgstr "Apenas para fallback avancado. Use esta forma etiquetada quando o contexto de anotacao nao estiver disponivel, quando uma carga aninhada for realmente generica ou quando varias familias de wrappers forem ambiguas." + +msgid "Argument '{argument}' must specify a parameter name after the step (e.g., {step_name}.param=...).{extra}" +msgstr "O argumento '{argument}' tem de especificar um nome de parametro apos a etapa (por exemplo, {step_name}.param=...).{extra}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}" +msgstr "O argumento '{argument}' tem de comecar por um dos seguintes: {valid_steps}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "O argumento '{argument}' tem de comecar por um dos seguintes: {valid_steps}.{extra}" + +msgid "Argument '{step_name}' must start with one of: {names}" +msgstr "O argumento '{step_name}' deve começar por um dos seguintes: {names}" + +msgid "Argument error calling {target}{signature}: {error}" +msgstr "Erro de argumento ao chamar {target}{signature}: {error}" + +msgid "Arguments as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value. Duplicate or conflicting paths are rejected (for example param=1 with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "Argumentos como key=value ou param.attr=value. Para destinos encadeados, prefixe o parametro com o nome do metodo, por exemplo find_plot_by_name.plot_name=\"My Plot\". O encaminhamento aninhado usa step.param.attr=value. Caminhos duplicados ou em conflito sao rejeitados (por exemplo param=1 com param.attr=2), e os metodos com parametros apenas posicionais nao sao suportados pelo encaminhamento nomeado da CLI." + +msgid "Arguments for step '{step_name}' must be a JSON object of parameters." +msgstr "Os argumentos para a etapa '{step_name}' devem ser um objeto JSON de parâmetros." + +msgid "Array" +msgstr "Matriz" + +msgid "Attribute '{matched_name}' on class '{class_name}' returns a non-wrapper value{continuation}" +msgstr "O atributo '{matched_name}' na classe '{class_name}' devolve um valor que nao e wrapper{continuation}" + +msgid "Attribute '{matched_name}' on object '{type_name}' returns a non-wrapper value{continuation}" +msgstr "O atributo '{matched_name}' no objeto '{type_name}' devolve um valor que nao e wrapper{continuation}" + +msgid "Batch file must contain a JSON array of invoke call objects." +msgstr "O ficheiro de lote tem de conter um array JSON de objetos de chamada invoke." + +msgid "Batch item field 'args' must be a list of strings." +msgstr "O campo 'args' do item de lote tem de ser uma lista de strings." + +msgid "Batch item field 'params_json_file' must be a string path." +msgstr "O campo 'params_json_file' do item de lote tem de ser um caminho em formato string." + +msgid "Batch item must be a JSON object." +msgstr "O item de lote tem de ser um objeto JSON." + +msgid "Batch item requires string field 'target'." +msgstr "O item de lote requer o campo string 'target'." + +msgid "Batch item {index} error: {error}" +msgstr "Erro no item de lote {index}: {error}" + +msgid "Batch results" +msgstr "Resultados do lote" + +msgid "Batch summary: {succeeded}/{total} succeeded, {failed} failed." +msgstr "Resumo do lote: {succeeded}/{total} com exito, {failed} falhados." + +msgid "CLI argument: {value}" +msgstr "Argumento da CLI: {value}" + msgid "Cancel" msgstr "Cancelar" +msgid "Cannot assign property '{property_name}' while resolving target '{target}' because the owner object resolved to None." +msgstr "Nao e possivel atribuir a propriedade '{property_name}' ao resolver o destino '{target}', porque o objeto proprietario foi resolvido como None." + +msgid "Cannot build instance for type 'EntList'. No create_entity_list provider found." +msgstr "Nao e possivel construir uma instancia do tipo 'EntList'. Nao foi encontrado nenhum fornecedor create_entity_list." + +msgid "Cannot build instance for type '{type_name}'. Not a known Synergy property or factory." +msgstr "Nao e possivel construir uma instancia do tipo '{type_name}'. Nao e uma propriedade nem uma fabrica Synergy conhecida." + +msgid "Cannot configure field '{key_name}' on '{type_name}': {error}" +msgstr "Nao e possivel configurar o campo '{key_name}' em '{type_name}': {error}" + +msgid "Cannot invoke method '{segment}' for target '{target}' because '{owner}' is unavailable in the current session (it resolved to None). This target only works when that object exists." +msgstr "Nao e possivel invocar o metodo '{segment}' para o destino '{target}', porque '{owner}' nao esta disponivel na sessao atual (foi resolvido como None). Este destino so funciona quando esse objeto existe." + +msgid "Cannot read JSON file '{file}': {exc}" +msgstr "Não é possível ler o ficheiro JSON '{file}': {exc}" + +msgid "Cannot read JSON file '{path}': {error}" +msgstr "Nao e possivel ler o ficheiro JSON '{path}': {error}" + +msgid "Cannot read batch file '{path}': {error}" +msgstr "Nao e possivel ler o ficheiro de lote '{path}': {error}" + +msgid "Cannot resolve '{first}' on moldflow for introspection" +msgstr "Nao e possivel resolver '{first}' em moldflow para introspecao" + +msgid "Cannot resolve attribute '{segment}' on '{class_name}' when executing target '{target}': {error}" +msgstr "Nao e possivel resolver o atributo '{segment}' em '{class_name}' ao executar o destino '{target}': {error}" + +msgid "Cannot resolve attribute '{segment}' without an object instance when resolving target '{target}'" +msgstr "Nao e possivel resolver o atributo '{segment}' sem uma instancia de objeto ao resolver o destino '{target}'" + +msgid "Cannot resolve segment '{segment}' in target '{target}' without a class context. Use a Synergy-rooted target such as 'synergy.some_method'." +msgstr "Nao e possivel resolver o segmento '{segment}' no destino '{target}' sem um contexto de classe. Use um destino com raiz Synergy, como 'synergy.some_method'." + +msgid "Cannot set nested argument '{path}': {error}" +msgstr "Nao e possivel definir o argumento aninhado '{path}': {error}" + +msgid "Cannot set nested attributes for '{param_name}' without signature info on '{step_name}'." +msgstr "Nao e possivel definir atributos aninhados para '{param_name}' sem informacao de assinatura em '{step_name}'." + +msgid "Cannot set property '{property_name}' on target '{target}': {error}" +msgstr "Nao e possivel definir a propriedade '{property_name}' no destino '{target}': {error}" + +msgid "Cannot write JSON file '{path}': {error}" +msgstr "Nao e possivel escrever o ficheiro JSON '{path}': {error}" + +msgid "Canonical JSON field is derived from the reflected wrapper method signature for {method_name}()." +msgstr "O campo JSON canonico e derivado da assinatura refletida do metodo wrapper para {method_name}()." + +msgid "Canonical list field is derived from the reflected wrapper method {method_name}()." +msgstr "O campo canonico da lista e derivado do metodo wrapper refletido {method_name}()." + +msgid "Canonical triplet field is derived from the reflected wrapper method {method_name}()." +msgstr "O campo canonico do triplo e derivado do metodo wrapper refletido {method_name}()." + +msgid "Canonical vector-array field is derived from the reflected wrapper method {method_name}()." +msgstr "O campo canonico do array de vetores e derivado do metodo wrapper refletido {method_name}()." + +msgid "Chained targets with repeated method names are ambiguous for argument routing: {duplicate_names}. Please use an equivalent target path where each invoked step name is unique." +msgstr "Os destinos encadeados com nomes de metodo repetidos sao ambiguos para o encaminhamento de argumentos: {duplicate_names}. Use um caminho de destino equivalente em que cada nome de etapa invocada seja unico." + msgid "Checking file extension {file_name}" msgstr "A verificar a extensão do ficheiro {file_name}" @@ -42,24 +192,207 @@ msgstr "A verificar se {value} é positivo" msgid "Checking {value} is {name}" msgstr "A verificar se {value} é {name}" +msgid "Class '{class_name}' has no attribute '{attr_name}'" +msgstr "A classe '{class_name}' nao tem o atributo '{attr_name}'" + +msgid "Clear the screen" +msgstr "Limpar o ecrã" + +msgid "Clears the terminal screen and redraws the banner." +msgstr "Limpa o ecrã do terminal e redesenha o banner." + +msgid "Close Synergy and reset the session" +msgstr "Fechar o Synergy e repor a sessão" + +msgid "Closes Synergy and resets the session for a fresh start." +msgstr "Fecha o Synergy e repõe a sessão para recomeçar do zero." + +msgid "Command exited with status {code}" +msgstr "O comando terminou com o estado {code}" + +msgid "Commands:" +msgstr "Comandos:" + +msgid "Conflicting argument paths '{left_path}' and '{right_path}' are not allowed." +msgstr "Os caminhos de argumentos em conflito '{left_path}' e '{right_path}' nao sao permitidos." + msgid "Could not initialize with Instance ID: {value}" msgstr "Não foi possível iniciar com o ID de instância: {value}" +msgid "Ctrl+D also exits." +msgstr "Ctrl+D também sai." + +msgid "Detail" +msgstr "Detalhe" + +msgid "Direct parameter assignment remains the preferred non-JSON form." +msgstr "A atribuicao direta de parametros continua a ser a forma preferida fora de JSON." + +msgid "Disable ANSI color/styling in CLI output." +msgstr "Desativar cor/estilo ANSI na saída da CLI." + +msgid "Discover invokable targets and the next command to run for each one." +msgstr "Descubra os alvos invocáveis e o próximo comando a executar para cada um." + +msgid "Do not pass TARGET when --batch-file is used." +msgstr "Nao passe TARGET quando --batch-file for usado." + +msgid "Do not pass positional args/JSON input with --batch-file." +msgstr "Nao passe argumentos posicionais/entrada JSON com --batch-file." + +msgid "Dotted path to a method or function, optionally chained, for example 'synergy.new_project' or 'synergy.plot_manager.find_plot_by_name'." +msgstr "Caminho pontuado para um metodo ou funcao, opcionalmente encadeado, por exemplo 'synergy.new_project' ou 'synergy.plot_manager.find_plot_by_name'." + +msgid "Dotted path, e.g., synergy.new_project" +msgstr "Caminho pontuado, por exemplo synergy.new_project" + +msgid "Dry run for {target}" +msgstr "Simulacao para {target}" + +msgid "Duplicate argument path '{path}' is not allowed." +msgstr "O caminho de argumento duplicado '{path}' nao e permitido." + +msgid "Duplicate/conflicting paths are rejected. Arguments are passed as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value (for example param=1 conflicts with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "Caminhos duplicados ou conflituosos são rejeitados. Os argumentos são passados como key=value ou param.attr=value. Para destinos encadeados, prefixe o parâmetro com o nome do método, por exemplo find_plot_by_name.plot_name=\"My Plot\". O encaminhamento aninhado usa step.param.attr=value (por exemplo, param=1 entra em conflito com param.attr=2), e métodos com parâmetros posicionais não são suportados pelo encaminhamento CLI nomeado." + +msgid "Emit a JSON schema-like representation of target parameters." +msgstr "Emitir uma representacao semelhante a um esquema JSON dos parametros do destino." + +msgid "Emit line-delimited JSON trace events to stderr for target resolution and runtime invoke binding." +msgstr "Emitir eventos de rastreio JSON delimitados por linha para stderr durante a resolucao do destino e a associacao invoke em tempo de execucao." + +msgid "Emit structured JSON for scripting or agent use." +msgstr "Emite JSON estruturado para scripts ou utilização por agentes." + +msgid "Emit structured YAML for scripting or agent use (requires PyYAML)." +msgstr "Emite YAML estruturado para scripts ou utilização por agentes (requer PyYAML)." + +msgid "Emit the structured result as JSON to stdout (useful for automation/LLMs)." +msgstr "Emitir o resultado estruturado como JSON para stdout (util para automacao/LLMs)." + +msgid "Empty target" +msgstr "Destino vazio" + +msgid "Empty type tag is not valid for '{primary_expected}'." +msgstr "A etiqueta de tipo vazia nao e valida para '{primary_expected}'." + +msgid "Error:" +msgstr "Erro:" + +msgid "Example object shape: {shape}." +msgstr "Exemplo de forma do objeto: {shape}." + msgid "Executing {name}" msgstr "Executando {name}" +msgid "Exit the REPL" +msgstr "Sair do REPL" + +msgid "Exits the REPL." +msgstr "Sai do REPL." + +msgid "Explicit field form: {value}" +msgstr "Forma explicita do campo: {value}" + msgid "Failed to initialize Synergy: Synergy not found" msgstr "Falha ao iniciar o Synergy: Synergy não encontrado" +msgid "Failed to render JSON output for {context}: {error}" +msgstr "Falha ao apresentar a saida JSON para {context}: {error}" + +msgid "Failed to render JSON output for {context}: {exc}" +msgstr "Falha ao gerar a saída JSON para {cont?mxt}: {exc}" + +msgid "Failed to render YAML output for {context}: {error}" +msgstr "Falha ao apresentar a saida YAML para {context}: {error}" + +msgid "Failed to render YAML output for {context}: {exc}" +msgstr "Falha ao gerar a saída YAML para {cont?mxt}: {exc}" + +msgid "Failed to reset session:" +msgstr "Falha ao repor a sessão:" + +msgid "Field '{key_name}' is not valid for '{type_name}'." +msgstr "O campo '{key_name}' nao e valido para '{type_name}'." + +msgid "Field '{key}' for '{type_name}' must be a 3-item numeric sequence like [0, 0, 1]." +msgstr "O campo '{key}' para '{type_name}' tem de ser uma sequencia numerica de 3 elementos, como [0, 0, 1]." + +msgid "Field '{key}' for '{type_name}' must be a JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "O campo '{key}' para '{type_name}' tem de ser um array JSON de triplos ou uma lista separada por ponto e virgula, como '0,0,0;1,0,0'." + +msgid "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list." +msgstr "O campo '{key}' para '{type_name}' tem de ser um array JSON ou uma lista separada por virgulas." + +msgid "Field '{key}' for '{type_name}' must be a comma-separated numeric triplet like '0,0,1'." +msgstr "O campo '{key}' para '{type_name}' tem de ser um triplo numerico separado por virgulas, como '0,0,1'." + +msgid "Field '{key}' for '{type_name}' must be a list of numeric triplets." +msgstr "O campo '{key}' para '{type_name}' tem de ser uma lista de triplos numericos." + +msgid "Field '{key}' for '{type_name}' must be a string selection expression. Expected field: {preferred_field}." +msgstr "O campo '{key}' para '{type_name}' tem de ser uma expressao de selecao em formato string. Campo esperado: {preferred_field}." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "O campo '{key}' para '{type_name}' tem de ser um array JSON valido de triplos ou uma lista separada por ponto e virgula, como '0,0,0;1,0,0'." + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array or a comma-separated list." +msgstr "O campo '{key}' para '{type_name}' tem de ser um array JSON valido ou uma lista separada por virgulas." + +msgid "Field '{key}' for '{type_name}' must contain integer values." +msgstr "O campo '{key}' para '{type_name}' tem de conter valores inteiros." + +msgid "Field '{key}' for '{type_name}' must contain numeric values." +msgstr "O campo '{key}' para '{type_name}' tem de conter valores numericos." + +msgid "Fields '{previous_key}' and '{key}' both map to the same input for '{type_name}'. Provide only one of: {preferred_field} or direct parameter shorthand." +msgstr "Os campos '{previous_key}' e '{key}' mapeiam ambos para a mesma entrada de '{type_name}'. Forneca apenas um de: {preferred_field} ou a forma abreviada direta do parametro." + +msgid "Filter by substring or wildcard pattern (* and ?)." +msgstr "Filtrar por subcadeia ou padrao com wildcard (* e ?)." + +msgid "Filter by substring or wildcard pattern (* and ?). Repeat to keep targets matching any filter." +msgstr "Filtra por subcadeia ou padrão com curingas (* e ?). Repita a opção para manter os alvos que correspondam a qualquer um dos filtros." + +msgid "Filtered matches:" +msgstr "Correspondencias filtradas:" + +msgid "For JSON input, group parameters by step name. Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "Para entrada JSON, agrupe os parametros pelo nome da etapa. O argumento '{argument}' tem de comecar por um dos seguintes: {valid_steps}.{extra}" + +msgid "For multi-step targets, group params-json fields by step name." +msgstr "Para destinos com varias etapas, agrupe os campos params-json pelo nome da etapa." + msgid "Getting {name}" msgstr "Obtendo {name}" msgid "Getting {name} at index {value}" msgstr "Obtendo {name} no índice {value}" +msgid "Goodbye!" +msgstr "Adeus!" + +msgid "If shorthand input is ambiguous, switch to --params-json. {guidance}" +msgstr "Se a entrada abreviada for ambigua, mude para --params-json. {guidance}" + +msgid "In non-JSON mode, prefer direct shorthand like '{preferred_non_json}'." +msgstr "No modo nao JSON, prefira a forma abreviada direta, como '{preferred_non_json}'." + +msgid "Index" +msgstr "Indice" + msgid "Initializing {name}" msgstr "Inicializando {name}" +msgid "Input hints:" +msgstr "Sugestoes de entrada:" + +msgid "Inspect a target's signature, docs, examples, and structured invoke template." +msgstr "Inspecione a assinatura, a documentação, os exemplos e o modelo estruturado de invoke de um alvo." + +msgid "Interrupted." +msgstr "Interrompido." + msgid "Invalid Attribute: {attribute} is not supported" msgstr "Atributo inválido: {attribute} não é suportado" @@ -69,17 +402,191 @@ msgstr "Tipo de ficheiro inválido: {file_name}, deve ser {extensions}" msgid "Invalid Index: out of range" msgstr "Índice inválido: fora do intervalo" +msgid "Invalid JSON payload for parameters: {error}" +msgstr "Carga JSON invalida para os parametros: {error}" + +msgid "Invalid JSON payload for parameters: {exc}" +msgstr "Payload JSON inválido para parâmetros: {exc}" + +msgid "Invalid JSON value for parameter '{param_name}': {error}" +msgstr "Valor JSON invalido para o parametro '{param_name}': {error}" + msgid "Invalid Type: must be {expected_types}, not {variable_type}" msgstr "Tipo inválido: deve ser {expected_types}, não {variable_type}" msgid "Invalid Value: {reason}" msgstr "Valor inválido: {reason}" +msgid "Invalid argument '{item}'. Expected key=value or param.attr=value." +msgstr "Argumento inválido '{item}'. Esperado key=value ou param.attr=value." + +msgid "Invalid argument for step '{step_name}': missing parameter name." +msgstr "Argumento invalido para a etapa '{step_name}': falta o nome do parametro." + +msgid "Invalid nested argument path '{path}': attribute '{attr}' does not exist on '{obj_type}'." +msgstr "Caminho de argumento aninhado invalido '{path}': o atributo '{attr}' nao existe em '{obj_type}'." + +msgid "Invalid nested argument path '{path}': cannot nest into non-object '{obj_type}'." +msgstr "Caminho de argumento aninhado invalido '{path}': nao e possivel aninhar num nao-objeto '{obj_type}'." + +msgid "Invalid nested argument path '{path}': cannot set '{final_attr}' on non-object '{obj_type}'." +msgstr "Caminho de argumento aninhado invalido '{path}': nao e possivel definir '{final_attr}' num nao-objeto '{obj_type}'." + +msgid "Invalid value for parameter '{param_name}': {error}" +msgstr "Valor invalido para o parametro '{param_name}': {error}" + +msgid "Invalid {field_name} '{path_text}': empty path segment is not allowed." +msgstr "{field_name} inválido '{path_text}': segmento de caminho vazio não é permitido." + +msgid "Invalid {field_name} '{path_text}': segment '{segment}' must be a valid identifier." +msgstr "{field_name} '{path_text}' invalido: o segmento '{segment}' tem de ser um identificador valido." + +msgid "Invalid {field_name}: value cannot be empty." +msgstr "{field_name} inválido: o valor não pode estar vazio." + +msgid "JSON example:" +msgstr "Exemplo JSON:" + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays and scalars are not allowed." +msgstr "Objeto JSON que contem os mapeamentos de parametros (substitui argumentos posicionais). Nao sao permitidos arrays nem escalares no nivel superior." + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays/scalars are not allowed." +msgstr "Objeto JSON que contem os mapeamentos de parametros (substitui argumentos posicionais). Nao sao permitidos arrays nem escalares no nivel superior." + +msgid "JSON parameters must be a JSON object of named arguments. Example: --params-json '{\"param\": 1}' or --params-json '{\"step\": {\"param\": 1}}' for chained targets." +msgstr "Os parâmetros JSON devem ser um objeto JSON de argumentos nomeados. Exemplo: --params-json '{\"param\": 1}' ou --params-json '{\"step\": {\"param\": 1}}' para destinos encadeados." + +msgid "JSON type tag '{type_tag}' does not match expected wrapper '{primary_expected}'." +msgstr "A etiqueta de tipo JSON '{type_tag}' nao corresponde ao wrapper esperado '{primary_expected}'." + +msgid "JSON value:" +msgstr "Valor JSON:" + +msgid "List result:" +msgstr "Resultado da lista:" + msgid "Logger was not setup" msgstr "Logger não foi configurado" +msgid "Missing required parameter '{parameter}' for {target}{signature}" +msgstr "Falta o parametro obrigatorio '{parameter}' para {target}{signature}" + +msgid "Moldflow command-line interface.\n\nStart with 'list' to discover targets, 'describe ' to inspect usage, then 'invoke ...' to run it." +msgstr "Interface de linha de comando do Moldflow.\n\nComece com 'list' para descobrir alvos, use 'describe ' para inspecionar a utilização e depois execute 'invoke ...'." + +msgid "Moldflow invokable targets" +msgstr "Destinos invocaveis do Moldflow" + +msgid "Nested argument '{path}' is not supported for **kwargs on step '{step_name}'. Use a single key (e.g., {example}=...)." +msgstr "O argumento aninhado '{path}' nao e suportado para **kwargs na etapa '{step_name}'. Use uma unica chave (por exemplo, {example}=...)." + +msgid "No invokable targets matched these filters." +msgstr "Nenhum destino invocavel correspondeu a estes filtros." + +msgid "No invokable targets matched this filter." +msgstr "Nenhum destino invocavel correspondeu a este filtro." + +msgid "Non-public argument path '{key}' is not allowed." +msgstr "O caminho de argumento não público '{key}' não é permitido." + +msgid "Non-public argument path '{left}' is not allowed." +msgstr "O caminho de argumento não público '{left}' não é permitido." + +msgid "Non-public argument path '{path}' is not allowed." +msgstr "O caminho de argumento nao publico '{path}' nao e permitido." + +msgid "Non-public argument path '{step_name}' is not allowed." +msgstr "O caminho de argumento não público '{step_name}' não é permitido." + +msgid "Non-public argument path '{step}.{key}' is not allowed." +msgstr "O caminho de argumento não público '{step}.{key}' não é permitido." + +msgid "Non-public field '{key_name}' is not allowed when constructing '{type_name}' from JSON." +msgstr "O campo nao publico '{key_name}' nao e permitido ao construir '{type_name}' a partir de JSON." + +msgid "Non-public segment '{segment}' is not allowed in target '{target}'." +msgstr "O segmento nao publico '{segment}' nao e permitido no destino '{target}'." + +msgid "Non-public segment '{seg}' is not allowed in target '{target}'." +msgstr "O segmento não público '{seg}' não é permitido no destino '{target}'." + msgid "OK" -msgstr "OK" +msgstr "Sucesso" + +msgid "Object" +msgstr "Objeto" + +msgid "Object '{type_name}' has no attribute '{attr_name}'" +msgstr "O objeto '{type_name}' nao tem o atributo '{attr_name}'" + +msgid "One or more dotted targets, for example synergy.new_project." +msgstr "Um ou mais alvos em notação pontuada, por exemplo synergy.new_project." + +msgid "Only one of --json or --yaml may be specified." +msgstr "Só pode ser especificado um de --json ou --yaml." + +msgid "Only one of --json, --yaml, or --schema may be specified." +msgstr "Só pode ser especificado um de --json, --yaml ou --schema." + +msgid "Only one of --params-json or --params-json-file may be specified." +msgstr "Só pode ser especificado um de --params-json ou --params-json-file." + +msgid "Parameter '{param_name}' contains a null byte which is not allowed." +msgstr "O parâmetro '{param_name}' contém um byte nulo, o que não é permitido." + +msgid "Parameter '{param_name}' contains control characters (newline/tab/carriage return); please provide a single-line value or quote/escape as needed." +msgstr "O parâmetro '{param_name}' contém caracteres de controlo (newline/tab/carriage return); forneça um valor numa só linha ou ?tilize aspas/escape conforme necessário." + +msgid "Parse error:" +msgstr "Erro de análise:" + +msgid "Parse/validate/build kwargs and emit a call plan without executing invoke steps." +msgstr "Analisar/validar/construir kwargs e emitir um plano de chamada sem executar etapas invoke." + +msgid "Parse/validate/build kwargs and emit a template summary call plan without executing invoke steps." +msgstr "Analisar/validar/construir kwargs e emitir um plano de chamada de resumo do modelo sem executar etapas invoke." + +msgid "Path to a JSON file containing an array of invoke calls for batch execution." +msgstr "Caminho para um ficheiro JSON que contem um array de chamadas invoke para execucao em lote." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object, not arrays/scalars." +msgstr "Caminho para um ficheiro JSON que contem os mapeamentos de parametros (substitui argumentos posicionais). A carga no nivel superior tem de ser um objeto." + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object." +msgstr "Caminho para um ficheiro JSON que contem os mapeamentos de parametros (substitui argumentos posicionais). A carga no nivel superior tem de ser um objeto." + +msgid "Planned steps:" +msgstr "Etapas planeadas:" + +msgid "Prefer chaining invoke targets so this parameter is produced by a previous step, instead of constructing it manually in JSON." +msgstr "Prefira encadear destinos invoke para que este parametro seja produzido por uma etapa anterior, em vez de o construir manualmente em JSON." + +msgid "Print the installed moldflow package version." +msgstr "Imprimir a versao instalada do pacote moldflow." + +msgid "Property assignment JSON must be an object with a single 'value' field." +msgstr "O JSON de atribuicao de propriedade tem de ser um objeto com um unico campo 'value'." + +msgid "Property assignment requires exactly one 'value' argument (e.g., value=... or --params-json '{\"value\": ...}')." +msgstr "A atribuicao de propriedade requer exatamente um argumento 'value' (por exemplo, value=... ou --params-json '{\"value\": ...}')." + +msgid "Property {name} (id={id}, type={prop_type})" +msgstr "Propriedade {name} (id={id}, tipo={prop_type})" + +msgid "Read current value:" +msgstr "Valor atual:" + +msgid "Resolved assignment:" +msgstr "Atribuicao resolvida:" + +msgid "Resolved kwargs:" +msgstr "kwargs resolvidos:" + +msgid "Resolved object has no callable attribute '{segment}' when executing target '{target}'" +msgstr "O objeto resolvido nao tem nenhum atributo invocavel '{segment}' ao executar o destino '{target}'" + +msgid "Run a Moldflow target with named parameters or JSON input. Bare targets are treated as synergy.." +msgstr "Execute um alvo do Moldflow com parâmetros nomeados ou entrada JSON. Alvos sem prefixo são tratados como synergy.." msgid "Save Error" msgstr "Erro ao guardar" @@ -90,15 +597,171 @@ msgstr "Erro ao guardar: não foi possível guardar {saving} em {file_name}" msgid "Save Error: Failed to save {saving} to {file_name}" msgstr "Erro ao guardar: falha ao guardar {saving} em {file_name}" +msgid "Segment '{segment}' does not resolve as an attribute on class '{class_name}' when resolving target '{target}'" +msgstr "O segmento '{segment}' nao e resolvido como atributo na classe '{class_name}' ao resolver o destino '{target}'" + +msgid "Segment '{segment}' is not a callable method on class '{class_name}' when resolving target '{target}'" +msgstr "O segmento '{segment}' nao e um metodo invocavel na classe '{class_name}' ao resolver o destino '{target}'" + +msgid "Selection" +msgstr "Selecao" + +msgid "Session:" +msgstr "Sessão:" + +msgid "Set it with:" +msgstr "Defina com:" + msgid "Setting {name} to {value}" msgstr "Configuração {name} para {value}" +msgid "Shorter JSON example:" +msgstr "Exemplo JSON mais curto:" + +msgid "Shorter form:" +msgstr "Forma mais curta:" + +msgid "Show detailed help for a command" +msgstr "Mostrar ajuda detalhada para um comando" + +msgid "Show full tracebacks on errors instead of short messages." +msgstr "Mostrar tracebacks completos em caso de erro em vez de mensagens curtas." + +msgid "Show this help message" +msgstr "Mostrar esta mensagem de ajuda" + +msgid "Showing the compact table for {count} filtered matches. Narrow the filter or use --json for canonical target strings." +msgstr "A mostrar a tabela compacta para {count} correspondencias filtradas. Restrinja o filtro ou use --json para obter cadeias de destino canonicas." + +msgid "Shows available commands and usage information." +msgstr "Mostra os comandos disponíveis e informações de utilização." + +msgid "Start an interactive moldflow shell session." +msgstr "Iniciar uma sessão interativa do shell moldflow." + +msgid "Status" +msgstr "Estado" + +msgid "Step '{step_name}' in target '{target}' has positional-only parameters ({parameters}), which are not supported by CLI named-argument routing. Use the Python API for this target." +msgstr "A etapa '{step_name}' no destino '{target}' tem parametros apenas posicionais ({parameters}), que nao sao suportados pelo encaminhamento de argumentos nomeados da CLI. Use a API Python para este destino." + msgid "Submit" msgstr "Submeter" +msgid "Synergy session reset." +msgstr "Sessão Synergy reposta." + +msgid "TARGET is required unless --batch-file is used." +msgstr "TARGET e obrigatorio, exceto se --batch-file for usado." + +msgid "Tab completion targets are refreshed automatically." +msgstr "Os alvos de conclusão por tabulação são atualizados automaticamente." + +msgid "Target" +msgstr "Destino" + +msgid "Target '{target}' is hidden from the CLI because '{hidden_path}' only creates a transient {wrapper_type} wrapper. The CLI constructs these helper objects internally when needed, so they are not exposed as direct CLI targets." +msgstr "O destino '{target}' esta oculto na CLI porque '{hidden_path}' apenas cria um wrapper transitorio {wrapper_type}. A CLI constroi estes objetos auxiliares internamente quando necessario, pelo que nao sao expostos como destinos diretos da CLI." + +msgid "Target '{target}' is hidden from the CLI by library metadata on '{hidden_path}'." +msgstr "O destino '{target}' esta oculto na CLI pelos metadados da biblioteca em '{hidden_path}'." + +msgid "Target '{target}' resolves to a property/attribute and does not accept arguments." +msgstr "O destino '{target}' e resolvido para uma propriedade/atributo e nao aceita argumentos." + +msgid "Target '{target}' resolves to a {class_name} wrapper property. Continue to one of its members, for example 'describe {target}.'." +msgstr "O destino '{target}' e resolvido para uma propriedade wrapper {class_name}. Continue para um dos seus membros, por exemplo 'describe {target}.'." + +msgid "Target '{target}' resolves to property '{property_name}' (getter) and does not accept arguments." +msgstr "O destino '{target}' e resolvido para a propriedade '{property_name}' (getter) e nao aceita argumentos." + +msgid "Target '{target}' resolves to write-only property '{property_name}' and cannot be read via invoke." +msgstr "O destino '{target}' e resolvido para a propriedade apenas de escrita '{property_name}' e nao pode ser lido via invoke." + +msgid "Target must include a class or function name" +msgstr "O destino tem de incluir um nome de classe ou de funcao" + +msgid "Target must include at least one segment" +msgstr "O destino deve incluir pelo menos um segmento" + +msgid "Target must start with 'synergy' (or 'moldflow.synergy'). All invocations are rooted on the Synergy COM object." +msgstr "O destino deve começar por 'synergy' (ou 'moldflow.synergy'). Todas as invocações têm origem no objeto COM Synergy." + +msgid "Target must start with 'synergy' after the optional 'moldflow.' prefix. Bare targets such as 'open_project' are accepted and are interpreted as 'synergy.open_project'." +msgstr "O destino tem de comecar por 'synergy' depois do prefixo opcional 'moldflow.'. Destinos sem prefixo, como 'open_project', sao aceites e interpretados como 'synergy.open_project'." + +msgid "Targets are shown without the leading 'synergy.' prefix. Describe and invoke accept either form." +msgstr "Os destinos sao mostrados sem o prefixo inicial 'synergy.'. Describe e invoke aceitam qualquer uma das formas." + msgid "Test String" msgstr "String de teste" +msgid "The Moldflow CLI requires optional dependencies. Install them with: pip install 'moldflow[cli]'" +msgstr "A CLI do Moldflow requer dependencias opcionais. Instale-as com: pip install 'moldflow[cli]'" + +msgid "The target returned False, which indicates a business-level failure." +msgstr "O destino devolveu False, o que indica uma falha ao nivel da logica de negocio." + +msgid "This dry run validates a property assignment." +msgstr "Esta simulacao valida uma atribuicao de propriedade." + +msgid "This parameter can be null to indicate no value." +msgstr "Este parametro pode ser null para indicar ausencia de valor." + +msgid "This property is read-only and takes no arguments." +msgstr "Esta propriedade e apenas de leitura e nao aceita argumentos." + +msgid "This property returns a {class_name} wrapper. Continue with describe {target}. or invoke {target}.." +msgstr "Esta propriedade devolve um wrapper {class_name}. Continue com describe {target}. ou invoke {target}.." + +msgid "Tip: install pyreadline3 for tab completion support on Windows." +msgstr "Dica: instale pyreadline3 para conclusão por tabulação no Windows." + +msgid "Treat False return values as CLI failures (exit 1). This is enabled by default for automation-friendly behavior." +msgstr "Trate valores de retorno False como falhas da CLI (saida 1). Isto esta ativado por omissao para um comportamento amigavel para automacao." + +msgid "Try this:" +msgstr "Experimente isto:" + +msgid "Type" +msgstr "Tipo" + +msgid "Type 'help' for available commands, 'exit' to quit." +msgstr "Digite 'help' para os comandos disponíveis, 'exit' para sair." + +msgid "Type help to see available commands." +msgstr "Digite help para ver os comandos disponíveis." + +msgid "Unknown batch item field(s): {fields}." +msgstr "Campo(s) desconhecido(s) no item de lote: {fields}." + +msgid "Unknown command:" +msgstr "Comando desconhecido:" + +msgid "Unknown parameter '{parameter}' for {target}{signature}.{extra}" +msgstr "Parametro desconhecido '{parameter}' para {target}{signature}.{extra}" + +msgid "Use 'help ' for detailed help on a specific command." +msgstr "Use 'help ' para ajuda detalhada sobre um comando específico." + +msgid "Use JSON field '{preferred_field}' for '{param_name}'." +msgstr "Use o campo JSON '{preferred_field}' para '{param_name}'." + +msgid "Use JSON field '{preferred_field}'." +msgstr "Use o campo JSON '{preferred_field}'." + +msgid "Use a comma-separated list for quick CLI input, or a JSON array string when values contain commas." +msgstr "Use uma lista separada por virgulas para entrada rapida na CLI, ou uma string de array JSON quando os valores contiverem virgulas." + +msgid "Use a comma-separated triplet for vector shorthand." +msgstr "Use um triplo separado por virgulas para a forma abreviada de vetor." + +msgid "Use describe to inspect parameters, examples, and property behavior before invoking." +msgstr "Use describe para inspecionar parametros, exemplos e o comportamento da propriedade antes de invocar." + +msgid "Use semicolon-separated triplets for quick CLI input. Quote the value in shells that treat semicolons specially." +msgstr "Use triplos separados por ponto e virgula para entrada rapida na CLI. Coloque o valor entre aspas em shells que tratem pontos e virgulas de forma especial." + msgid "Using prompts will use pop-up import options and will always show logs" msgstr "Ao usar avisos, serão usadas opções de importação em pop‑up e os registos serão sempre apresentados" @@ -114,12 +777,30 @@ msgstr "Entrada válida" msgid "Valid Input Type" msgstr "Tipo de entrada válido" +msgid "Vector" +msgstr "Vetor" + +msgid "When JSON input is provided via --params-json or --params-json-file, no positional key=value args may be given." +msgstr "Quando a entrada JSON é fornecida via --params-json ou --params-json-file, não podem ser passados argumentos posicionais key=value." + +msgid "Write JSON output to the given file path without changing stdout mode." +msgstr "Escreva a saida JSON no caminho de ficheiro indicado sem alterar o modo stdout." + +msgid "Wrote structured output to {path}." +msgstr "A saida estruturada foi escrita em {path}." + +msgid "You are already in the REPL." +msgstr "Já se encontra no REPL." + msgid "both {first} and {second} must be provided together" msgstr "{first} e {second} devem ser fornecidos em conjunto" msgid "cannot be empty" msgstr "não pode estar vazio" +msgid "failed" +msgstr "falhou" + msgid "found {min_value} must be less than {max_value}" msgstr "encontrado {min_value}, deve ser menor que {max_value}" @@ -142,7 +823,7 @@ msgid "found {value}, must be non-negative" msgstr "encontrado {value}, deve ser não negativo" msgid "found {value}, must be non-zero" -msgstr "encontrado {value}, deve ser diferente de zero" +msgstr "encontrado {value}, deve ser distinto de zero" msgid "found {value}, must be one of {expected_values}" msgstr "encontrado {value}, deve ser um de {expected_values}" @@ -150,6 +831,18 @@ msgstr "encontrado {value}, deve ser um de {expected_values}" msgid "found {value}, must be positive" msgstr "encontrado {value}, deve ser positivo" +msgid "interactive shell" +msgstr "shell interativo" + +msgid "ok" +msgstr "sucesso" + +msgid "settable" +msgstr "configuravel" + +msgid "the owning object" +msgstr "o objeto proprietario" + msgid "{file_name} does not have a valid file extension, will use {default}" msgstr "{file_name} não tem uma extensão válida; será usado {default}" @@ -159,6 +852,21 @@ msgstr "{name} é {value}" msgid "{name} parameter will be ignored" msgstr "O parâmetro {name} será ignorado" +msgid "{param} receives the Plot returned by find_plot_by_name" +msgstr "{param} recebe o Plot devolvido por find_plot_by_name" + +msgid "{type_name} ({size} items): {value}" +msgstr "{type_name} ({size} itens): {value}" + +msgid "{type_name} attributes:" +msgstr "Atributos de {type_name}:" + +msgid "{type_name} result:" +msgstr "Resultado de {type_name}:" + +msgid "{type_name} values ({count} items):" +msgstr "Valores de {type_name} ({count} itens):" + msgid "{value} cannot be found documented in {enum_name}, this may cause function call to fail" msgstr "O valor {value} não se encontra documentado em {enum_name}; isto pode fazer falhar a chamada da função" @@ -166,4 +874,4 @@ msgid "{value} does not have a valid file extension, must be {extensions}" msgstr "O valor {value} não tem uma extensão válida; deve ser {extensions}" msgid "{value} is not a valid {enum_name}" -msgstr "{value} não é um {enum_name} válido" +msgstr "{value} nao e um {enum_name} valido" diff --git a/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po b/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po index 1a68283..745e7d3 100644 --- a/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po +++ b/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po @@ -3,9 +3,159 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: zh-CN\n" +msgid "\nDid you mean step '{step_name}'?" +msgstr "\n您是指步骤 '{step_name}' 吗?" + +msgid "\nFor JSON input on multi-step targets, group parameters by step name, e.g. {example}" +msgstr "\n对于多步骤目标的 JSON 输入,请按步骤名称对参数进行分组,例如 {example}" + +msgid "\nFor JSON input, this step key must map to an object of parameter names, e.g. {example}" +msgstr "\n对于 JSON 输入,此步骤键必须映射到参数名称对象,例如 {example}" + +msgid " Did you mean '{parameter}'?" +msgstr " 您是指 '{parameter}' 吗?" + +msgid " Known parameters: {known_params}." +msgstr " 已知参数:{known_params}。" + +msgid "'{type_name}' no longer exposes adapter method '{method_name}'." +msgstr "'{type_name}' 不再公开适配器方法 '{method_name}'。" + +msgid "--yaml requested but PyYAML is not installed: {error}" +msgstr "已请求 --yaml,但未安装 PyYAML:{error}" + +msgid "--yaml requested but PyYAML is not installed: {exc}" +msgstr "请求了 --yaml,但未安装 PyYAML:{exc}" + +msgid "Aborted." +msgstr "已中止。" + +msgid "Advanced fallback only. Use this tagged shape when annotation context is unavailable, when a nested payload is truly generic, or when multiple wrapper families would be ambiguous." +msgstr "仅供高级回退使用。当注解上下文不可用、嵌套负载确实是通用结构,或多个包装器族会产生歧义时,请使用此带标签的形状。" + +msgid "Argument '{argument}' must specify a parameter name after the step (e.g., {step_name}.param=...).{extra}" +msgstr "参数 '{argument}' 必须在步骤名后指定参数名称(例如 {step_name}.param=...)。{extra}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}" +msgstr "参数 '{argument}' 必须以下列之一开头:{valid_steps}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "参数 '{argument}' 必须以下列之一开头:{valid_steps}。{extra}" + +msgid "Argument '{step_name}' must start with one of: {names}" +msgstr "参数 '{step_name}' 必须以下列之一开头:{names}" + +msgid "Argument error calling {target}{signature}: {error}" +msgstr "调用 {target}{signature} 时参数错误:{error}" + +msgid "Arguments as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value. Duplicate or conflicting paths are rejected (for example param=1 with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "参数可写为 key=value 或 param.attr=value。对于链式目标,请在参数前加上方法名,例如 find_plot_by_name.plot_name=\"My Plot\"。嵌套路由使用 step.param.attr=value。重复或冲突的路径会被拒绝(例如同时使用 param=1 和 param.attr=2),并且带仅限位置参数的方法不支持命名式 CLI 路由。" + +msgid "Arguments for step '{step_name}' must be a JSON object of parameters." +msgstr "步骤 '{step_name}' 的参数必须是一个 JSON 格式的参数对象。" + +msgid "Array" +msgstr "数组" + +msgid "Attribute '{matched_name}' on class '{class_name}' returns a non-wrapper value{continuation}" +msgstr "类 '{class_name}' 上的属性 '{matched_name}' 返回了非包装器值{continuation}" + +msgid "Attribute '{matched_name}' on object '{type_name}' returns a non-wrapper value{continuation}" +msgstr "对象 '{type_name}' 上的属性 '{matched_name}' 返回了非包装器值{continuation}" + +msgid "Batch file must contain a JSON array of invoke call objects." +msgstr "批处理文件必须包含由 invoke 调用对象组成的 JSON 数组。" + +msgid "Batch item field 'args' must be a list of strings." +msgstr "批处理项字段 'args' 必须是字符串列表。" + +msgid "Batch item field 'params_json_file' must be a string path." +msgstr "批处理项字段 'params_json_file' 必须是字符串路径。" + +msgid "Batch item must be a JSON object." +msgstr "批处理项必须是 JSON 对象。" + +msgid "Batch item requires string field 'target'." +msgstr "批处理项需要字符串字段 'target'。" + +msgid "Batch item {index} error: {error}" +msgstr "批处理项 {index} 错误:{error}" + +msgid "Batch results" +msgstr "批处理结果" + +msgid "Batch summary: {succeeded}/{total} succeeded, {failed} failed." +msgstr "批处理摘要:共 {total} 项,成功 {succeeded} 项,失败 {failed} 项。" + +msgid "CLI argument: {value}" +msgstr "CLI 参数:{value}" + msgid "Cancel" msgstr "取消" +msgid "Cannot assign property '{property_name}' while resolving target '{target}' because the owner object resolved to None." +msgstr "解析目标 '{target}' 时无法赋值属性 '{property_name}',因为所属对象解析结果为 None。" + +msgid "Cannot build instance for type 'EntList'. No create_entity_list provider found." +msgstr "无法为类型 'EntList' 构建实例。未找到 create_entity_list 提供程序。" + +msgid "Cannot build instance for type '{type_name}'. Not a known Synergy property or factory." +msgstr "无法为类型 '{type_name}' 构建实例。它不是已知的 Synergy 属性或工厂。" + +msgid "Cannot configure field '{key_name}' on '{type_name}': {error}" +msgstr "无法在 '{type_name}' 上配置字段 '{key_name}':{error}" + +msgid "Cannot invoke method '{segment}' for target '{target}' because '{owner}' is unavailable in the current session (it resolved to None). This target only works when that object exists." +msgstr "无法为目标 '{target}' 调用方法 '{segment}',因为 '{owner}' 在当前会话中不可用(其解析结果为 None)。此目标仅在该对象存在时有效。" + +msgid "Cannot read JSON file '{file}': {exc}" +msgstr "无法读取 JSON 文件 '{file}':{exc}" + +msgid "Cannot read JSON file '{path}': {error}" +msgstr "无法读取 JSON 文件 '{path}':{error}" + +msgid "Cannot read batch file '{path}': {error}" +msgstr "无法读取批处理文件 '{path}':{error}" + +msgid "Cannot resolve '{first}' on moldflow for introspection" +msgstr "无法在 moldflow 上解析 '{first}' 以进行内省。" + +msgid "Cannot resolve attribute '{segment}' on '{class_name}' when executing target '{target}': {error}" +msgstr "执行目标 '{target}' 时,无法解析 '{class_name}' 上的属性 '{segment}':{error}" + +msgid "Cannot resolve attribute '{segment}' without an object instance when resolving target '{target}'" +msgstr "解析目标 '{target}' 时,没有对象实例就无法解析属性 '{segment}'。" + +msgid "Cannot resolve segment '{segment}' in target '{target}' without a class context. Use a Synergy-rooted target such as 'synergy.some_method'." +msgstr "在没有类上下文的情况下,无法解析目标 '{target}' 中的段 '{segment}'。请使用以 Synergy 为根的目标,例如 'synergy.some_method'。" + +msgid "Cannot set nested argument '{path}': {error}" +msgstr "无法设置嵌套参数 '{path}':{error}" + +msgid "Cannot set nested attributes for '{param_name}' without signature info on '{step_name}'." +msgstr "如果 '{step_name}' 上没有签名信息,就无法为 '{param_name}' 设置嵌套属性。" + +msgid "Cannot set property '{property_name}' on target '{target}': {error}" +msgstr "无法在目标 '{target}' 上设置属性 '{property_name}':{error}" + +msgid "Cannot write JSON file '{path}': {error}" +msgstr "无法写入 JSON 文件 '{path}':{error}" + +msgid "Canonical JSON field is derived from the reflected wrapper method signature for {method_name}()." +msgstr "规范 JSON 字段来自反射得到的包装器方法 {method_name}() 的签名。" + +msgid "Canonical list field is derived from the reflected wrapper method {method_name}()." +msgstr "规范列表字段来自反射得到的包装器方法 {method_name}()。" + +msgid "Canonical triplet field is derived from the reflected wrapper method {method_name}()." +msgstr "规范三元组字段来自反射得到的包装器方法 {method_name}()。" + +msgid "Canonical vector-array field is derived from the reflected wrapper method {method_name}()." +msgstr "规范向量数组字段来自反射得到的包装器方法 {method_name}()。" + +msgid "Chained targets with repeated method names are ambiguous for argument routing: {duplicate_names}. Please use an equivalent target path where each invoked step name is unique." +msgstr "链式目标中重复的方法名会导致参数路由产生歧义:{duplicate_names}。请使用等效且每个调用步骤名都唯一的目标路径。" + msgid "Checking file extension {file_name}" msgstr "正在检查文件扩展名{file_name}" @@ -42,24 +192,207 @@ msgstr "正在检查{value}是否为正数" msgid "Checking {value} is {name}" msgstr "检查{value}是{name}" +msgid "Class '{class_name}' has no attribute '{attr_name}'" +msgstr "类 '{class_name}' 没有属性 '{attr_name}'。" + +msgid "Clear the screen" +msgstr "清除屏幕" + +msgid "Clears the terminal screen and redraws the banner." +msgstr "清除终端屏幕并重新绘制横幅。" + +msgid "Close Synergy and reset the session" +msgstr "关闭 Synergy 并重置会话" + +msgid "Closes Synergy and resets the session for a fresh start." +msgstr "关闭 Synergy 并重置会话,以便重新开始。" + +msgid "Command exited with status {code}" +msgstr "命令以状态 {code} 退出" + +msgid "Commands:" +msgstr "命令:" + +msgid "Conflicting argument paths '{left_path}' and '{right_path}' are not allowed." +msgstr "不允许冲突的参数路径 '{left_path}' 和 '{right_path}'。" + msgid "Could not initialize with Instance ID: {value}" msgstr "无法使用实例ID: {value} 进行初始化" +msgid "Ctrl+D also exits." +msgstr "Ctrl+D 也可以退出。" + +msgid "Detail" +msgstr "详情" + +msgid "Direct parameter assignment remains the preferred non-JSON form." +msgstr "直接参数赋值仍然是首选的非 JSON 形式。" + +msgid "Disable ANSI color/styling in CLI output." +msgstr "禁用 CLI 输出中的 ANSI 颜色/样式。" + +msgid "Discover invokable targets and the next command to run for each one." +msgstr "发现可调用的目标以及每个目标下一步要运行的命令。" + +msgid "Do not pass TARGET when --batch-file is used." +msgstr "使用 --batch-file 时,请勿传入 TARGET。" + +msgid "Do not pass positional args/JSON input with --batch-file." +msgstr "使用 --batch-file 时,请勿传入位置参数或 JSON 输入。" + +msgid "Dotted path to a method or function, optionally chained, for example 'synergy.new_project' or 'synergy.plot_manager.find_plot_by_name'." +msgstr "指向方法或函数的点分路径,可选支持链式调用,例如 'synergy.new_project' 或 'synergy.plot_manager.find_plot_by_name'。" + +msgid "Dotted path, e.g., synergy.new_project" +msgstr "点分路径,例如 synergy.new_project" + +msgid "Dry run for {target}" +msgstr "{target} 的试运行" + +msgid "Duplicate argument path '{path}' is not allowed." +msgstr "不允许重复的参数路径 '{path}'。" + +msgid "Duplicate/conflicting paths are rejected. Arguments are passed as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value (for example param=1 conflicts with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "拒绝重复/冲突路径。参数以 key=value 或 param.attr=value 形式传递。对于链式目标,请在参数前加上方法名,例如 find_plot_by_name.plot_name=\"My Plot\"。嵌套路由使用 step.param.attr=value(例如 param=1 与 param.attr=2 冲突),不支持仅含位置参数的方法的命名 CLI 路由。" + +msgid "Emit a JSON schema-like representation of target parameters." +msgstr "输出目标参数的类 JSON Schema 表示。" + +msgid "Emit line-delimited JSON trace events to stderr for target resolution and runtime invoke binding." +msgstr "将用于目标解析和运行时 invoke 绑定的逐行 JSON 跟踪事件输出到 stderr。" + +msgid "Emit structured JSON for scripting or agent use." +msgstr "输出供脚本或代理使用的结构化 JSON。" + +msgid "Emit structured YAML for scripting or agent use (requires PyYAML)." +msgstr "输出供脚本或代理使用的结构化 YAML(需要 PyYAML)。" + +msgid "Emit the structured result as JSON to stdout (useful for automation/LLMs)." +msgstr "将结构化结果以 JSON 形式输出到 stdout(适用于自动化/LLM)。" + +msgid "Empty target" +msgstr "目标为空" + +msgid "Empty type tag is not valid for '{primary_expected}'." +msgstr "空类型标签对 '{primary_expected}' 无效。" + +msgid "Error:" +msgstr "错误:" + +msgid "Example object shape: {shape}." +msgstr "对象示例形状:{shape}。" + msgid "Executing {name}" msgstr "执行{name}" +msgid "Exit the REPL" +msgstr "退出 REPL" + +msgid "Exits the REPL." +msgstr "退出 REPL。" + +msgid "Explicit field form: {value}" +msgstr "显式字段形式:{value}" + msgid "Failed to initialize Synergy: Synergy not found" msgstr "初始化 Synergy 失败:未找到 Synergy" +msgid "Failed to render JSON output for {context}: {error}" +msgstr "无法为 {context} 生成 JSON 输出:{error}" + +msgid "Failed to render JSON output for {context}: {exc}" +msgstr "无法为 {context} 生成 JSON 输出:{exc}" + +msgid "Failed to render YAML output for {context}: {error}" +msgstr "无法为 {context} 生成 YAML 输出:{error}" + +msgid "Failed to render YAML output for {context}: {exc}" +msgstr "无法为 {context} 生成 YAML 输出:{exc}" + +msgid "Failed to reset session:" +msgstr "重置会话失败:" + +msgid "Field '{key_name}' is not valid for '{type_name}'." +msgstr "字段 '{key_name}' 对 '{type_name}' 无效。" + +msgid "Field '{key}' for '{type_name}' must be a 3-item numeric sequence like [0, 0, 1]." +msgstr "'{type_name}' 的字段 '{key}' 必须是类似 [0, 0, 1] 的 3 项数字序列。" + +msgid "Field '{key}' for '{type_name}' must be a JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "'{type_name}' 的字段 '{key}' 必须是三元组的 JSON 数组,或类似 '0,0,0;1,0,0' 的分号分隔列表。" + +msgid "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list." +msgstr "'{type_name}' 的字段 '{key}' 必须是 JSON 数组或逗号分隔列表。" + +msgid "Field '{key}' for '{type_name}' must be a comma-separated numeric triplet like '0,0,1'." +msgstr "'{type_name}' 的字段 '{key}' 必须是类似 '0,0,1' 的逗号分隔数字三元组。" + +msgid "Field '{key}' for '{type_name}' must be a list of numeric triplets." +msgstr "'{type_name}' 的字段 '{key}' 必须是数字三元组列表。" + +msgid "Field '{key}' for '{type_name}' must be a string selection expression. Expected field: {preferred_field}." +msgstr "'{type_name}' 的字段 '{key}' 必须是字符串选择表达式。期望字段:{preferred_field}。" + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "'{type_name}' 的字段 '{key}' 必须是有效的三元组 JSON 数组,或类似 '0,0,0;1,0,0' 的分号分隔列表。" + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array or a comma-separated list." +msgstr "'{type_name}' 的字段 '{key}' 必须是有效的 JSON 数组或逗号分隔列表。" + +msgid "Field '{key}' for '{type_name}' must contain integer values." +msgstr "'{type_name}' 的字段 '{key}' 必须包含整数值。" + +msgid "Field '{key}' for '{type_name}' must contain numeric values." +msgstr "'{type_name}' 的字段 '{key}' 必须包含数值。" + +msgid "Fields '{previous_key}' and '{key}' both map to the same input for '{type_name}'. Provide only one of: {preferred_field} or direct parameter shorthand." +msgstr "字段 '{previous_key}' 和 '{key}' 都映射到 '{type_name}' 的同一输入。请只提供以下之一:{preferred_field} 或直接参数简写。" + +msgid "Filter by substring or wildcard pattern (* and ?)." +msgstr "按子串或通配符模式(* 和 ?)过滤。" + +msgid "Filter by substring or wildcard pattern (* and ?). Repeat to keep targets matching any filter." +msgstr "按子串或通配符模式(* 和 ?)过滤。重复此选项可保留匹配任一过滤条件的目标。" + +msgid "Filtered matches:" +msgstr "过滤后的匹配项:" + +msgid "For JSON input, group parameters by step name. Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "对于 JSON 输入,请按步骤名称对参数分组。参数 '{argument}' 必须以下列之一开头:{valid_steps}。{extra}" + +msgid "For multi-step targets, group params-json fields by step name." +msgstr "对于多步骤目标,请按步骤名称对 params-json 字段分组。" + msgid "Getting {name}" msgstr "获取{name}" msgid "Getting {name} at index {value}" msgstr "在索引{value}获取{name}" +msgid "Goodbye!" +msgstr "再见!" + +msgid "If shorthand input is ambiguous, switch to --params-json. {guidance}" +msgstr "如果简写输入有歧义,请改用 --params-json。{guidance}" + +msgid "In non-JSON mode, prefer direct shorthand like '{preferred_non_json}'." +msgstr "在非 JSON 模式下,优先使用类似 '{preferred_non_json}' 的直接简写。" + +msgid "Index" +msgstr "索引" + msgid "Initializing {name}" msgstr "初始化{name}" +msgid "Input hints:" +msgstr "输入提示:" + +msgid "Inspect a target's signature, docs, examples, and structured invoke template." +msgstr "检查目标的签名、文档、示例以及结构化 invoke 模板。" + +msgid "Interrupted." +msgstr "已中断。" + msgid "Invalid Attribute: {attribute} is not supported" msgstr "无效属性:不支持{attribute}" @@ -69,18 +402,192 @@ msgstr "无效文件类型:{file_name},必须为{extensions}" msgid "Invalid Index: out of range" msgstr "无效索引:超出范围" +msgid "Invalid JSON payload for parameters: {error}" +msgstr "参数的 JSON 负载无效:{error}" + +msgid "Invalid JSON payload for parameters: {exc}" +msgstr "参数的 JSON 有效载荷无效:{exc}" + +msgid "Invalid JSON value for parameter '{param_name}': {error}" +msgstr "参数 '{param_name}' 的 JSON 值无效:{error}" + msgid "Invalid Type: must be {expected_types}, not {variable_type}" msgstr "无效类型:必须为{expected_types},而不是{variable_type}" msgid "Invalid Value: {reason}" msgstr "无效的值:{reason}" +msgid "Invalid argument '{item}'. Expected key=value or param.attr=value." +msgstr "无效参数 '{item}'。应为 key=value 或 param.attr=value。" + +msgid "Invalid argument for step '{step_name}': missing parameter name." +msgstr "步骤 '{step_name}' 的参数无效:缺少参数名称。" + +msgid "Invalid nested argument path '{path}': attribute '{attr}' does not exist on '{obj_type}'." +msgstr "无效的嵌套参数路径 '{path}':'{obj_type}' 上不存在属性 '{attr}'。" + +msgid "Invalid nested argument path '{path}': cannot nest into non-object '{obj_type}'." +msgstr "无效的嵌套参数路径 '{path}':无法嵌套到非对象 '{obj_type}' 中。" + +msgid "Invalid nested argument path '{path}': cannot set '{final_attr}' on non-object '{obj_type}'." +msgstr "无效的嵌套参数路径 '{path}':无法在非对象 '{obj_type}' 上设置 '{final_attr}'。" + +msgid "Invalid value for parameter '{param_name}': {error}" +msgstr "参数 '{param_name}' 的值无效:{error}" + +msgid "Invalid {field_name} '{path_text}': empty path segment is not allowed." +msgstr "无效的 {field_name} '{path_text}':不允许空的路径段。" + +msgid "Invalid {field_name} '{path_text}': segment '{segment}' must be a valid identifier." +msgstr "无效的 {field_name} '{path_text}':段 '{segment}' 必须是有效标识符。" + +msgid "Invalid {field_name}: value cannot be empty." +msgstr "无效的 {field_name}:值不能为空。" + +msgid "JSON example:" +msgstr "JSON 示例:" + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays and scalars are not allowed." +msgstr "包含参数映射的 JSON 对象(会覆盖位置参数)。不允许顶层数组和标量。" + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays/scalars are not allowed." +msgstr "包含参数映射的 JSON 对象(会覆盖位置参数)。不允许顶层数组和标量。" + +msgid "JSON parameters must be a JSON object of named arguments. Example: --params-json '{\"param\": 1}' or --params-json '{\"step\": {\"param\": 1}}' for chained targets." +msgstr "JSON 参数必须是命名参数的 JSON 对象。例如:--params-json '{\"param\": 1}' 或对于链式目标:--params-json '{\"step\": {\"param\": 1}}'" + +msgid "JSON type tag '{type_tag}' does not match expected wrapper '{primary_expected}'." +msgstr "JSON 类型标签 '{type_tag}' 与预期的包装器 '{primary_expected}' 不匹配。" + +msgid "JSON value:" +msgstr "JSON 值:" + +msgid "List result:" +msgstr "列表结果:" + msgid "Logger was not setup" msgstr "没有设置记录器" +msgid "Missing required parameter '{parameter}' for {target}{signature}" +msgstr "缺少 {target}{signature} 所需的参数 '{parameter}'" + +msgid "Moldflow command-line interface.\n\nStart with 'list' to discover targets, 'describe ' to inspect usage, then 'invoke ...' to run it." +msgstr "Moldflow 命令行界面。\n\n先用 'list' 查找目标,再用 'describe ' 查看用法,然后运行 'invoke ...'。" + +msgid "Moldflow invokable targets" +msgstr "Moldflow 可调用目标" + +msgid "Nested argument '{path}' is not supported for **kwargs on step '{step_name}'. Use a single key (e.g., {example}=...)." +msgstr "步骤 '{step_name}' 的 **kwargs 不支持嵌套参数 '{path}'。请使用单个键(例如 {example}=...)。" + +msgid "No invokable targets matched these filters." +msgstr "没有可调用目标匹配这些过滤条件。" + +msgid "No invokable targets matched this filter." +msgstr "没有可调用目标匹配此过滤条件。" + +msgid "Non-public argument path '{key}' is not allowed." +msgstr "不允许使用非公开的参数路径 '{key}'。" + +msgid "Non-public argument path '{left}' is not allowed." +msgstr "不允许使用非公开的参数路径 '{left}'。" + +msgid "Non-public argument path '{path}' is not allowed." +msgstr "不允许使用非公开参数路径 '{path}'。" + +msgid "Non-public argument path '{step_name}' is not allowed." +msgstr "不允许使用非公开的参数路径 '{step_name}'。" + +msgid "Non-public argument path '{step}.{key}' is not allowed." +msgstr "不允许使用非公开的参数路径 '{step}.{key}'。" + +msgid "Non-public field '{key_name}' is not allowed when constructing '{type_name}' from JSON." +msgstr "从 JSON 构造 '{type_name}' 时,不允许使用非公开字段 '{key_name}'。" + +msgid "Non-public segment '{segment}' is not allowed in target '{target}'." +msgstr "目标 '{target}' 中不允许使用非公开段 '{segment}'。" + +msgid "Non-public segment '{seg}' is not allowed in target '{target}'." +msgstr "目标 '{target}' 中不允许出现非公开段 '{seg}'。" + msgid "OK" msgstr "确定" +msgid "Object" +msgstr "对象" + +msgid "Object '{type_name}' has no attribute '{attr_name}'" +msgstr "对象 '{type_name}' 没有属性 '{attr_name}'。" + +msgid "One or more dotted targets, for example synergy.new_project." +msgstr "一个或多个点分目标,例如 synergy.new_project。" + +msgid "Only one of --json or --yaml may be specified." +msgstr "只能指定 --json 或 --yaml 其一。" + +msgid "Only one of --json, --yaml, or --schema may be specified." +msgstr "只能指定 --json、--yaml 或 --schema 中的一个。" + +msgid "Only one of --params-json or --params-json-file may be specified." +msgstr "只能指定 --params-json 或 --params-json-file 其一。" + +msgid "Parameter '{param_name}' contains a null byte which is not allowed." +msgstr "参数 '{param_name}' 包含不允许的空字节。" + +msgid "Parameter '{param_name}' contains control characters (newline/tab/carriage return); please provide a single-line value or quote/escape as needed." +msgstr "参数 '{param_name}' 包含控制字符(换行/制表/回车);请提供单行值或根据需要引用/转义。" + +msgid "Parse error:" +msgstr "解析错误:" + +msgid "Parse/validate/build kwargs and emit a call plan without executing invoke steps." +msgstr "解析/验证/构建 kwargs,并在不执行 invoke 步骤的情况下输出调用计划。" + +msgid "Parse/validate/build kwargs and emit a template summary call plan without executing invoke steps." +msgstr "解析/验证/构建 kwargs,并在不执行 invoke 步骤的情况下输出模板摘要调用计划。" + +msgid "Path to a JSON file containing an array of invoke calls for batch execution." +msgstr "包含 invoke 调用数组、用于批量执行的 JSON 文件路径。" + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object, not arrays/scalars." +msgstr "包含参数映射(覆盖位置参数)的 JSON 文件路径。顶层负载必须是对象。" + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object." +msgstr "包含参数映射(覆盖位置参数)的 JSON 文件路径。顶层负载必须是对象。" + +msgid "Planned steps:" +msgstr "计划步骤:" + +msgid "Prefer chaining invoke targets so this parameter is produced by a previous step, instead of constructing it manually in JSON." +msgstr "优先链式调用 invoke 目标,使该参数由前一步生成,而不是在 JSON 中手动构造。" + +msgid "Print the installed moldflow package version." +msgstr "打印已安装的 moldflow 包版本。" + +msgid "Property assignment JSON must be an object with a single 'value' field." +msgstr "属性赋值 JSON 必须是仅包含一个 'value' 字段的对象。" + +msgid "Property assignment requires exactly one 'value' argument (e.g., value=... or --params-json '{\"value\": ...}')." +msgstr "属性赋值必须且只能提供一个 'value' 参数(例如 value=... 或 --params-json '{\"value\": ...}')。" + +msgid "Property {name} (id={id}, type={prop_type})" +msgstr "属性 {name}(id={id},类型={prop_type})" + +msgid "Read current value:" +msgstr "读取当前值:" + +msgid "Resolved assignment:" +msgstr "已解析的赋值:" + +msgid "Resolved kwargs:" +msgstr "已解析的 kwargs:" + +msgid "Resolved object has no callable attribute '{segment}' when executing target '{target}'" +msgstr "执行目标 '{target}' 时,已解析对象没有可调用属性 '{segment}'。" + +msgid "Run a Moldflow target with named parameters or JSON input. Bare targets are treated as synergy.." +msgstr "使用命名参数或 JSON 输入运行 Moldflow 目标。未加前缀的目标会按 synergy. 处理。" + msgid "Save Error" msgstr "保存错误" @@ -90,15 +597,171 @@ msgstr "保存错误:无法将{saving}保存到{file_name}" msgid "Save Error: Failed to save {saving} to {file_name}" msgstr "保存错误:将{saving}保存到{file_name}失败" +msgid "Segment '{segment}' does not resolve as an attribute on class '{class_name}' when resolving target '{target}'" +msgstr "解析目标 '{target}' 时,段 '{segment}' 无法解析为类 '{class_name}' 上的属性。" + +msgid "Segment '{segment}' is not a callable method on class '{class_name}' when resolving target '{target}'" +msgstr "解析目标 '{target}' 时,段 '{segment}' 不是类 '{class_name}' 上的可调用方法。" + +msgid "Selection" +msgstr "选择" + +msgid "Session:" +msgstr "会话:" + +msgid "Set it with:" +msgstr "可使用以下方式设置:" + msgid "Setting {name} to {value}" msgstr "将{name}设置为{value}" +msgid "Shorter JSON example:" +msgstr "更简短的 JSON 示例:" + +msgid "Shorter form:" +msgstr "更简短的形式:" + +msgid "Show detailed help for a command" +msgstr "显示命令的详细帮助" + +msgid "Show full tracebacks on errors instead of short messages." +msgstr "出错时显示完整的回溯信息,而不是简短的错误消息。" + +msgid "Show this help message" +msgstr "显示此帮助信息" + +msgid "Showing the compact table for {count} filtered matches. Narrow the filter or use --json for canonical target strings." +msgstr "正在显示 {count} 个过滤匹配项的紧凑表格。请缩小过滤范围,或使用 --json 查看规范目标字符串。" + +msgid "Shows available commands and usage information." +msgstr "显示可用命令和使用信息。" + +msgid "Start an interactive moldflow shell session." +msgstr "启动一个交互式 moldflow shell 会话。" + +msgid "Status" +msgstr "状态" + +msgid "Step '{step_name}' in target '{target}' has positional-only parameters ({parameters}), which are not supported by CLI named-argument routing. Use the Python API for this target." +msgstr "目标 '{target}' 中的步骤 '{step_name}' 包含仅限位置参数 ({parameters}),CLI 命名参数路由不支持此类参数。请对此目标使用 Python API。" + msgid "Submit" msgstr "确定" +msgid "Synergy session reset." +msgstr "Synergy 会话已重置。" + +msgid "TARGET is required unless --batch-file is used." +msgstr "除非使用 --batch-file,否则 TARGET 为必填项。" + +msgid "Tab completion targets are refreshed automatically." +msgstr "Tab 补全目标会自动刷新。" + +msgid "Target" +msgstr "目标" + +msgid "Target '{target}' is hidden from the CLI because '{hidden_path}' only creates a transient {wrapper_type} wrapper. The CLI constructs these helper objects internally when needed, so they are not exposed as direct CLI targets." +msgstr "目标 '{target}' 在 CLI 中被隐藏,因为 '{hidden_path}' 只会创建一个瞬态 {wrapper_type} 包装器。CLI 会在需要时在内部构造这些辅助对象,因此不会将它们公开为直接的 CLI 目标。" + +msgid "Target '{target}' is hidden from the CLI by library metadata on '{hidden_path}'." +msgstr "目标 '{target}' 因 '{hidden_path}' 上的库元数据而在 CLI 中被隐藏。" + +msgid "Target '{target}' resolves to a property/attribute and does not accept arguments." +msgstr "目标 '{target}' 解析为属性,不接受参数。" + +msgid "Target '{target}' resolves to a {class_name} wrapper property. Continue to one of its members, for example 'describe {target}.'." +msgstr "目标 '{target}' 解析为 {class_name} 包装器属性。请继续访问其某个成员,例如 'describe {target}.'。" + +msgid "Target '{target}' resolves to property '{property_name}' (getter) and does not accept arguments." +msgstr "目标 '{target}' 解析为属性 '{property_name}'(getter),不接受参数。" + +msgid "Target '{target}' resolves to write-only property '{property_name}' and cannot be read via invoke." +msgstr "目标 '{target}' 解析为只写属性 '{property_name}',无法通过 invoke 读取。" + +msgid "Target must include a class or function name" +msgstr "目标必须包含类名或函数名" + +msgid "Target must include at least one segment" +msgstr "目标必须包含至少一个段" + +msgid "Target must start with 'synergy' (or 'moldflow.synergy'). All invocations are rooted on the Synergy COM object." +msgstr "目标必须以 'synergy'(或 'moldflow.synergy')开头。所有调用都基于 Synergy COM 对象。" + +msgid "Target must start with 'synergy' after the optional 'moldflow.' prefix. Bare targets such as 'open_project' are accepted and are interpreted as 'synergy.open_project'." +msgstr "目标在可选的 'moldflow.' 前缀之后必须以 'synergy' 开头。接受像 'open_project' 这样的裸目标,并会将其解释为 'synergy.open_project'。" + +msgid "Targets are shown without the leading 'synergy.' prefix. Describe and invoke accept either form." +msgstr "显示目标时不带前导 'synergy.' 前缀。Describe 和 invoke 两种形式都接受。" + msgid "Test String" msgstr "测试字符串" +msgid "The Moldflow CLI requires optional dependencies. Install them with: pip install 'moldflow[cli]'" +msgstr "Moldflow CLI 需要可选依赖。请使用以下命令安装:pip install 'moldflow[cli]'" + +msgid "The target returned False, which indicates a business-level failure." +msgstr "目标返回了 False,这表示业务层面的失败。" + +msgid "This dry run validates a property assignment." +msgstr "此试运行会验证一次属性赋值。" + +msgid "This parameter can be null to indicate no value." +msgstr "此参数可以为 null,以表示无值。" + +msgid "This property is read-only and takes no arguments." +msgstr "此属性为只读,不接受参数。" + +msgid "This property returns a {class_name} wrapper. Continue with describe {target}. or invoke {target}.." +msgstr "此属性返回一个 {class_name} 包装器。请继续使用 describe {target}. 或 invoke {target}.。" + +msgid "Tip: install pyreadline3 for tab completion support on Windows." +msgstr "提示:在 Windows 上安装 pyreadline3 以获得 Tab 补全支持。" + +msgid "Treat False return values as CLI failures (exit 1). This is enabled by default for automation-friendly behavior." +msgstr "将返回值 False 视为 CLI 失败(退出码 1)。默认启用此行为以便于自动化。" + +msgid "Try this:" +msgstr "试试这个:" + +msgid "Type" +msgstr "类型" + +msgid "Type 'help' for available commands, 'exit' to quit." +msgstr "输入 'help' 查看可用命令,输入 'exit' 退出。" + +msgid "Type help to see available commands." +msgstr "输入 help 查看可用命令。" + +msgid "Unknown batch item field(s): {fields}." +msgstr "未知的批处理项字段:{fields}。" + +msgid "Unknown command:" +msgstr "未知命令:" + +msgid "Unknown parameter '{parameter}' for {target}{signature}.{extra}" +msgstr "目标 {target}{signature} 的参数 '{parameter}' 未知。{extra}" + +msgid "Use 'help ' for detailed help on a specific command." +msgstr "使用 'help <命令>' 查看特定命令的详细帮助。" + +msgid "Use JSON field '{preferred_field}' for '{param_name}'." +msgstr "请为 '{param_name}' 使用 JSON 字段 '{preferred_field}'。" + +msgid "Use JSON field '{preferred_field}'." +msgstr "请使用 JSON 字段 '{preferred_field}'。" + +msgid "Use a comma-separated list for quick CLI input, or a JSON array string when values contain commas." +msgstr "对于快速 CLI 输入,请使用逗号分隔列表;当值本身包含逗号时,请使用 JSON 数组字符串。" + +msgid "Use a comma-separated triplet for vector shorthand." +msgstr "向量简写请使用逗号分隔的三元组。" + +msgid "Use describe to inspect parameters, examples, and property behavior before invoking." +msgstr "调用前请使用 describe 检查参数、示例和属性行为。" + +msgid "Use semicolon-separated triplets for quick CLI input. Quote the value in shells that treat semicolons specially." +msgstr "对于快速 CLI 输入,请使用分号分隔的三元组。对于会特殊处理分号的 shell,请为该值加引号。" + msgid "Using prompts will use pop-up import options and will always show logs" msgstr "使用提示将使用弹出式导入选项,并且始终显示日志" @@ -114,12 +777,30 @@ msgstr "有效输入" msgid "Valid Input Type" msgstr "有效的输入类型" +msgid "Vector" +msgstr "向量" + +msgid "When JSON input is provided via --params-json or --params-json-file, no positional key=value args may be given." +msgstr "当通过 --params-json 或 --params-json-file 提供 JSON 输入时,不允许提供位置参数 key=value。" + +msgid "Write JSON output to the given file path without changing stdout mode." +msgstr "在不更改 stdout 模式的情况下,将 JSON 输出写入给定文件路径。" + +msgid "Wrote structured output to {path}." +msgstr "已将结构化输出写入 {path}。" + +msgid "You are already in the REPL." +msgstr "您已在 REPL 中。" + msgid "both {first} and {second} must be provided together" msgstr "{first} 和 {second} 必须一起提供" msgid "cannot be empty" msgstr "不能为空" +msgid "failed" +msgstr "失败" + msgid "found {min_value} must be less than {max_value}" msgstr "找到{min_value},必须小于{max_value}" @@ -150,6 +831,18 @@ msgstr "找到{value},必须是{expected_values}之一" msgid "found {value}, must be positive" msgstr "找到{value},必须是积极的" +msgid "interactive shell" +msgstr "交互式 shell" + +msgid "ok" +msgstr "成功" + +msgid "settable" +msgstr "可设置" + +msgid "the owning object" +msgstr "所属对象" + msgid "{file_name} does not have a valid file extension, will use {default}" msgstr "{file_name}没有有效的文件扩展名,将使用{default}" @@ -159,6 +852,21 @@ msgstr "{name}为{value}" msgid "{name} parameter will be ignored" msgstr "参数{name}将被忽略" +msgid "{param} receives the Plot returned by find_plot_by_name" +msgstr "{param} 接收 find_plot_by_name 返回的 Plot" + +msgid "{type_name} ({size} items): {value}" +msgstr "{type_name}({size} 项):{value}" + +msgid "{type_name} attributes:" +msgstr "{type_name} 属性:" + +msgid "{type_name} result:" +msgstr "{type_name} 结果:" + +msgid "{type_name} values ({count} items):" +msgstr "{type_name} 的值({count} 项):" + msgid "{value} cannot be found documented in {enum_name}, this may cause function call to fail" msgstr "在{enum_name}中找不到{value}的文档,这可能导致函数调用失败" @@ -166,4 +874,4 @@ msgid "{value} does not have a valid file extension, must be {extensions}" msgstr "{value}没有有效的文件扩展名,必须为{extensions}" msgid "{value} is not a valid {enum_name}" -msgstr "{value}不是有效的{enum_name}" +msgstr "{value} 不是有效的 {enum_name}" diff --git a/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po b/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po index cc1e7d8..907172a 100644 --- a/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po +++ b/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po @@ -3,9 +3,159 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: zh-TW\n" +msgid "\nDid you mean step '{step_name}'?" +msgstr "\n您是指步驟 '{step_name}' 嗎?" + +msgid "\nFor JSON input on multi-step targets, group parameters by step name, e.g. {example}" +msgstr "\n對於多步驟目標的 JSON 輸入,請依步驟名稱分組參數,例如 {example}" + +msgid "\nFor JSON input, this step key must map to an object of parameter names, e.g. {example}" +msgstr "\n對於 JSON 輸入,此步驟鍵必須對應到參數名稱的物件,例如 {example}" + +msgid " Did you mean '{parameter}'?" +msgstr " 您是指 '{parameter}' 嗎?" + +msgid " Known parameters: {known_params}." +msgstr " 已知參數:{known_params}。" + +msgid "'{type_name}' no longer exposes adapter method '{method_name}'." +msgstr "'{type_name}' 不再公開適配器方法 '{method_name}'。" + +msgid "--yaml requested but PyYAML is not installed: {error}" +msgstr "已要求使用 --yaml,但尚未安裝 PyYAML:{error}" + +msgid "--yaml requested but PyYAML is not installed: {exc}" +msgstr "請求了 --yaml,但未安裝 PyYAML:{exc}" + +msgid "Aborted." +msgstr "已中止。" + +msgid "Advanced fallback only. Use this tagged shape when annotation context is unavailable, when a nested payload is truly generic, or when multiple wrapper families would be ambiguous." +msgstr "僅供進階回退使用。當無法取得註解內容、巢狀載荷確實為泛型,或多個包裝器家族會造成歧義時,請使用此帶標記的結構。" + +msgid "Argument '{argument}' must specify a parameter name after the step (e.g., {step_name}.param=...).{extra}" +msgstr "參數 '{argument}' 必須在步驟後指定參數名稱(例如 {step_name}.param=...)。{extra}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}" +msgstr "參數 '{argument}' 必須以下列之一開頭:{valid_steps}" + +msgid "Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "參數 '{argument}' 必須以下列之一開頭:{valid_steps}.{extra}" + +msgid "Argument '{step_name}' must start with one of: {names}" +msgstr "參數 '{step_name}' 必須以下列之一開頭:{names}" + +msgid "Argument error calling {target}{signature}: {error}" +msgstr "呼叫 {target}{signature} 時發生引數錯誤:{error}" + +msgid "Arguments as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value. Duplicate or conflicting paths are rejected (for example param=1 with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "參數可使用 key=value 或 param.attr=value。對於鏈式目標,請以前置方法名稱指定參數,例如 find_plot_by_name.plot_name=\"My Plot\"。巢狀路由使用 step.param.attr=value。重複或衝突的路徑會被拒絕(例如 param=1 與 param.attr=2),且具有僅限位置參數的方法不支援具名 CLI 路由。" + +msgid "Arguments for step '{step_name}' must be a JSON object of parameters." +msgstr "步驟 '{step_name}' 的參數必須是參數的 JSON 物件。" + +msgid "Array" +msgstr "陣列" + +msgid "Attribute '{matched_name}' on class '{class_name}' returns a non-wrapper value{continuation}" +msgstr "類別 '{class_name}' 上的屬性 '{matched_name}' 會傳回非包裝器值{continuation}" + +msgid "Attribute '{matched_name}' on object '{type_name}' returns a non-wrapper value{continuation}" +msgstr "物件 '{type_name}' 上的屬性 '{matched_name}' 會傳回非包裝器值{continuation}" + +msgid "Batch file must contain a JSON array of invoke call objects." +msgstr "批次檔必須包含由 invoke 呼叫物件組成的 JSON 陣列。" + +msgid "Batch item field 'args' must be a list of strings." +msgstr "批次項目的 'args' 欄位必須是字串清單。" + +msgid "Batch item field 'params_json_file' must be a string path." +msgstr "批次項目的 'params_json_file' 欄位必須是字串路徑。" + +msgid "Batch item must be a JSON object." +msgstr "批次項目必須是 JSON 物件。" + +msgid "Batch item requires string field 'target'." +msgstr "批次項目必須包含字串欄位 'target'。" + +msgid "Batch item {index} error: {error}" +msgstr "批次項目 {index} 錯誤:{error}" + +msgid "Batch results" +msgstr "批次結果" + +msgid "Batch summary: {succeeded}/{total} succeeded, {failed} failed." +msgstr "批次摘要:{succeeded}/{total} 成功,{failed} 失敗。" + +msgid "CLI argument: {value}" +msgstr "CLI 引數:{value}" + msgid "Cancel" msgstr "取消" +msgid "Cannot assign property '{property_name}' while resolving target '{target}' because the owner object resolved to None." +msgstr "解析目標 '{target}' 時無法指定屬性 '{property_name}',因為擁有者物件解析為 None。" + +msgid "Cannot build instance for type 'EntList'. No create_entity_list provider found." +msgstr "無法為類型 'EntList' 建立執行個體。找不到 create_entity_list 提供者。" + +msgid "Cannot build instance for type '{type_name}'. Not a known Synergy property or factory." +msgstr "無法為類型 '{type_name}' 建立執行個體。這不是已知的 Synergy 屬性或工廠。" + +msgid "Cannot configure field '{key_name}' on '{type_name}': {error}" +msgstr "無法設定 '{type_name}' 上的欄位 '{key_name}':{error}" + +msgid "Cannot invoke method '{segment}' for target '{target}' because '{owner}' is unavailable in the current session (it resolved to None). This target only works when that object exists." +msgstr "無法為目標 '{target}' 呼叫方法 '{segment}',因為 '{owner}' 在目前工作階段中不可用(其解析結果為 None)。此目標僅在該物件存在時可用。" + +msgid "Cannot read JSON file '{file}': {exc}" +msgstr "無法讀取 JSON 檔案 '{file}':{exc}" + +msgid "Cannot read JSON file '{path}': {error}" +msgstr "無法讀取 JSON 檔案 '{path}':{error}" + +msgid "Cannot read batch file '{path}': {error}" +msgstr "無法讀取批次檔 '{path}':{error}" + +msgid "Cannot resolve '{first}' on moldflow for introspection" +msgstr "無法在 moldflow 上解析 '{first}' 以進行內省" + +msgid "Cannot resolve attribute '{segment}' on '{class_name}' when executing target '{target}': {error}" +msgstr "執行目標 '{target}' 時,無法在 '{class_name}' 上解析屬性 '{segment}':{error}" + +msgid "Cannot resolve attribute '{segment}' without an object instance when resolving target '{target}'" +msgstr "解析目標 '{target}' 時,若沒有物件執行個體,則無法解析屬性 '{segment}'" + +msgid "Cannot resolve segment '{segment}' in target '{target}' without a class context. Use a Synergy-rooted target such as 'synergy.some_method'." +msgstr "無法在沒有類別內容的情況下解析目標 '{target}' 中的區段 '{segment}'。請使用以 Synergy 為根的目標,例如 'synergy.some_method'。" + +msgid "Cannot set nested argument '{path}': {error}" +msgstr "無法設定巢狀引數 '{path}':{error}" + +msgid "Cannot set nested attributes for '{param_name}' without signature info on '{step_name}'." +msgstr "若 '{step_name}' 沒有簽章資訊,則無法為 '{param_name}' 設定巢狀屬性。" + +msgid "Cannot set property '{property_name}' on target '{target}': {error}" +msgstr "無法在目標 '{target}' 上設定屬性 '{property_name}':{error}" + +msgid "Cannot write JSON file '{path}': {error}" +msgstr "無法寫入 JSON 檔案 '{path}':{error}" + +msgid "Canonical JSON field is derived from the reflected wrapper method signature for {method_name}()." +msgstr "正規 JSON 欄位是由 {method_name}() 的反映包裝器方法簽章推導而來。" + +msgid "Canonical list field is derived from the reflected wrapper method {method_name}()." +msgstr "正規清單欄位是由反映的包裝器方法 {method_name}() 推導而來。" + +msgid "Canonical triplet field is derived from the reflected wrapper method {method_name}()." +msgstr "正規三元組欄位是由反映的包裝器方法 {method_name}() 推導而來。" + +msgid "Canonical vector-array field is derived from the reflected wrapper method {method_name}()." +msgstr "正規向量陣列欄位是由反映的包裝器方法 {method_name}() 推導而來。" + +msgid "Chained targets with repeated method names are ambiguous for argument routing: {duplicate_names}. Please use an equivalent target path where each invoked step name is unique." +msgstr "對於引數路由而言,包含重複方法名稱的鏈式目標具有歧義:{duplicate_names}。請改用等效的目標路徑,讓每個呼叫步驟的名稱都唯一。" + msgid "Checking file extension {file_name}" msgstr "正在檢查檔案副檔名 {file_name}" @@ -42,24 +192,207 @@ msgstr "正在檢查 {value} 是否為正" msgid "Checking {value} is {name}" msgstr "正在檢查 {value} 是否為 {name}" +msgid "Class '{class_name}' has no attribute '{attr_name}'" +msgstr "類別 '{class_name}' 沒有屬性 '{attr_name}'" + +msgid "Clear the screen" +msgstr "清除螢幕" + +msgid "Clears the terminal screen and redraws the banner." +msgstr "清除終端機螢幕並重新繪製橫幅。" + +msgid "Close Synergy and reset the session" +msgstr "關閉 Synergy 並重設工作階段" + +msgid "Closes Synergy and resets the session for a fresh start." +msgstr "關閉 Synergy 並重設工作階段,以便重新開始。" + +msgid "Command exited with status {code}" +msgstr "命令以狀態 {code} 結束" + +msgid "Commands:" +msgstr "命令:" + +msgid "Conflicting argument paths '{left_path}' and '{right_path}' are not allowed." +msgstr "不允許衝突的引數路徑 '{left_path}' 與 '{right_path}'。" + msgid "Could not initialize with Instance ID: {value}" msgstr "無法使用執行個體 ID {value} 進行初始化" +msgid "Ctrl+D also exits." +msgstr "Ctrl+D 也可以退出。" + +msgid "Detail" +msgstr "詳細資料" + +msgid "Direct parameter assignment remains the preferred non-JSON form." +msgstr "直接參數指定仍是非 JSON 形式的首選。" + +msgid "Disable ANSI color/styling in CLI output." +msgstr "停用 CLI 輸出中的 ANSI 色彩/樣式。" + +msgid "Discover invokable targets and the next command to run for each one." +msgstr "找出可呼叫的目標,以及每個目標下一步要執行的命令。" + +msgid "Do not pass TARGET when --batch-file is used." +msgstr "使用 --batch-file 時,不要傳入 TARGET。" + +msgid "Do not pass positional args/JSON input with --batch-file." +msgstr "使用 --batch-file 時,不要傳入位置引數或 JSON 輸入。" + +msgid "Dotted path to a method or function, optionally chained, for example 'synergy.new_project' or 'synergy.plot_manager.find_plot_by_name'." +msgstr "方法或函式的點號路徑,可選擇鏈接,例如 'synergy.new_project' 或 'synergy.plot_manager.find_plot_by_name'。" + +msgid "Dotted path, e.g., synergy.new_project" +msgstr "點號路徑,例如 synergy.new_project" + +msgid "Dry run for {target}" +msgstr "{target} 的乾跑" + +msgid "Duplicate argument path '{path}' is not allowed." +msgstr "不允許重複的引數路徑 '{path}'。" + +msgid "Duplicate/conflicting paths are rejected. Arguments are passed as key=value or param.attr=value. For chained targets, prefix the parameter with the method name, for example find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value (for example param=1 conflicts with param.attr=2), and methods with positional-only parameters are not supported by named CLI routing." +msgstr "拒絕重複/衝突路徑。引數以 key=value 或 param.attr=value 形式傳遞。對於鏈式目標,請在參數前加上方法名稱,例如 find_plot_by_name.plot_name=\"My Plot\"。巢狀路由使用 step.param.attr=value(例如 param=1 與 param.attr=2 衝突),不支援僅含位置參數的方法的命名 CLI 路由。" + +msgid "Emit a JSON schema-like representation of target parameters." +msgstr "輸出目標參數的類似 JSON 結構描述表示。" + +msgid "Emit line-delimited JSON trace events to stderr for target resolution and runtime invoke binding." +msgstr "將以逐行 JSON 追蹤事件輸出到 stderr,用於目標解析與執行階段 invoke 繫結。" + +msgid "Emit structured JSON for scripting or agent use." +msgstr "輸出供指令碼或代理使用的結構化 JSON。" + +msgid "Emit structured YAML for scripting or agent use (requires PyYAML)." +msgstr "輸出供指令碼或代理使用的結構化 YAML(需要 PyYAML)。" + +msgid "Emit the structured result as JSON to stdout (useful for automation/LLMs)." +msgstr "將結構化結果以 JSON 輸出到 stdout(適用於自動化/LLM)。" + +msgid "Empty target" +msgstr "目標為空" + +msgid "Empty type tag is not valid for '{primary_expected}'." +msgstr "空的類型標記對 '{primary_expected}' 無效。" + +msgid "Error:" +msgstr "錯誤:" + +msgid "Example object shape: {shape}." +msgstr "範例物件形狀:{shape}。" + msgid "Executing {name}" msgstr "正在執行 {name}" +msgid "Exit the REPL" +msgstr "退出 REPL" + +msgid "Exits the REPL." +msgstr "退出 REPL。" + +msgid "Explicit field form: {value}" +msgstr "明確欄位形式:{value}" + msgid "Failed to initialize Synergy: Synergy not found" msgstr "初始化 Synergy 失敗:找不到 Synergy" +msgid "Failed to render JSON output for {context}: {error}" +msgstr "無法為 {context} 產生 JSON 輸出:{error}" + +msgid "Failed to render JSON output for {context}: {exc}" +msgstr "無法為 {context} 產生 JSON 輸出:{exc}" + +msgid "Failed to render YAML output for {context}: {error}" +msgstr "無法為 {context} 產生 YAML 輸出:{error}" + +msgid "Failed to render YAML output for {context}: {exc}" +msgstr "無法為 {context} 產生 YAML 輸出:{exc}" + +msgid "Failed to reset session:" +msgstr "重設工作階段失敗:" + +msgid "Field '{key_name}' is not valid for '{type_name}'." +msgstr "欄位 '{key_name}' 對 '{type_name}' 無效。" + +msgid "Field '{key}' for '{type_name}' must be a 3-item numeric sequence like [0, 0, 1]." +msgstr "欄位 '{key}' 對 '{type_name}' 必須是像 [0, 0, 1] 這樣的 3 項數值序列。" + +msgid "Field '{key}' for '{type_name}' must be a JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "欄位 '{key}' 對 '{type_name}' 必須是三元組的 JSON 陣列,或像 '0,0,0;1,0,0' 這樣以分號分隔的清單。" + +msgid "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list." +msgstr "欄位 '{key}' 對 '{type_name}' 必須是 JSON 陣列或逗號分隔清單。" + +msgid "Field '{key}' for '{type_name}' must be a comma-separated numeric triplet like '0,0,1'." +msgstr "欄位 '{key}' 對 '{type_name}' 必須是像 '0,0,1' 這樣以逗號分隔的數值三元組。" + +msgid "Field '{key}' for '{type_name}' must be a list of numeric triplets." +msgstr "欄位 '{key}' 對 '{type_name}' 必須是數值三元組清單。" + +msgid "Field '{key}' for '{type_name}' must be a string selection expression. Expected field: {preferred_field}." +msgstr "欄位 '{key}' 對 '{type_name}' 必須是字串選取表示式。預期欄位:{preferred_field}。" + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'." +msgstr "欄位 '{key}' 對 '{type_name}' 必須是有效的三元組 JSON 陣列,或像 '0,0,0;1,0,0' 這樣以分號分隔的清單。" + +msgid "Field '{key}' for '{type_name}' must be a valid JSON array or a comma-separated list." +msgstr "欄位 '{key}' 對 '{type_name}' 必須是有效的 JSON 陣列或逗號分隔清單。" + +msgid "Field '{key}' for '{type_name}' must contain integer values." +msgstr "欄位 '{key}' 對 '{type_name}' 必須包含整數值。" + +msgid "Field '{key}' for '{type_name}' must contain numeric values." +msgstr "欄位 '{key}' 對 '{type_name}' 必須包含數值。" + +msgid "Fields '{previous_key}' and '{key}' both map to the same input for '{type_name}'. Provide only one of: {preferred_field} or direct parameter shorthand." +msgstr "欄位 '{previous_key}' 與 '{key}' 都對應到 '{type_name}' 的相同輸入。請只提供以下其一:{preferred_field} 或直接參數簡寫。" + +msgid "Filter by substring or wildcard pattern (* and ?)." +msgstr "依子字串或萬用字元模式(* 與 ?)篩選。" + +msgid "Filter by substring or wildcard pattern (* and ?). Repeat to keep targets matching any filter." +msgstr "依子字串或萬用字元模式(* 和 ?)篩選。重複此選項可保留符合任一篩選條件的目標。" + +msgid "Filtered matches:" +msgstr "篩選結果:" + +msgid "For JSON input, group parameters by step name. Argument '{argument}' must start with one of: {valid_steps}.{extra}" +msgstr "對於 JSON 輸入,請依步驟名稱分組參數。參數 '{argument}' 必須以下列之一開頭:{valid_steps}.{extra}" + +msgid "For multi-step targets, group params-json fields by step name." +msgstr "對於多步驟目標,請依步驟名稱分組 params-json 欄位。" + msgid "Getting {name}" msgstr "正在取得 {name}" msgid "Getting {name} at index {value}" msgstr "正在於索引 {value} 取得 {name}" +msgid "Goodbye!" +msgstr "再見!" + +msgid "If shorthand input is ambiguous, switch to --params-json. {guidance}" +msgstr "如果簡寫輸入有歧義,請改用 --params-json。{guidance}" + +msgid "In non-JSON mode, prefer direct shorthand like '{preferred_non_json}'." +msgstr "在非 JSON 模式下,請優先使用像 '{preferred_non_json}' 這樣的直接簡寫。" + +msgid "Index" +msgstr "索引" + msgid "Initializing {name}" msgstr "正在初始化 {name}" +msgid "Input hints:" +msgstr "輸入提示:" + +msgid "Inspect a target's signature, docs, examples, and structured invoke template." +msgstr "檢視目標的簽章、文件、範例,以及結構化的 invoke 範本。" + +msgid "Interrupted." +msgstr "已中斷。" + msgid "Invalid Attribute: {attribute} is not supported" msgstr "無效的屬性:不支援 {attribute}" @@ -69,18 +402,192 @@ msgstr "無效的檔案類型:{file_name},必須為 {extensions}" msgid "Invalid Index: out of range" msgstr "無效的索引:超出範圍" +msgid "Invalid JSON payload for parameters: {error}" +msgstr "參數的 JSON 載荷無效:{error}" + +msgid "Invalid JSON payload for parameters: {exc}" +msgstr "參數的 JSON 載荷無效:{exc}" + +msgid "Invalid JSON value for parameter '{param_name}': {error}" +msgstr "參數 '{param_name}' 的 JSON 值無效:{error}" + msgid "Invalid Type: must be {expected_types}, not {variable_type}" msgstr "無效的型別:必須為 {expected_types},而非 {variable_type}" msgid "Invalid Value: {reason}" msgstr "無效的值:{reason}" +msgid "Invalid argument '{item}'. Expected key=value or param.attr=value." +msgstr "無效的參數 '{item}'。預期為 key=value 或 param.attr=value。" + +msgid "Invalid argument for step '{step_name}': missing parameter name." +msgstr "步驟 '{step_name}' 的參數無效:缺少參數名稱。" + +msgid "Invalid nested argument path '{path}': attribute '{attr}' does not exist on '{obj_type}'." +msgstr "無效的巢狀引數路徑 '{path}':'{obj_type}' 上不存在屬性 '{attr}'。" + +msgid "Invalid nested argument path '{path}': cannot nest into non-object '{obj_type}'." +msgstr "無效的巢狀引數路徑 '{path}':無法巢狀進入非物件 '{obj_type}'。" + +msgid "Invalid nested argument path '{path}': cannot set '{final_attr}' on non-object '{obj_type}'." +msgstr "無效的巢狀引數路徑 '{path}':無法在非物件 '{obj_type}' 上設定 '{final_attr}'。" + +msgid "Invalid value for parameter '{param_name}': {error}" +msgstr "參數 '{param_name}' 的值無效:{error}" + +msgid "Invalid {field_name} '{path_text}': empty path segment is not allowed." +msgstr "無效的 {field_name} '{path_text}':不允許空的路徑段。" + +msgid "Invalid {field_name} '{path_text}': segment '{segment}' must be a valid identifier." +msgstr "無效的 {field_name} '{path_text}':區段 '{segment}' 必須是有效的識別字。" + +msgid "Invalid {field_name}: value cannot be empty." +msgstr "無效的 {field_name}:值不能為空。" + +msgid "JSON example:" +msgstr "JSON 範例:" + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays and scalars are not allowed." +msgstr "包含參數對映的 JSON 物件(會覆蓋位置引數)。不允許最上層為陣列或純量。" + +msgid "JSON object containing parameter mappings (overrides positional args). Top-level arrays/scalars are not allowed." +msgstr "包含參數對映的 JSON 物件(會覆蓋位置引數)。不允許最上層為陣列或純量。" + +msgid "JSON parameters must be a JSON object of named arguments. Example: --params-json '{\"param\": 1}' or --params-json '{\"step\": {\"param\": 1}}' for chained targets." +msgstr "JSON 參數必須是命名參數的 JSON 物件。範例:--params-json '{\"param\": 1}' 或對於鏈式目標:--params-json '{\"step\": {\"param\": 1}}'" + +msgid "JSON type tag '{type_tag}' does not match expected wrapper '{primary_expected}'." +msgstr "JSON 類型標記 '{type_tag}' 與預期的包裝器 '{primary_expected}' 不符。" + +msgid "JSON value:" +msgstr "JSON 值:" + +msgid "List result:" +msgstr "清單結果:" + msgid "Logger was not setup" msgstr "記錄器未設定" +msgid "Missing required parameter '{parameter}' for {target}{signature}" +msgstr "缺少 {target}{signature} 所需的必要參數 '{parameter}'" + +msgid "Moldflow command-line interface.\n\nStart with 'list' to discover targets, 'describe ' to inspect usage, then 'invoke ...' to run it." +msgstr "Moldflow 命令列介面。\n\n先用 'list' 找出目標,再用 'describe ' 查看用法,然後執行 'invoke ...'。" + +msgid "Moldflow invokable targets" +msgstr "Moldflow 可呼叫目標" + +msgid "Nested argument '{path}' is not supported for **kwargs on step '{step_name}'. Use a single key (e.g., {example}=...)." +msgstr "步驟 '{step_name}' 的 **kwargs 不支援巢狀引數 '{path}'。請使用單一鍵(例如 {example}=...)。" + +msgid "No invokable targets matched these filters." +msgstr "沒有可呼叫目標符合這些篩選條件。" + +msgid "No invokable targets matched this filter." +msgstr "沒有可呼叫目標符合此篩選條件。" + +msgid "Non-public argument path '{key}' is not allowed." +msgstr "不允許使用非公開的參數路徑 '{key}'。" + +msgid "Non-public argument path '{left}' is not allowed." +msgstr "不允許使用非公開的參數路徑 '{left}'。" + +msgid "Non-public argument path '{path}' is not allowed." +msgstr "不允許使用非公開的引數路徑 '{path}'。" + +msgid "Non-public argument path '{step_name}' is not allowed." +msgstr "不允許使用非公開的參數路徑 '{step_name}'。" + +msgid "Non-public argument path '{step}.{key}' is not allowed." +msgstr "不允許使用非公開的參數路徑 '{step}.{key}'。" + +msgid "Non-public field '{key_name}' is not allowed when constructing '{type_name}' from JSON." +msgstr "從 JSON 建構 '{type_name}' 時,不允許使用非公開欄位 '{key_name}'。" + +msgid "Non-public segment '{segment}' is not allowed in target '{target}'." +msgstr "目標 '{target}' 中不允許非公開區段 '{segment}'。" + +msgid "Non-public segment '{seg}' is not allowed in target '{target}'." +msgstr "目標 '{target}' 中不允許出現非公開段 '{seg}'。" + msgid "OK" msgstr "確定" +msgid "Object" +msgstr "物件" + +msgid "Object '{type_name}' has no attribute '{attr_name}'" +msgstr "物件 '{type_name}' 沒有屬性 '{attr_name}'" + +msgid "One or more dotted targets, for example synergy.new_project." +msgstr "一個或多個點分隔目標,例如 synergy.new_project。" + +msgid "Only one of --json or --yaml may be specified." +msgstr "只能指定 --json 或 --yaml 其一。" + +msgid "Only one of --json, --yaml, or --schema may be specified." +msgstr "只能指定 --json、--yaml 或 --schema 其一。" + +msgid "Only one of --params-json or --params-json-file may be specified." +msgstr "只能指定 --params-json 或 --params-json-file 其一。" + +msgid "Parameter '{param_name}' contains a null byte which is not allowed." +msgstr "參數 '{param_name}' 包含不允許的空位元組。" + +msgid "Parameter '{param_name}' contains control characters (newline/tab/carriage return); please provide a single-line value or quote/escape as needed." +msgstr "參數 '{param_name}' 包含控制字元(換行/製表/回車);請提供單行值或依需要引用/轉義。" + +msgid "Parse error:" +msgstr "解析錯誤:" + +msgid "Parse/validate/build kwargs and emit a call plan without executing invoke steps." +msgstr "解析/驗證/建構 kwargs,並在不執行 invoke 步驟的情況下輸出呼叫計畫。" + +msgid "Parse/validate/build kwargs and emit a template summary call plan without executing invoke steps." +msgstr "解析/驗證/建構 kwargs,並在不執行 invoke 步驟的情況下輸出範本摘要呼叫計畫。" + +msgid "Path to a JSON file containing an array of invoke calls for batch execution." +msgstr "包含供批次執行之 invoke 呼叫陣列的 JSON 檔案路徑。" + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object, not arrays/scalars." +msgstr "包含參數對映的 JSON 檔案路徑(會覆蓋位置引數)。最上層載荷必須是物件。" + +msgid "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object." +msgstr "包含參數對映的 JSON 檔案路徑(會覆蓋位置引數)。最上層載荷必須是物件。" + +msgid "Planned steps:" +msgstr "計畫步驟:" + +msgid "Prefer chaining invoke targets so this parameter is produced by a previous step, instead of constructing it manually in JSON." +msgstr "建議串接 invoke 目標,讓此參數由前一步產生,而不是在 JSON 中手動建構。" + +msgid "Print the installed moldflow package version." +msgstr "列印已安裝的 moldflow 套件版本。" + +msgid "Property assignment JSON must be an object with a single 'value' field." +msgstr "屬性指定的 JSON 必須是僅包含單一 'value' 欄位的物件。" + +msgid "Property assignment requires exactly one 'value' argument (e.g., value=... or --params-json '{\"value\": ...}')." +msgstr "屬性指定必須且只能有一個 'value' 引數(例如 value=... 或 --params-json '{\"value\": ...}')。" + +msgid "Property {name} (id={id}, type={prop_type})" +msgstr "屬性 {name}(id={id},type={prop_type})" + +msgid "Read current value:" +msgstr "讀取目前值:" + +msgid "Resolved assignment:" +msgstr "已解析的指定:" + +msgid "Resolved kwargs:" +msgstr "已解析的 kwargs:" + +msgid "Resolved object has no callable attribute '{segment}' when executing target '{target}'" +msgstr "已解析物件在執行目標 '{target}' 時沒有可呼叫屬性 '{segment}'" + +msgid "Run a Moldflow target with named parameters or JSON input. Bare targets are treated as synergy.." +msgstr "使用具名參數或 JSON 輸入執行 Moldflow 目標。未加前綴的目標會視為 synergy.。" + msgid "Save Error" msgstr "儲存錯誤" @@ -90,15 +597,171 @@ msgstr "儲存錯誤:無法將 {saving} 儲存至 {file_name}" msgid "Save Error: Failed to save {saving} to {file_name}" msgstr "儲存錯誤:將 {saving} 儲存到 {file_name} 失敗" +msgid "Segment '{segment}' does not resolve as an attribute on class '{class_name}' when resolving target '{target}'" +msgstr "解析目標 '{target}' 時,區段 '{segment}' 無法解析為類別 '{class_name}' 上的屬性" + +msgid "Segment '{segment}' is not a callable method on class '{class_name}' when resolving target '{target}'" +msgstr "解析目標 '{target}' 時,區段 '{segment}' 不是類別 '{class_name}' 上可呼叫的方法" + +msgid "Selection" +msgstr "選取" + +msgid "Session:" +msgstr "工作階段:" + +msgid "Set it with:" +msgstr "可用下列方式設定:" + msgid "Setting {name} to {value}" msgstr "正在將 {name} 設定為 {value}" +msgid "Shorter JSON example:" +msgstr "較短的 JSON 範例:" + +msgid "Shorter form:" +msgstr "較短形式:" + +msgid "Show detailed help for a command" +msgstr "顯示命令的詳細說明" + +msgid "Show full tracebacks on errors instead of short messages." +msgstr "發生錯誤時顯示完整的追蹤資訊,而非簡短的錯誤訊息。" + +msgid "Show this help message" +msgstr "顯示此說明訊息" + +msgid "Showing the compact table for {count} filtered matches. Narrow the filter or use --json for canonical target strings." +msgstr "正在顯示 {count} 筆篩選結果的精簡表格。請縮小篩選條件,或使用 --json 取得正規目標字串。" + +msgid "Shows available commands and usage information." +msgstr "顯示可用命令和使用資訊。" + +msgid "Start an interactive moldflow shell session." +msgstr "啟動一個互動式 moldflow shell 工作階段。" + +msgid "Status" +msgstr "狀態" + +msgid "Step '{step_name}' in target '{target}' has positional-only parameters ({parameters}), which are not supported by CLI named-argument routing. Use the Python API for this target." +msgstr "目標 '{target}' 中的步驟 '{step_name}' 具有僅限位置參數 ({parameters}),CLI 具名引數路由不支援此類參數。請改用此目標的 Python API。" + msgid "Submit" msgstr "確定" +msgid "Synergy session reset." +msgstr "Synergy 工作階段已重設。" + +msgid "TARGET is required unless --batch-file is used." +msgstr "除非使用 --batch-file,否則必須提供 TARGET。" + +msgid "Tab completion targets are refreshed automatically." +msgstr "Tab 自動完成目標會自動重新整理。" + +msgid "Target" +msgstr "目標" + +msgid "Target '{target}' is hidden from the CLI because '{hidden_path}' only creates a transient {wrapper_type} wrapper. The CLI constructs these helper objects internally when needed, so they are not exposed as direct CLI targets." +msgstr "目標 '{target}' 已從 CLI 隱藏,因為 '{hidden_path}' 只會建立暫時性的 {wrapper_type} 包裝器。CLI 會在需要時於內部建立這些輔助物件,因此不會將它們公開為直接 CLI 目標。" + +msgid "Target '{target}' is hidden from the CLI by library metadata on '{hidden_path}'." +msgstr "目標 '{target}' 因 '{hidden_path}' 上的程式庫中繼資料而從 CLI 隱藏。" + +msgid "Target '{target}' resolves to a property/attribute and does not accept arguments." +msgstr "目標 '{target}' 會解析為屬性/特性,不接受引數。" + +msgid "Target '{target}' resolves to a {class_name} wrapper property. Continue to one of its members, for example 'describe {target}.'." +msgstr "目標 '{target}' 會解析為 {class_name} 包裝器屬性。請繼續使用其成員之一,例如 'describe {target}.'。" + +msgid "Target '{target}' resolves to property '{property_name}' (getter) and does not accept arguments." +msgstr "目標 '{target}' 會解析為屬性 '{property_name}'(getter),不接受引數。" + +msgid "Target '{target}' resolves to write-only property '{property_name}' and cannot be read via invoke." +msgstr "目標 '{target}' 會解析為唯寫屬性 '{property_name}',無法透過 invoke 讀取。" + +msgid "Target must include a class or function name" +msgstr "目標必須包含類別或函式名稱" + +msgid "Target must include at least one segment" +msgstr "目標必須包含至少一個段" + +msgid "Target must start with 'synergy' (or 'moldflow.synergy'). All invocations are rooted on the Synergy COM object." +msgstr "目標必須以 'synergy'(或 'moldflow.synergy')開頭。所有呼叫皆以 Synergy COM 物件為根。" + +msgid "Target must start with 'synergy' after the optional 'moldflow.' prefix. Bare targets such as 'open_project' are accepted and are interpreted as 'synergy.open_project'." +msgstr "在可選的 'moldflow.' 前綴之後,目標必須以 'synergy' 開頭。可接受像 'open_project' 這樣的裸目標,並會將其解讀為 'synergy.open_project'。" + +msgid "Targets are shown without the leading 'synergy.' prefix. Describe and invoke accept either form." +msgstr "顯示目標時不包含前置的 'synergy.' 前綴。describe 與 invoke 兩種形式都可接受。" + msgid "Test String" msgstr "測試字串" +msgid "The Moldflow CLI requires optional dependencies. Install them with: pip install 'moldflow[cli]'" +msgstr "Moldflow CLI 需要選用相依套件。請使用下列指令安裝:pip install 'moldflow[cli]'" + +msgid "The target returned False, which indicates a business-level failure." +msgstr "目標回傳 False,這表示業務層級失敗。" + +msgid "This dry run validates a property assignment." +msgstr "此乾跑會驗證屬性指定。" + +msgid "This parameter can be null to indicate no value." +msgstr "此參數可為 null,以表示沒有值。" + +msgid "This property is read-only and takes no arguments." +msgstr "此屬性為唯讀,不接受任何引數。" + +msgid "This property returns a {class_name} wrapper. Continue with describe {target}. or invoke {target}.." +msgstr "此屬性會回傳 {class_name} 包裝器。請繼續使用 describe {target}. 或 invoke {target}.。" + +msgid "Tip: install pyreadline3 for tab completion support on Windows." +msgstr "提示:在 Windows 上安裝 pyreadline3 以獲得 Tab 自動完成支援。" + +msgid "Treat False return values as CLI failures (exit 1). This is enabled by default for automation-friendly behavior." +msgstr "將 False 回傳值視為 CLI 失敗(exit 1)。此行為預設啟用,以利自動化。" + +msgid "Try this:" +msgstr "請試試:" + +msgid "Type" +msgstr "類型" + +msgid "Type 'help' for available commands, 'exit' to quit." +msgstr "輸入 'help' 查看可用命令,輸入 'exit' 退出。" + +msgid "Type help to see available commands." +msgstr "輸入 help 查看可用命令。" + +msgid "Unknown batch item field(s): {fields}." +msgstr "未知的批次項目欄位:{fields}。" + +msgid "Unknown command:" +msgstr "未知命令:" + +msgid "Unknown parameter '{parameter}' for {target}{signature}.{extra}" +msgstr "在 {target}{signature} 中發現未知參數 '{parameter}'。{extra}" + +msgid "Use 'help ' for detailed help on a specific command." +msgstr "使用 'help <命令>' 查看特定命令的詳細說明。" + +msgid "Use JSON field '{preferred_field}' for '{param_name}'." +msgstr "請為 '{param_name}' 使用 JSON 欄位 '{preferred_field}'。" + +msgid "Use JSON field '{preferred_field}'." +msgstr "請使用 JSON 欄位 '{preferred_field}'。" + +msgid "Use a comma-separated list for quick CLI input, or a JSON array string when values contain commas." +msgstr "若要快速輸入 CLI,請使用逗號分隔清單;若值本身包含逗號,請使用 JSON 陣列字串。" + +msgid "Use a comma-separated triplet for vector shorthand." +msgstr "向量簡寫請使用逗號分隔的三元組。" + +msgid "Use describe to inspect parameters, examples, and property behavior before invoking." +msgstr "在呼叫前,請使用 describe 檢查參數、範例與屬性行為。" + +msgid "Use semicolon-separated triplets for quick CLI input. Quote the value in shells that treat semicolons specially." +msgstr "若要快速輸入 CLI,請使用分號分隔的三元組。在會特別處理分號的 shell 中,請為該值加上引號。" + msgid "Using prompts will use pop-up import options and will always show logs" msgstr "使用提示將使用快顯匯入選項,並且一律顯示記錄" @@ -114,12 +777,30 @@ msgstr "有效輸入" msgid "Valid Input Type" msgstr "有效的輸入型別" +msgid "Vector" +msgstr "向量" + +msgid "When JSON input is provided via --params-json or --params-json-file, no positional key=value args may be given." +msgstr "當透過 --params-json 或 --params-json-file 提供 JSON 輸入時,不得提供位置參數 key=value。" + +msgid "Write JSON output to the given file path without changing stdout mode." +msgstr "將 JSON 輸出寫入指定檔案路徑,而不變更 stdout 模式。" + +msgid "Wrote structured output to {path}." +msgstr "已將結構化輸出寫入 {path}。" + +msgid "You are already in the REPL." +msgstr "您已在 REPL 中。" + msgid "both {first} and {second} must be provided together" msgstr "{first} 與 {second} 必須一併提供" msgid "cannot be empty" msgstr "不能為空" +msgid "failed" +msgstr "失敗" + msgid "found {min_value} must be less than {max_value}" msgstr "找到 {min_value},必須小於 {max_value}" @@ -150,6 +831,18 @@ msgstr "找到 {value},必須為 {expected_values} 之一" msgid "found {value}, must be positive" msgstr "找到{value},必須是積極的" +msgid "interactive shell" +msgstr "互動式 shell" + +msgid "ok" +msgstr "正常" + +msgid "settable" +msgstr "可設定" + +msgid "the owning object" +msgstr "擁有該項的物件" + msgid "{file_name} does not have a valid file extension, will use {default}" msgstr "{file_name} 沒有有效的副檔名,將使用 {default}" @@ -159,6 +852,21 @@ msgstr "{name} 為 {value}" msgid "{name} parameter will be ignored" msgstr "將忽略參數 {name}" +msgid "{param} receives the Plot returned by find_plot_by_name" +msgstr "{param} 會接收 find_plot_by_name 傳回的 Plot" + +msgid "{type_name} ({size} items): {value}" +msgstr "{type_name}({size} 個項目):{value}" + +msgid "{type_name} attributes:" +msgstr "{type_name} 屬性:" + +msgid "{type_name} result:" +msgstr "{type_name} 結果:" + +msgid "{type_name} values ({count} items):" +msgstr "{type_name} 值({count} 個項目):" + msgid "{value} cannot be found documented in {enum_name}, this may cause function call to fail" msgstr "在 {enum_name} 中找不到 {value} 的記載,這可能會導致函式呼叫失敗" diff --git a/src/moldflow/localization.py b/src/moldflow/localization.py index c51598f..feba51b 100644 --- a/src/moldflow/localization.py +++ b/src/moldflow/localization.py @@ -3,26 +3,156 @@ """Localization module for Moldflow.""" +import ctypes +import logging import os +import re import winreg from .constants import ( - LOCALE_FILE_NAME, - THREE_LETTER_TO_BCP_47, DEFAULT_BCP_47_STD, DEFAULT_THREE_LETTER_CODE, LOCALE_DIR, - USER_LOCALE_KEY, LOCALE_ENVIRONMENT_VARIABLE_NAME, + LOCALE_FILE_NAME, + LOCALE_LOCATION, LOCALE_REGISTRY_VARIABLE_NAME, DEFAULT_LOCALE_KEY, - LOCALE_LOCATION, + USER_LOCALE_KEY, + THREE_LETTER_TO_BCP_47, ) from .common import LogMessage from .i18n import install_translation, get_text from .logger import process_log +def _normalize_locale_code(locale: str | None) -> str | None: + """Normalize locale to BCP-47 for gettext.""" + if locale is None: + return None + + locale_text = str(locale).strip() + if not locale_text: + return None + + # Three-letter (from MFSYN_LOCALE / MSI): map to BCP-47 + mapped_locale = THREE_LETTER_TO_BCP_47.get(locale_text.lower()) + if mapped_locale: + return mapped_locale + + # BCP-47 (from Windows fallback): normalize and pass through + parts = locale_text.replace("_", "-").split("-") + if any(not part for part in parts): + return None + normalized_parts = [] + for index, part in enumerate(parts): + if index == 0: + normalized_parts.append(part.lower()) + elif len(part) == 4 and part.isalpha(): + normalized_parts.append(part.title()) + elif (len(part) == 2 and part.isalpha()) or (len(part) == 3 and part.isdigit()): + normalized_parts.append(part.upper()) + else: + normalized_parts.append(part.lower()) + return "-".join(normalized_parts) + + +def _get_windows_locale_name() -> str | None: + """Return the Windows user locale as a BCP-47-style tag when available.""" + locale_name_max_length = 85 + buffer = ctypes.create_unicode_buffer(locale_name_max_length) + get_locale_name = getattr(getattr(ctypes, "windll", None), "kernel32", None) + if get_locale_name is None: + return None + + get_user_default_locale_name = getattr(get_locale_name, "GetUserDefaultLocaleName", None) + if get_user_default_locale_name is None: + return None + + try: + result = get_user_default_locale_name(buffer, locale_name_max_length) + except (AttributeError, OSError, TypeError, ValueError): + return None + + if not result: + return None + + return buffer.value or None + + +def _discover_product_versions(product_name: str) -> list[str]: + """Return all installed product version subkeys from the registry. + + Enumerates numeric year-like subkeys (e.g. ``2025``, ``2026``, ``2027``) + under ``HKCU\\SOFTWARE\\Autodesk\\{product_name}`` and + ``HKLM\\SOFTWARE\\Autodesk\\{product_name}``. + + The list is de-duplicated and sorted **newest-first** so that callers + that iterate will check the most recent installation first. + """ + product_path = f"SOFTWARE\\Autodesk\\{product_name}" + version_set: set[str] = set() + + for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): + try: + with winreg.OpenKey(hive, product_path) as key: + index = 0 + while True: + try: + subkey = winreg.EnumKey(key, index) + if subkey.isdigit(): + version_set.add(subkey) + index += 1 + except OSError: + break + except FileNotFoundError: + continue + + return sorted(version_set, key=int, reverse=True) + + +def _discover_com_version(product_name: str) -> str: + """Determine which product version the COM subsystem will dispatch. + + ``win32com.client.Dispatch("synergy.Synergy")`` connects to whichever + Synergy registered its COM server **last** — typically the most recently + installed version or the last one run as administrator. This function + reads the COM registration to determine that version without actually + launching Synergy. + + Lookup path:: + + HKCR\\synergy.Synergy\\CLSID → {clsid} + HKCR\\CLSID\\{clsid}\\LocalServer32 → exe path containing year + + Returns: + The four-digit year string (e.g. ``"2027"``) extracted from the + ``LocalServer32`` path, or an empty string if it cannot be determined. + """ + try: + with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"synergy.Synergy\CLSID") as clsid_key: + clsid, _ = winreg.QueryValueEx(clsid_key, "") + + with winreg.OpenKey( + winreg.HKEY_CLASSES_ROOT, rf"CLSID\{clsid}\LocalServer32" + ) as server_key: + server_path, _ = winreg.QueryValueEx(server_key, "") + + # Expected patterns: + # C:\Program Files\Autodesk\Moldflow Synergy 2027\bin\synergy.exe + # …\Moldflow Synergy 2025\… + pattern = re.escape(product_name) + r"\s+(\d{4})" + match = re.search(pattern, server_path, re.IGNORECASE) + if match: + return match.group(1) + except (FileNotFoundError, OSError): + logging.getLogger(__name__).debug( + "Could not determine COM-registered Synergy version", exc_info=True + ) + + return "" + + def get_locale(product_name: str = "Moldflow Synergy", version: str = ""): """ Get the locale of the specified Autodesk product from the Windows registry. @@ -48,7 +178,12 @@ def _process_locale(method: str, product_key: str, value: str): process_log(__name__, LogMessage.SYSTEM_SET, name="Language", value=value) def _fetch_registry_value( - winreg_key: int, location: str, value_name: str, registry_method: str + winreg_key: int, + location: str, + value_name: str, + registry_method: str, + *, + version_override: str | None = None, ): """ Fetch a value from the Windows registry. @@ -58,37 +193,74 @@ def _fetch_registry_value( location (str): The location of the registry key. value_name (str): The name of the registry value. registry_method (str): The method used to fetch the registry value. + version_override (str | None): If provided, use this version instead + of the outer *version* when formatting *location*. Returns: str: The value from the Windows registry if found, otherwise None. """ try: - location = location.format(product_name=product_name, version=version) - with winreg.OpenKey(winreg_key, location) as key: + effective_version = version_override if version_override is not None else version + formatted = location.format(product_name=product_name, version=effective_version) + with winreg.OpenKey(winreg_key, formatted) as key: value, _ = winreg.QueryValueEx(key, value_name) - _process_locale(registry_method, f"{location}\\{value_name}", value) + _process_locale(registry_method, f"{formatted}\\{value_name}", value) return value except FileNotFoundError: return None - # Environment Variable + def _is_valid_three_letter(value: str) -> bool: + """Return True if value is a valid three-letter locale (MFSYN_LOCALE / MSI only).""" + return value.strip().lower() in THREE_LETTER_TO_BCP_47 + + # Environment Variable (MFSYN_LOCALE: three-letter only) locale = os.getenv(LOCALE_ENVIRONMENT_VARIABLE_NAME) - if locale: - _process_locale("Environment Variable", LOCALE_ENVIRONMENT_VARIABLE_NAME, locale) - return locale + if locale and _is_valid_three_letter(locale): + three_letter = locale.strip().lower() + _process_locale("Environment Variable", LOCALE_ENVIRONMENT_VARIABLE_NAME, three_letter) + return three_letter - # Registry - User - locale = _fetch_registry_value( - USER_LOCALE_KEY, LOCALE_LOCATION, LOCALE_REGISTRY_VARIABLE_NAME, "Registry - User" - ) - if locale: - return locale + # When no explicit version is supplied (e.g. CLI bootstrap without a + # running Synergy instance) we try to match the version that + # win32com.client.Dispatch("synergy.Synergy") would actually launch. + # That is the version whose COM server was registered last (last + # installed or last run-as-admin). If the COM lookup fails, fall back + # to probing every installed version (newest-first). + if version: + versions_to_probe = [version] + else: + com_version = _discover_com_version(product_name) + if com_version: + all_versions = set(_discover_product_versions(product_name)) + all_versions.discard(com_version) + versions_to_probe = [com_version] + sorted(all_versions, key=int, reverse=True) + else: + versions_to_probe = _discover_product_versions(product_name) + + if not versions_to_probe: + logging.getLogger(__name__).info( + "No installed %s versions found in registry, using Windows locale", product_name + ) - # Registry - Default - locale = _fetch_registry_value( - DEFAULT_LOCALE_KEY, LOCALE_LOCATION, LOCALE_REGISTRY_VARIABLE_NAME, "Registry - Default" - ) + for ver in versions_to_probe: + for hive_key, method_label in ( + (USER_LOCALE_KEY, "Registry - User"), + (DEFAULT_LOCALE_KEY, "Registry - Default"), + ): + locale = _fetch_registry_value( + hive_key, + LOCALE_LOCATION, + LOCALE_REGISTRY_VARIABLE_NAME, + method_label, + version_override=ver, + ) + if locale and _is_valid_three_letter(locale): + return locale.strip().lower() + + # Windows - User Locale (BCP-47-style: accept whatever Windows returns) + locale = _get_windows_locale_name() if locale: + _process_locale("Windows User Locale", "", locale) return locale # Default @@ -113,13 +285,16 @@ def set_language(product_name: str = "Moldflow Synergy", version: str = "", loca function: The gettext translation function for the specified language. """ if not locale: - locale = get_locale(product_name, version).lower() - try: - locale = THREE_LETTER_TO_BCP_47[locale] - except KeyError: - locale = DEFAULT_BCP_47_STD + locale = get_locale(product_name, version) + + locale = _normalize_locale_code(locale) or DEFAULT_BCP_47_STD locale_file_name_custom = f"{LOCALE_FILE_NAME}.{locale}" - install_translation(locale_file_name_custom, LOCALE_DIR, [locale]) + try: + install_translation(locale_file_name_custom, LOCALE_DIR, [locale]) + except FileNotFoundError: + locale = DEFAULT_BCP_47_STD + locale_file_name_custom = f"{LOCALE_FILE_NAME}.{locale}" + install_translation(locale_file_name_custom, LOCALE_DIR, [locale]) return get_text() diff --git a/src/moldflow/logger.py b/src/moldflow/logger.py index 697425c..8664062 100644 --- a/src/moldflow/logger.py +++ b/src/moldflow/logger.py @@ -134,6 +134,19 @@ def get_logger(name) -> logging.Logger | None: return None +def _prune_unusable_moldflow_handlers() -> None: + """Remove handlers bound to closed streams and restore propagation when needed.""" + moldflow_logger = logging.getLogger("moldflow") + removed_handler = False + for handler in list(moldflow_logger.handlers): + stream = getattr(handler, "stream", None) + if stream is not None and getattr(stream, "closed", False): + moldflow_logger.removeHandler(handler) + removed_handler = True + if removed_handler and not moldflow_logger.handlers: + moldflow_logger.propagate = True + + def process_log(logger_name: str, message_log: LogMessage | str, dump=None, **kwargs): """ Processes a log entry with the message_log. @@ -145,7 +158,10 @@ def process_log(logger_name: str, message_log: LogMessage | str, dump=None, **kw **kwargs: The keyword arguments to format the message. """ if _IS_LOGGING: + _prune_unusable_moldflow_handlers() logger = get_logger(logger_name) + if logger is None: + return _ = get_text() message = message_log diff --git a/src/moldflow/mesh_editor.py b/src/moldflow/mesh_editor.py index 3c2f1ae..857f2b0 100644 --- a/src/moldflow/mesh_editor.py +++ b/src/moldflow/mesh_editor.py @@ -9,6 +9,7 @@ # pylint: disable=C0302 from .logger import process_log +from .cli_input_metadata import CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY, cli_hidden from .helper import deprecated from .common import LogMessage from .ent_list import EntList @@ -53,6 +54,7 @@ def purge_nodes(self) -> int: process_log(__name__, LogMessage.FUNCTION_CALL, locals(), name="purge_nodes") return self.mesh_editor.PurgeNodes + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_entity_list(self) -> EntList: """ Creates a new entity list in the model. diff --git a/src/moldflow/modeler.py b/src/moldflow/modeler.py index 76f62da..2fffdef 100644 --- a/src/moldflow/modeler.py +++ b/src/moldflow/modeler.py @@ -9,6 +9,7 @@ # pylint: disable=C0302 from .logger import process_log +from .cli_input_metadata import CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY, cli_hidden from .common import LogMessage, CurveInitPosition, LCSType from .helper import ( check_is_non_negative, @@ -143,6 +144,7 @@ def create_nodes_by_divide(self, curve: EntList | None, num_nodes: int, ends: bo return None return EntList(result) + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_entity_list(self) -> EntList: """ Creates an empty EntList object diff --git a/src/moldflow/property_editor.py b/src/moldflow/property_editor.py index 978e537..71181cd 100644 --- a/src/moldflow/property_editor.py +++ b/src/moldflow/property_editor.py @@ -7,6 +7,7 @@ """ from .logger import process_log, LogMessage +from .cli_input_metadata import CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY, cli_hidden from .ent_list import EntList from .prop import Property from .helper import get_enum_value, check_type, coerce_optional_dispatch @@ -124,6 +125,7 @@ def set_property(self, entities: EntList | None, prop: Property | None) -> bool: coerce_optional_dispatch(entities, "ent_list"), coerce_optional_dispatch(prop, "prop") ) + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_entity_list(self) -> EntList: """ Create a new entity list. diff --git a/src/moldflow/string_array.py b/src/moldflow/string_array.py index dcb953d..c3d56c9 100644 --- a/src/moldflow/string_array.py +++ b/src/moldflow/string_array.py @@ -6,6 +6,7 @@ StringArray Class API Wrapper """ +from .cli_input_metadata import CLI_VALUE_KIND_LIST_VALUES, cli_input_adapter from .logger import process_log from .helper import check_type, _mf_array_to_list from .com_proxy import safe_com, flag_com_method @@ -63,6 +64,7 @@ def to_list(self) -> list[str]: process_log(__name__, LogMessage.FUNCTION_CALL, locals(), name="to_list") return _mf_array_to_list(self) + @cli_input_adapter(value_kind=CLI_VALUE_KIND_LIST_VALUES, shorthand_supported=True) def from_list(self, values: list[str]) -> int: """ Convert a list of strings to a string array. diff --git a/src/moldflow/study_doc.py b/src/moldflow/study_doc.py index 75fe885..125ea63 100644 --- a/src/moldflow/study_doc.py +++ b/src/moldflow/study_doc.py @@ -7,6 +7,7 @@ """ from .ent_list import EntList +from .cli_input_metadata import CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY, cli_hidden from .import_options import ImportOptions from .logger import process_log from .helper import ( @@ -39,6 +40,7 @@ def __init__(self, _study_doc): process_log(__name__, LogMessage.CLASS_INIT, locals(), name="StudyDoc") self.study_doc = safe_com(_study_doc) + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_entity_list(self) -> EntList: """ Creates a new entity list. diff --git a/src/moldflow/synergy.py b/src/moldflow/synergy.py index d8c0e72..3a08645 100644 --- a/src/moldflow/synergy.py +++ b/src/moldflow/synergy.py @@ -8,6 +8,7 @@ import os import win32com.client +from .cli_input_metadata import CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY, cli_hidden from .boundary_conditions import BoundaryConditions from .cad_diagnostic import CADDiagnostic from .cad_manager import CADManager @@ -216,6 +217,7 @@ def import_file( file, import_options.import_options, show_logs, show_prompts ) + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_vector(self) -> Vector: """ Create a Vector object. @@ -229,6 +231,7 @@ def create_vector(self) -> Vector: return None return Vector(result) + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_vector_array(self) -> VectorArray: """ Create a VectorArray object. @@ -242,6 +245,7 @@ def create_vector_array(self) -> VectorArray: return None return VectorArray(result) + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_double_array(self) -> DoubleArray: """ Create a DoubleArray object. @@ -255,6 +259,7 @@ def create_double_array(self) -> DoubleArray: return None return DoubleArray(result) + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_integer_array(self) -> IntegerArray: """ Create a IntegerArray object. @@ -268,6 +273,7 @@ def create_integer_array(self) -> IntegerArray: return None return IntegerArray(result) + @cli_hidden(reason=CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY) def create_string_array(self) -> StringArray: """ Create a StringArray instance. diff --git a/src/moldflow/vector.py b/src/moldflow/vector.py index 1b603b8..f2153ac 100644 --- a/src/moldflow/vector.py +++ b/src/moldflow/vector.py @@ -6,6 +6,7 @@ Vector Class API Wrapper """ +from .cli_input_metadata import CLI_VALUE_KIND_VECTOR_TRIPLET, cli_input_adapter from .helper import check_type from .com_proxy import safe_com from .logger import process_log, LogMessage @@ -26,6 +27,9 @@ def __init__(self, _vector): process_log(__name__, LogMessage.CLASS_INIT, locals(), name="Vector") self.vector = safe_com(_vector) + @cli_input_adapter( + value_kind=CLI_VALUE_KIND_VECTOR_TRIPLET, preferred_field="xyz", shorthand_supported=True + ) def set_xyz(self, x: float, y: float, z: float) -> None: """ Set the x, y, z values of the vector. diff --git a/src/moldflow/vector_array.py b/src/moldflow/vector_array.py index 3593e4c..bb29575 100644 --- a/src/moldflow/vector_array.py +++ b/src/moldflow/vector_array.py @@ -6,6 +6,7 @@ VectorArray Class API Wrapper """ +from .cli_input_metadata import CLI_VALUE_KIND_VECTOR_ARRAY_VALUES, cli_input_adapter from .helper import check_type, check_index from .com_proxy import safe_com from .logger import process_log, LogMessage @@ -33,6 +34,11 @@ def clear(self) -> None: process_log(__name__, LogMessage.FUNCTION_CALL, locals(), name="clear") self.vector_array.Clear() + @cli_input_adapter( + value_kind=CLI_VALUE_KIND_VECTOR_ARRAY_VALUES, + preferred_field="xyz", + shorthand_supported=True, + ) def add_xyz(self, x: float, y: float, z: float) -> None: """ Add a vector to the array with x, y, z values. diff --git a/src/moldflow_cli/__init__.py b/src/moldflow_cli/__init__.py new file mode 100644 index 0000000..d0a32d2 --- /dev/null +++ b/src/moldflow_cli/__init__.py @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +__all__ = [] + + diff --git a/src/moldflow_cli/__main__.py b/src/moldflow_cli/__main__.py new file mode 100644 index 0000000..4fa62a9 --- /dev/null +++ b/src/moldflow_cli/__main__.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from moldflow.i18n import get_text + + +def _install_cli_localization() -> None: + """Best-effort localization bootstrap for CLI help and errors.""" + try: + from moldflow.localization import set_language + + set_language() + except (ImportError, OSError, ValueError, RuntimeError): + # Keep CLI functional even when locale probing/installation is unavailable. + return + +def _ensure_utf8_stdio() -> None: + """Best-effort UTF-8 stdio for predictable CLI automation output.""" + import sys + + for stream_name in ("stdout", "stderr"): + stream = getattr(sys, stream_name, None) + if stream is None or not hasattr(stream, "reconfigure"): + continue + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, OSError, TypeError, ValueError): + # Keep CLI functional even if the host stream disallows reconfiguration. + continue + + +def main() -> None: + """ + Entrypoint for the 'moldflow' console script. + - Imports optional CLI deps lazily. + - Provides a helpful message if extras are missing. + """ + _ensure_utf8_stdio() + _install_cli_localization() + _ = get_text() + try: + # Imported only when the CLI is invoked so library-only installs stay lean. + import importlib + importlib.import_module("typer") + importlib.import_module("rich") + except ModuleNotFoundError as exc: + missing_name = (getattr(exc, "name", None) or "").split(".")[0] + missing_cli_dep = missing_name in {"typer", "rich"} or any( + f"No module named '{dep}'" in str(exc) for dep in ("typer", "rich") + ) + if missing_cli_dep: + print( + _( + "The Moldflow CLI requires optional dependencies. " + "Install them with: pip install 'moldflow[cli]'" + ) + ) + raise SystemExit(1) from exc + raise + + app = _build_app() + app() + + +def _build_app(): + from .commands import build_cli_app + return build_cli_app() + + +if __name__ == "__main__": + main() + diff --git a/src/moldflow_cli/commands.py b/src/moldflow_cli/commands.py new file mode 100644 index 0000000..3b70ffc --- /dev/null +++ b/src/moldflow_cli/commands.py @@ -0,0 +1,650 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any +import inspect +import re + +import typer +from moldflow.i18n import get_text + +from .constants import ( + CLI_MODE_PROPERTY_ASSIGNMENT, + CLI_ROOT_MOLDFLOW, + CLI_ROOT_SYNERGY, +) +from .introspection import ( + get_docstring, + get_signature_string, + split_structured_doc, +) +from .invoke_handlers import ( + SCHEMA_VERSION, + build_invoke_template_for_target, + invoke_cmd, +) +from .listing import ( + collect_list_rows, + has_list_filters, + human_list_kind, + human_list_target, + render_filtered_list_matches, +) +from .output_utils import ( + configure_console, + emit_yaml_text, + get_console, + human_output_pager, + human_table_kwargs, + should_use_ascii_output, + to_json_text, +) +from .presentation import human_signature_text as _shared_human_signature_text +from .presentation import render_input_hints_summary as _shared_render_input_hints_summary +from .presentation import render_workflow_examples as _shared_render_workflow_examples +from .target_resolution import ( + canonicalize_describe_target_parts, + resolve_describe_read_target, +) +from .type_annotations import format_annotation_text, format_signature_for_display + + +_T = get_text() + + +__all__ = [ + "build_cli_app", + "collect_list_rows", + "describe_cmd", + "invoke_cmd", + "list_public_cmd", + "version_cmd", +] + + +def _configure_typer_rich_output(*, no_color: bool) -> None: + """Align Typer's Rich help/error rendering with Moldflow CLI output settings.""" + import typer.rich_utils as rich_utils + from rich import box + + if not hasattr(rich_utils, "_moldflow_original_panel"): + rich_utils._moldflow_original_panel = rich_utils.Panel + rich_utils._moldflow_original_color_system = getattr(rich_utils, "COLOR_SYSTEM", None) + + original_panel = rich_utils._moldflow_original_panel + original_color_system = rich_utils._moldflow_original_color_system + use_ascii = should_use_ascii_output() or should_use_ascii_output(stderr=True) + + if use_ascii: + def _ascii_panel(*args: Any, **kwargs: Any): + kwargs.setdefault("box", box.ASCII) + return original_panel(*args, **kwargs) + + rich_utils.Panel = _ascii_panel + else: + rich_utils.Panel = original_panel + + rich_utils.COLOR_SYSTEM = None if no_color else original_color_system + + +def _configure_global_output_options(ctx: typer.Context, no_color: bool) -> None: + """Apply global CLI output settings before help/errors are rendered.""" + configure_console(no_color=no_color) + _configure_typer_rich_output(no_color=no_color) + ctx.color = not no_color + + +def _canonical_annotation_text(annotation: Any) -> str | None: + """Return stable, tool-friendly annotation text.""" + return format_annotation_text(annotation) + + +_RST_FIELD_LINE = re.compile(r"^:[A-Za-z_][\w-]*:\s*") + + +_MAX_FILTERED_MATCH_SUMMARY_ROWS = 10 + + +def _normalize_human_doc(doc: str, *, obj_type: str | None) -> str: + """Clean doc text for terminal display without changing structured output.""" + if not doc: + return doc + lines = [line.rstrip() for line in doc.splitlines()] + if obj_type == "property": + lines = [line for line in lines if not _RST_FIELD_LINE.match(line.strip())] + cleaned: list[str] = [] + previous_blank = False + for line in lines: + is_blank = not line.strip() + if is_blank and previous_blank: + continue + cleaned.append(line) + previous_blank = is_blank + while cleaned and not cleaned[0].strip(): + cleaned.pop(0) + while cleaned and not cleaned[-1].strip(): + cleaned.pop() + return "\n".join(cleaned) + + +def _validate_describe_target_parts(target_parts: list[str], target: str) -> str: + return canonicalize_describe_target_parts(target_parts, target=target, translate=_T) + + +def _validate_describe_output_modes( + json_output: bool, + yaml_output: bool, + schema_output: bool, +) -> None: + if (json_output and yaml_output) or (schema_output and (json_output or yaml_output)): + raise typer.BadParameter(_T("Only one of --json, --yaml, or --schema may be specified.")) + + +def _safe_describe_template_payload(target: str) -> dict[str, Any]: + """Best-effort invoke guidance for describe output without weakening introspection.""" + candidates = [target] + normalized_target = target.strip() + if normalized_target.lower().startswith(f"{CLI_ROOT_MOLDFLOW}."): + bare_target = normalized_target.split(".", 1)[1] + if not bare_target.lower().startswith(f"{CLI_ROOT_SYNERGY}."): + candidates.append(f"{CLI_ROOT_SYNERGY}.{bare_target}") + candidates.append(f"{CLI_ROOT_MOLDFLOW}.{CLI_ROOT_SYNERGY}.{bare_target}") + elif not normalized_target.lower().startswith(f"{CLI_ROOT_SYNERGY}."): + candidates.append(f"{CLI_ROOT_SYNERGY}.{normalized_target}") + + for candidate in candidates: + try: + return build_invoke_template_for_target(candidate) + except (AttributeError, TypeError, ValueError, typer.BadParameter): + continue + + return { + "schema_version": SCHEMA_VERSION, + "target": target, + "workflow_examples": {}, + "params_json_template": {}, + } + + +def _human_signature_text(signature_value: str | None) -> str: + """Remove return annotations from human-facing signature strings.""" + return _shared_human_signature_text(signature_value) + + +def _structured_return_annotation(annotation: Any) -> str | None: + """Return a structured return-type label without Python signature punctuation.""" + if annotation is inspect.Parameter.empty or annotation is None: + return None + text = _canonical_annotation_text(annotation) + if not isinstance(text, str): + return text + if text.strip().strip("'\"") in {"None", "NoneType"}: + return None + return text + + +def _strip_receiver_from_signature(sig_obj: inspect.Signature | None) -> inspect.Signature | None: + """Remove an implicit self/cls receiver from an inspect.Signature.""" + if sig_obj is None: + return None + params = list(sig_obj.parameters.values()) + if params and params[0].name in {"self", "cls"}: + return sig_obj.replace(parameters=params[1:]) + return sig_obj + + +def _describe_signature_payload( + obj: Any, + sig: str, +) -> tuple[str | None, str | None, list[dict[str, Any]], list[str], dict[str, Any], str | None]: + try: + sig_obj = _strip_receiver_from_signature(inspect.signature(obj)) + except (TypeError, ValueError): + sig_obj = None + + _ = sig + signature_value = None + return_value = None + params: list[dict[str, Any]] = [] + required_params: list[str] = [] + properties: dict[str, Any] = {} + obj_type: str | None = None + + if sig_obj is None: + if isinstance(obj, property): + obj_type = "property" + elif not callable(obj): + obj_type = "attribute" + return signature_value, return_value, params, required_params, properties, obj_type + + signature_value = format_signature_for_display(sig_obj, empty_as_none=True) + return_value = _structured_return_annotation(sig_obj.return_annotation) + + for name, param in sig_obj.parameters.items(): + annotation = _canonical_annotation_text(param.annotation) + default_value = repr(param.default) if param.default is not inspect._empty else None + params.append( + { + "name": name, + "annotation": annotation, + "default": default_value, + } + ) + properties[name] = { + "kind": param.kind.name, + "annotation": annotation, + "default": default_value, + } + if ( + param.default is inspect._empty + and param.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) + ): + required_params.append(name) + + return signature_value, return_value, params, required_params, properties, obj_type + + +def _build_describe_structured_output( + *, + normalized_target: str, + summary_value: str | None, + details_value: str | None, + signature_value: str | None, + return_value: str | None, + params: list[dict[str, Any]], + required_params: list[str], + properties: dict[str, Any], + obj_type: str | None, + template_payload: dict[str, Any], + schema_output: bool, +) -> dict[str, Any]: + structured_params = list(params) + mode = template_payload.get("mode") + if not structured_params and mode == CLI_MODE_PROPERTY_ASSIGNMENT: + steps = template_payload.get("steps") + if isinstance(steps, list) and steps: + first_step = steps[0] + step_params = first_step.get("params") if isinstance(first_step, dict) else None + if isinstance(step_params, list): + for param in step_params: + if not isinstance(param, dict): + continue + param_name = param.get("name") + if not isinstance(param_name, str) or not param_name: + continue + structured_params.append( + { + "name": param_name, + "annotation": param.get("annotation"), + "default": param.get("default"), + "synthetic": True, + } + ) + out = { + "schema_version": SCHEMA_VERSION, + "target": normalized_target, + "signature": signature_value, + "summary": summary_value, + "params": structured_params, + "params_json_template": template_payload.get("params_json_template", {}), + "invoke_examples": template_payload.get("workflow_examples", {}), + } + next_command = _next_command_from_template_payload(template_payload) + if isinstance(next_command, str) and next_command: + out["next_command"] = next_command + if details_value is not None: + out["details"] = details_value + if return_value is not None: + out["returns"] = return_value + if obj_type is not None: + out["type"] = obj_type + terminal_target = template_payload.get("terminal_target") + if isinstance(terminal_target, dict): + out["terminal_target"] = terminal_target + if isinstance(mode, str): + out["mode"] = mode + + if schema_output: + return { + "schema_version": SCHEMA_VERSION, + "target": normalized_target, + "type": "object", + "required": required_params, + "properties": properties, + "examples": template_payload.get("workflow_examples", {}), + } + return out + + +def _next_command_from_template_payload(template_payload: dict[str, Any]) -> str | None: + """Return the best machine-friendly next command from a describe/template payload.""" + workflow_examples = template_payload.get("workflow_examples") + if not isinstance(workflow_examples, dict): + return None + for key in ( + "cli_command", + "preferred_cli_command", + "read_command", + "read_cli_command", + "minimal_command", + "minimal_cli_command", + ): + value = workflow_examples.get(key) + if isinstance(value, str) and value: + return value + return None + + +def _list_structured_rows( + rows: list[dict[str, Any]], + *, + include_describe: bool, + max_results: int | None, +) -> list[dict[str, Any]]: + """Return list rows optionally enriched with structured describe metadata.""" + if isinstance(max_results, int): + rows = rows[:max_results] + structured_rows: list[dict[str, Any]] = [] + for row in rows: + structured_row = dict(row) + next_command = structured_row.get("suggested_command") + if isinstance(next_command, str) and next_command: + structured_row["next_command"] = next_command + if include_describe: + describe_payload = _describe_target(str(row["target"])) + structured_row["describe"] = _build_describe_structured_output( + normalized_target=describe_payload["normalized_target"], + summary_value=describe_payload["summary_value"], + details_value=describe_payload["details_value"], + signature_value=describe_payload["signature_value"], + return_value=describe_payload["return_value"], + params=describe_payload["params"], + required_params=describe_payload["required_params"], + properties=describe_payload["properties"], + obj_type=describe_payload["obj_type"], + template_payload=describe_payload["template_payload"], + schema_output=False, + ) + structured_rows.append(structured_row) + return structured_rows + + +def version_cmd() -> None: + "Print the installed moldflow package version." + import moldflow + + typer.echo(moldflow.__version__) + + +def list_public_cmd( + filter_: list[str] = typer.Option( + None, + "--filter", + "-f", + help=_T( + "Filter by substring or wildcard pattern (* and ?). Repeat to keep targets matching any filter." + ), + ), + json_output: bool = typer.Option( + False, + "--json", + help=_T("Emit structured JSON for scripting or agent use."), + ), + yaml_output: bool = typer.Option( + False, + "--yaml", + help=_T("Emit structured YAML for scripting or agent use (requires PyYAML)."), + ), + include_describe: bool = typer.Option( + False, + "--with-describe", + help=_T("Include describe-style metadata for each listed target (JSON/YAML only)."), + ), + max_results: int | None = typer.Option( + None, + "--max-results", + help=_T("Limit the number of targets returned after filtering."), + ), +) -> None: + "List directly invokable targets exported by moldflow." + from rich.table import Table + + if json_output and yaml_output: + raise typer.BadParameter(_T("Only one of --json or --yaml may be specified.")) + if include_describe and not (json_output or yaml_output): + raise typer.BadParameter(_T("--with-describe requires --json or --yaml.")) + if isinstance(max_results, int) and max_results < 0: + raise typer.BadParameter(_T("--max-results must be greater than or equal to 0.")) + + rows = collect_list_rows(filter_) + if isinstance(max_results, int): + rows = rows[:max_results] + has_filters = has_list_filters(filter_) + if json_output or yaml_output: + structured_rows = _list_structured_rows( + rows, + include_describe=include_describe, + max_results=max_results, + ) + if json_output: + typer.echo(to_json_text(structured_rows, context="list")) + return + typer.echo(emit_yaml_text(structured_rows, context="list")) + return + + console = get_console() + with human_output_pager(console): + table = Table( + title=_T("Moldflow invokable targets"), + expand=True, + **human_table_kwargs(console), + ) + table.add_column(_T("Target"), overflow="fold", ratio=6) + table.add_column(_T("Type"), overflow="fold", ratio=2, min_width=13) + for row in rows: + table.add_row( + human_list_target(row), + human_list_kind(row), + ) + console.print(table) + if has_filters and not rows: + message = _T("No invokable targets matched this filter.") + if len([value for value in filter_ if value]) > 1: + message = _T("No invokable targets matched these filters.") + console.print(message, markup=False) + console.print( + _T("Try a broader --filter, or rerun list --json for canonical target strings."), + markup=False, + ) + return + if any(row.get("target", "").lower().startswith(f"{CLI_ROOT_SYNERGY}.") for row in rows): + console.print( + _T("Targets are shown without the leading 'synergy.' prefix. Describe and invoke accept either form."), + markup=False, + ) + if has_filters: + render_filtered_list_matches(console, rows) + if len(rows) > _MAX_FILTERED_MATCH_SUMMARY_ROWS: + console.print( + _T( + "Showing the compact table for {count} filtered matches. Narrow the filter or use --json for canonical target strings." + ).format(count=len(rows)), + markup=False, + ) + if isinstance(max_results, int) and rows: + console.print( + _T("Showing up to {count} targets due to --max-results.").format(count=max_results), + markup=False, + ) + console.print( + _T( + "Use describe to inspect parameters, examples, and property behavior before invoking." + ), + markup=False, + ) + + +def _describe_target(target: str) -> dict[str, Any]: + """Resolve one describe target into shared human and structured metadata.""" + resolved_target = resolve_describe_read_target(target, translate=_T) + normalized_target = resolved_target.canonical_target + obj = resolved_target.resolved_object + sig = get_signature_string(obj) + signature_value, return_value, params, required_params, properties, obj_type = _describe_signature_payload(obj, sig) + doc = get_docstring(obj) + human_doc = _normalize_human_doc(doc, obj_type=obj_type) if doc else doc + structured_summary, structured_details = split_structured_doc(doc, obj_type=obj_type) if doc else (None, None) + template_payload = _safe_describe_template_payload(normalized_target) + return { + "normalized_target": normalized_target, + "signature_value": signature_value, + "return_value": return_value, + "params": params, + "required_params": required_params, + "properties": properties, + "obj_type": obj_type, + "human_doc": human_doc, + "summary_value": structured_summary, + "details_value": structured_details, + "template_payload": template_payload, + } + + +def _render_describe_target(console: Any, describe_payload: dict[str, Any]) -> None: + """Render one describe target in human-readable form.""" + console.print( + f"{describe_payload['normalized_target']}{_human_signature_text(describe_payload['signature_value'])}", + markup=False, + ) + human_doc = describe_payload.get("human_doc") + if human_doc: + console.print(human_doc, markup=False) + workflow_examples = describe_payload["template_payload"].get("workflow_examples") + if isinstance(workflow_examples, dict): + _render_describe_workflow_examples(console, workflow_examples) + _render_input_hints_summary(console, describe_payload["template_payload"]) + + +def _render_describe_workflow_examples(console: Any, workflow_examples: dict[str, Any]) -> None: + """Render preferred and minimal invoke examples for describe human output.""" + _shared_render_workflow_examples( + console, + workflow_examples, + params_json_context="describe-example", + minimal_params_json_context="describe-example-minimal", + ) + + +def _render_input_hints_summary(console: Any, payload: dict[str, Any]) -> None: + """Compatibility wrapper for shared input-hint rendering.""" + _shared_render_input_hints_summary(console, payload) + + +def describe_cmd( + targets: list[str] = typer.Argument( + ..., + help=_T("One or more dotted targets, for example synergy.new_project."), + ), + json_output: bool = typer.Option( + False, + "--json", + help=_T("Emit structured JSON for scripting or agent use."), + ), + yaml_output: bool = typer.Option( + False, + "--yaml", + help=_T("Emit structured YAML for scripting or agent use (requires PyYAML)."), + ), + schema_output: bool = typer.Option( + False, + "--schema", + help=_T("Emit a JSON schema-like representation of target parameters."), + ), +) -> None: + "Show signature and documentation for one or more targets." + _validate_describe_output_modes(json_output, yaml_output, schema_output) + describe_payloads = [_describe_target(target) for target in targets] + + if json_output or yaml_output or schema_output: + structured_output: dict[str, Any] | list[dict[str, Any]] = [ + _build_describe_structured_output( + normalized_target=describe_payload["normalized_target"], + summary_value=describe_payload["summary_value"], + details_value=describe_payload["details_value"], + signature_value=describe_payload["signature_value"], + return_value=describe_payload["return_value"], + params=describe_payload["params"], + required_params=describe_payload["required_params"], + properties=describe_payload["properties"], + obj_type=describe_payload["obj_type"], + template_payload=describe_payload["template_payload"], + schema_output=schema_output, + ) + for describe_payload in describe_payloads + ] + if len(structured_output) == 1: + structured_output = structured_output[0] + if json_output or schema_output: + context = "describe-schema" if schema_output else "describe" + typer.echo(to_json_text(structured_output, context=context)) + else: + typer.echo(emit_yaml_text(structured_output, context="describe")) + return + + console = get_console() + with human_output_pager(console): + for index, describe_payload in enumerate(describe_payloads): + if index: + console.print() + _render_describe_target(console, describe_payload) + + +def build_cli_app(): + configure_console(no_color=False) + _configure_typer_rich_output(no_color=False) + app = typer.Typer( + help=_T( + "Moldflow command-line interface.\n\n" + "Start with 'list' to discover targets, 'describe ' to inspect usage, " + "then 'invoke ...' to run it." + ) + ) + + @app.callback() + def _cli_callback( + ctx: typer.Context, + no_color: bool = typer.Option( + False, + "--no-color", + help=_T("Disable ANSI color/styling in CLI output."), + is_eager=True, + ), + ) -> None: + """Apply global CLI options before dispatching subcommands.""" + _configure_global_output_options(ctx, no_color) + + app.command("version", help=_T("Print the installed moldflow package version."))(version_cmd) + app.command( + "list", + help=_T("Discover invokable targets and the next command to run for each one."), + )( + list_public_cmd + ) + app.command( + "describe", + help=_T("Inspect a target's signature, docs, examples, and structured invoke template."), + )(describe_cmd) + app.command( + "invoke", + help=_T( + "Run a Moldflow target with named parameters or JSON input. Bare targets are treated as synergy.." + ), + )(invoke_cmd) + + from .repl import repl_cmd + + app.command("repl", help=_T("Start an interactive moldflow shell session."))(repl_cmd) + return app + diff --git a/src/moldflow_cli/constants.py b/src/moldflow_cli/constants.py new file mode 100644 index 0000000..36138d6 --- /dev/null +++ b/src/moldflow_cli/constants.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +CLI_FIELD_VALUE = "value" +CLI_INPUT_SOURCE_JSON = "json" +CLI_KIND_PROPERTY = "property" +CLI_KIND_PROPERTY_GETTER = "property_getter" +CLI_KIND_PROPERTY_SETTER_ONLY = "property_setter_only" +CLI_KIND_SETTABLE_PROPERTY = "settable_property" +CLI_MODE_PROPERTY_ASSIGNMENT = "property_assignment" +CLI_ROOT_MOLDFLOW = "moldflow" +CLI_ROOT_SYNERGY = "synergy" diff --git a/src/moldflow_cli/context.py b/src/moldflow_cli/context.py new file mode 100644 index 0000000..f069767 --- /dev/null +++ b/src/moldflow_cli/context.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging + +_logger = logging.getLogger(__name__) + +_synergy_singleton = None + + +def get_synergy() -> "object": + """ + Lazily create and cache a moldflow.Synergy instance for the current process. + We avoid importing moldflow at module import time to keep base installs lean. + """ + global _synergy_singleton + if _synergy_singleton is None: + import moldflow # imported lazily + + # Default construction; advanced options could be exposed via CLI flags later. + _synergy_singleton = moldflow.Synergy() + return _synergy_singleton + + +def reset_synergy() -> None: + """Close the active Synergy application and discard the cached instance. + + The next call to :func:`get_synergy` will launch a fresh Synergy session. + If no instance is cached the call is a no-op. + """ + global _synergy_singleton + if _synergy_singleton is not None: + try: + _synergy_singleton.quit(prompt_save=True) + except Exception: + # COM may already be disconnected — ignore and drop the reference. + _logger.debug("Ignoring error while quitting Synergy", exc_info=True) + _synergy_singleton = None + + diff --git a/src/moldflow_cli/factories.py b/src/moldflow_cli/factories.py new file mode 100644 index 0000000..2f4e33f --- /dev/null +++ b/src/moldflow_cli/factories.py @@ -0,0 +1,362 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Wrapper-construction helpers for CLI JSON and nested argument binding.""" + +from __future__ import annotations + +from collections.abc import Iterator +from types import NoneType +from typing import Any +import inspect +import json as _json +import re + +from moldflow.i18n import get_text + +from .context import get_synergy +from .type_annotations import extract_non_none_type_names +from .wrapper_input_adapters import apply_wrapper_input_adapter +from .wrapper_registry import build_target_input_hints + + +_MISSING = object() +_T = get_text() + + +def _tr(message: str, **kwargs: Any) -> str: + text = _T(message) + return text.format(**kwargs) if kwargs else text + + +def _matching_wrapper_type_metadata( + obj: Any, + data: dict[str, Any], + *, + expected_type: str | None = None, +) -> str | None: + """Return a matching wrapper type tag when JSON metadata agrees with the target wrapper.""" + explicit_type = data.get("__type__") + alias_type = data.get("type") if isinstance(data.get("type"), str) else None + type_tag = explicit_type if isinstance(explicit_type, str) else alias_type + if type_tag is None: + return None + expected_names = {type(obj).__name__} + if isinstance(expected_type, str) and expected_type.strip(): + expected_names.add(expected_type.strip()) + if not type_tag.strip(): + primary_expected = expected_type or type(obj).__name__ + raise ValueError( + _tr( + "Empty type tag is not valid for '{primary_expected}'.", + primary_expected=primary_expected, + ) + ) + normalized_tag = type_tag.strip().lower() + if all(normalized_tag != candidate.lower() for candidate in expected_names): + primary_expected = expected_type or type(obj).__name__ + raise ValueError( + _tr( + "JSON type tag '{type_tag}' does not match expected wrapper '{primary_expected}'.", + type_tag=type_tag, + primary_expected=primary_expected, + ) + ) + return type_tag + + +def _is_none_annotation(value: Any) -> bool: + """Return True when a type annotation represents NoneType.""" + return value is NoneType + + +def camel_to_snake(name: str) -> str: + """ + Best-effort CamelCase to snake_case converter for mapping class names + to Synergy properties (e.g., ImportOptions -> import_options). + """ + # Split at acronym-to-word boundaries and lower-to-upper transitions: + # CADManager -> cad_manager, ImportOptions -> import_options. + stage1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name) + stage2 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", stage1) + return stage2.lower() + + +def _safe_getattr(obj: Any, attr_name: str) -> Any: + try: + return getattr(obj, attr_name) + except (AttributeError, TypeError, ValueError, RuntimeError): + return None + + +def _get_public_attr_value(obj: Any, attr_name: str) -> Any: + if attr_name.startswith("_"): + return _MISSING + obj_dict = getattr(obj, "__dict__", {}) + if attr_name in obj_dict: + value = obj_dict[attr_name] + return value if not callable(value) else _MISSING + try: + descriptor = inspect.getattr_static(type(obj), attr_name) + except AttributeError: + return _MISSING + if isinstance(descriptor, property) or hasattr(obj, attr_name): + try: + value = getattr(obj, attr_name) + except (AttributeError, TypeError, ValueError, RuntimeError): + return _MISSING + return value if not callable(value) else _MISSING + return _MISSING + + +def _can_set_public_attr(obj: Any, attr_name: str) -> bool: + if attr_name.startswith("_"): + return False + if attr_name in getattr(obj, "__dict__", {}): + return True + try: + descriptor = inspect.getattr_static(type(obj), attr_name) + except AttributeError: + return False + if isinstance(descriptor, property): + return descriptor.fset is not None + if not hasattr(obj, attr_name): + return False + value = _safe_getattr(obj, attr_name) + return value is not None and not callable(value) + + +def _is_nested_wrapper_candidate(value: Any) -> bool: + return value is not _MISSING and value is not None and not isinstance( + value, + (str, int, float, bool, list, tuple, dict, set), + ) + + +def _format_json_snippet(value: Any) -> str: + try: + return _json.dumps(value, ensure_ascii=True) + except (TypeError, ValueError): + return repr(value) + + +def _wrapper_input_guidance(obj: Any) -> str | None: + hints = build_target_input_hints(obj) + if not isinstance(hints, dict): + return None + friendly_json_input = hints.get("friendly_json_input") + examples = hints.get("examples") + guidance_parts: list[str] = [] + if isinstance(friendly_json_input, dict): + preferred_field = friendly_json_input.get("preferred_field") + if isinstance(preferred_field, str) and preferred_field: + guidance_parts.append( + _tr("Use JSON field '{preferred_field}'.", preferred_field=preferred_field) + ) + if isinstance(examples, dict): + preferred_param_value = examples.get("preferred_param_value") + if isinstance(preferred_param_value, dict) and preferred_param_value: + guidance_parts.append( + _tr( + "Example object template: {shape}.", + shape=_format_json_snippet(preferred_param_value), + ) + ) + preferred_non_json = examples.get("preferred_non_json") + if isinstance(preferred_non_json, str) and preferred_non_json: + guidance_parts.append( + _tr( + "In non-JSON mode, prefer direct shorthand like '{preferred_non_json}'.", + preferred_non_json=preferred_non_json, + ) + ) + return " ".join(guidance_parts) if guidance_parts else None + + +def _unknown_wrapper_field_message(obj: Any, key_name: str) -> str: + message = _tr( + "Field '{key_name}' is not valid for '{type_name}'.", + key_name=key_name, + type_name=type(obj).__name__, + ) + guidance = _wrapper_input_guidance(obj) + if guidance: + return f"{message} {guidance}" + return message + + +def _iter_public_attr_names(obj: Any) -> Iterator[str]: + seen: set[str] = set() + for name in vars(obj): + if name.startswith("_"): + continue + seen.add(name) + yield name + for cls in type(obj).__mro__: + for name, descriptor in vars(cls).items(): + if name.startswith("_") or name in seen or not isinstance(descriptor, property): + continue + seen.add(name) + yield name + + +def _iter_synergy_related_objects(synergy: Any) -> Iterator[Any]: + seen_ids = {id(synergy)} + yield synergy + for attr_name in _iter_public_attr_names(synergy): + candidate = _safe_getattr(synergy, attr_name) + if candidate is None: + continue + candidate_id = id(candidate) + if candidate_id in seen_ids: + continue + seen_ids.add(candidate_id) + yield candidate + + +def _build_from_synergy_property(synergy: Any, type_name: str) -> Any: + attr_name = camel_to_snake(type_name) + if hasattr(synergy, attr_name): + return getattr(synergy, attr_name) + return None + + +def _build_ent_list_instance(synergy: Any) -> Any: + for provider in _iter_synergy_related_objects(synergy): + factory = _safe_getattr(provider, "create_entity_list") + if callable(factory): + return factory() + raise ValueError( + _T("Cannot build instance for type 'EntList'. No create_entity_list provider found.") + ) + + +def _build_from_synergy_factory_method(synergy: Any, type_name: str) -> Any: + factory_name = f"create_{camel_to_snake(type_name)}" + factory = _safe_getattr(synergy, factory_name) + if not callable(factory): + return None + return factory() + + +def build_wrapper_instance(type_hint: Any) -> Any: + """ + Create an instance of a wrapper class that normally comes from Synergy. + This is contextual: we resolve via Synergy properties or factory methods. + """ + type_names = extract_non_none_type_names(type_hint) + if len(type_names) == 1: + type_name = type_names[0] + elif isinstance(type_hint, str): + type_name = type_hint.strip().strip("'\"") + else: + type_name = getattr(type_hint, "__name__", str(type_hint)) + + synergy = get_synergy() + instance = _build_from_synergy_property(synergy, type_name) + if instance is not None: + return instance + + if type_name == "EntList": + return _build_ent_list_instance(synergy) + + instance = _build_from_synergy_factory_method(synergy, type_name) + if instance is not None: + return instance + + raise ValueError( + _tr( + "Cannot build instance for type '{type_name}'. " + "Not a known Synergy property or factory.", + type_name=type_name, + ) + ) + + +def configure_object_from_dict( + obj: Any, + data: dict[str, Any], + *, + expected_type: str | None = None, +) -> Any: + """ + Set attributes on wrapper objects using simple assignment/property setters. + Supports nested constructions for values shaped as {'__type__': str, ...}. + """ + _matching_wrapper_type_metadata(obj, data, expected_type=expected_type) + applied_adapters: dict[str, str] = {} + for key, value in data.items(): + key_name = str(key) + if key_name == "__type__": + continue + if key_name == "type" and isinstance(value, str): + continue + if apply_wrapper_input_adapter( + obj, + key_name, + value, + applied_adapters=applied_adapters, + ): + continue + if key_name.startswith("_"): + raise ValueError( + _tr( + "Non-public field '{key_name}' is not allowed " + "when constructing '{type_name}' from JSON.", + key_name=key_name, + type_name=type(obj).__name__, + ) + ) + existing_attr = _get_public_attr_value(obj, key_name) + if ( + isinstance(value, dict) + and "__type__" not in value + and _is_nested_wrapper_candidate(existing_attr) + ): + try: + configure_object_from_dict(existing_attr, value) + except (AttributeError, TypeError, ValueError, RecursionError) as exc: + raise ValueError( + _tr( + "Cannot configure field '{key_name}' on '{type_name}': {error}", + key_name=key_name, + type_name=type(obj).__name__, + error=exc, + ) + ) from exc + if _can_set_public_attr(obj, key_name): + setattr(obj, key_name, existing_attr) + continue + if not _can_set_public_attr(obj, key_name): + raise ValueError(_unknown_wrapper_field_message(obj, key_name)) + try: + setattr(obj, key, convert_value(value)) + except (AttributeError, TypeError, ValueError, RecursionError) as exc: + guidance = _wrapper_input_guidance(obj) + if guidance: + raise ValueError(f"{exc} {guidance}") from exc + raise + return obj + + +def convert_value(value: Any) -> Any: + """ + Convert a value possibly containing nested typed dicts into wrapper instances. + Accepted shapes: + - primitives (str, int, float, bool, None) + - lists/tuples of primitives or nested objects + - dicts: either plain mappings passed as-is, or typed objects: + {'__type__': 'ImportOptions', ...} + """ + if isinstance(value, (str, int, float, bool)) or value is None: + return value + if isinstance(value, (list, tuple)): + return [convert_value(v) for v in value] + if isinstance(value, dict): + type_tag = value.get("__type__") + if type_tag: + instance = build_wrapper_instance(type_tag) + return configure_object_from_dict(instance, value, expected_type=type_tag) + # Plain dict; pass through + return {k: convert_value(v) for k, v in value.items()} + return value diff --git a/src/moldflow_cli/introspection.py b/src/moldflow_cli/introspection.py new file mode 100644 index 0000000..4e72a1e --- /dev/null +++ b/src/moldflow_cli/introspection.py @@ -0,0 +1,410 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import importlib +import inspect +import re +from functools import lru_cache +from typing import Any + +from moldflow.i18n import get_text + +from .constants import CLI_ROOT_MOLDFLOW +from .target_resolution import resolve_attr_name_case_insensitive +from .type_annotations import extract_non_none_type_names, format_annotation_text + + +_RST_FIELD_LINE = re.compile(r"^:[A-Za-z_][\w-]*:\s*") +_T = get_text() + + +def _tr(message: str, **kwargs: Any) -> str: + text = _T(message) + return text.format(**kwargs) if kwargs else text + + +class HiddenCliTargetError(ValueError): + """Raised when a target resolves through a member intentionally hidden from the CLI.""" + + +def _public_class_lookup() -> dict[str, type]: + """Map public wrapper type names and CLI aliases to their classes.""" + from .wrapper_registry import public_wrapper_alias_lookup + + return dict(public_wrapper_alias_lookup()) + + +def _resolve_annotation_class(annotation: Any, class_lookup: dict[str, type]) -> type | None: + """Resolve a return annotation to a known public wrapper class.""" + if isinstance(annotation, type): + for cls in class_lookup.values(): + if cls is annotation: + return cls + + matches: list[type] = [] + for type_name in extract_non_none_type_names(annotation): + cls = class_lookup.get(type_name.lower()) + if cls is not None and cls not in matches: + matches.append(cls) + if len(matches) == 1: + return matches[0] + return None + + +def _resolve_next_chain_context(current: Any, attr_name: str, class_lookup: dict[str, type]) -> Any: + """Return the next introspection context for a chained target segment.""" + if inspect.isclass(current): + raw_attr = inspect.getattr_static(current, attr_name) + if isinstance(raw_attr, property): + if raw_attr.fget is None: + return None + try: + getter_sig = inspect.signature(raw_attr.fget) + except (TypeError, ValueError): + return None + return _resolve_annotation_class(getter_sig.return_annotation, class_lookup) + if isinstance(raw_attr, (staticmethod, classmethod)): + callable_obj = raw_attr.__func__ + else: + callable_obj = getattr(current, attr_name) + else: + callable_obj = getattr(current, attr_name) + + if inspect.isclass(callable_obj): + return callable_obj + if not callable(callable_obj): + return None + try: + callable_sig = inspect.signature(callable_obj) + except (TypeError, ValueError): + return None + return _resolve_annotation_class(callable_sig.return_annotation, class_lookup) + + +def _cli_visibility_member(current: Any, attr_name: str) -> Any | None: + """Return the underlying member object used for CLI visibility metadata checks.""" + try: + if inspect.isclass(current): + raw_attr = inspect.getattr_static(current, attr_name) + else: + raw_attr = inspect.getattr_static(type(current), attr_name) + except AttributeError: + return getattr(current, attr_name, None) + + if isinstance(raw_attr, property): + return raw_attr.fget + if isinstance(raw_attr, (staticmethod, classmethod)): + return raw_attr.__func__ + return raw_attr + + +def _member_cli_visibility_metadata(member: Any) -> Any | None: + """Return library-declared CLI visibility metadata for a member, if present.""" + try: + from moldflow.cli_input_metadata import get_cli_visibility_metadata + except ImportError: + return None + return get_cli_visibility_metadata(member) + + +def _transient_wrapper_factory_hidden_reason() -> str | None: + """Return the library marker used for transient wrapper factory helpers.""" + try: + from moldflow.cli_input_metadata import CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY + except ImportError: + return None + return CLI_HIDDEN_REASON_TRANSIENT_WRAPPER_FACTORY + + +def _hidden_wrapper_type_name(member: Any) -> str | None: + """Return a compact wrapper type name for a hidden factory member, if inspectable.""" + if member is None or not callable(member): + return None + try: + signature = inspect.signature(member) + except (TypeError, ValueError): + return None + type_names = extract_non_none_type_names(signature.return_annotation) + if len(type_names) == 1: + return type_names[0] + if signature.return_annotation is inspect.Parameter.empty or signature.return_annotation is None: + return None + text = format_annotation_text(signature.return_annotation) + return text or None + + +def hidden_cli_target_details(target: str) -> dict[str, Any] | None: + """Return metadata for a target hidden by library CLI visibility decorators.""" + from .factories import camel_to_snake + + parts = [part for part in target.split(".") if part] + if not parts: + return None + if parts[0].lower() == CLI_ROOT_MOLDFLOW: + parts = parts[1:] + if not parts: + return None + + mf = importlib.import_module("moldflow") + class_lookup = _public_class_lookup() + first, *rest = parts + first_lower = first.lower() + + current = getattr(mf, first, None) + if current is None or inspect.ismodule(current): + current = None + for name, cls in iter_public_classes(): + if camel_to_snake(name) == first_lower: + current = cls + break + if current is None: + return None + + resolved_path = [first] + for index, attr_name in enumerate(rest): + matched_name = resolve_attr_name_case_insensitive(current, attr_name, static_lookup=True) + if matched_name is None: + return None + member = _cli_visibility_member(current, matched_name) + metadata = _member_cli_visibility_metadata(member) + hidden_path = ".".join([*resolved_path, matched_name]) + if metadata is not None and getattr(metadata, "hidden", False): + return { + "hidden_path": hidden_path, + "member": member, + "metadata": metadata, + } + if index == len(rest) - 1: + return None + next_context = _resolve_next_chain_context(current, matched_name, class_lookup) + if next_context is None: + return None + current = next_context + resolved_path.append(matched_name) + return None + + +def is_cli_target_hidden(target: str) -> bool: + """Return True when a target or any parent surface is hidden from the CLI.""" + return hidden_cli_target_details(target) is not None + + +def validate_cli_target_visible(target: str) -> None: + """Raise when a target is intentionally hidden from CLI discovery/invocation.""" + hidden_details = hidden_cli_target_details(target) + if hidden_details is None: + return + + hidden_path = hidden_details["hidden_path"] + member = hidden_details.get("member") + metadata = hidden_details["metadata"] + message_template = getattr(metadata, "message", None) + wrapper_type = _hidden_wrapper_type_name(member) or "wrapper" + transient_hidden_reason = _transient_wrapper_factory_hidden_reason() + + if isinstance(message_template, str) and message_template: + raise HiddenCliTargetError( + message_template.format( + target=target, + hidden_path=hidden_path, + wrapper_type=wrapper_type, + ) + ) + + if transient_hidden_reason is not None and getattr(metadata, "reason", None) == transient_hidden_reason: + raise HiddenCliTargetError( + _tr( + "Target '{target}' is hidden from the CLI because '{hidden_path}' only creates a " + "transient {wrapper_type} wrapper. The CLI constructs these helper objects " + "internally when needed, so they are not exposed as direct CLI targets.", + target=target, + hidden_path=hidden_path, + wrapper_type=wrapper_type, + ) + ) + + raise HiddenCliTargetError( + _tr( + "Target '{target}' is hidden from the CLI by library metadata on '{hidden_path}'.", + target=target, + hidden_path=hidden_path, + ) + ) + + +@lru_cache(maxsize=8) +def _iter_public_classes_cached(snapshot: tuple[tuple[str, int], ...]) -> tuple[tuple[str, type], ...]: + """Return public wrapper classes for a stable moldflow export snapshot.""" + mf = importlib.import_module("moldflow") + return tuple((name, getattr(mf, name)) for name, _ in snapshot) + + +def iter_public_classes() -> list[tuple[str, type]]: + """ + Collect top-level wrapper classes from moldflow by importing the package + and walking attributes on the module that are classes. + """ + mf = importlib.import_module("moldflow") + snapshot = tuple( + sorted( + (name, id(obj)) + for name, obj in vars(mf).items() + if not name.startswith("_") and inspect.isclass(obj) + ) + ) + return list(_iter_public_classes_cached(snapshot)) + + +def _parse_target_parts(target: str) -> tuple[list[str], str]: + """Parse and validate target string; return (parts, first_lower).""" + parts = [p for p in target.split(".") if p] + if not parts: + raise ValueError(_T("Empty target")) + if parts[0].lower() == CLI_ROOT_MOLDFLOW: + parts = parts[1:] + if not parts: + raise ValueError(_T("Target must include a class or function name")) + return parts, parts[0].lower() + + +def _resolve_first_segment(parts: list[str], first_lower: str) -> Any: + """Resolve the first segment of a target to an object on the moldflow module.""" + from .factories import camel_to_snake + + mf = importlib.import_module("moldflow") + first = parts[0] + obj = getattr(mf, first, None) + if obj is None or inspect.ismodule(obj): + for name, cls in iter_public_classes(): + if camel_to_snake(name) == first_lower: + obj = cls + break + if obj is None: + raise AttributeError( + _tr("Cannot resolve '{first}' on moldflow for introspection", first=first) + ) + return obj + + +def _find_matched_attr_name(current: Any, attr_name: str) -> str: + """Case-insensitive attribute lookup; raise AttributeError if not found.""" + if hasattr(current, attr_name): + return attr_name + attr_lower = attr_name.lower() + matched_name = next((name for name in dir(current) if name.lower() == attr_lower), None) + if matched_name is None: + if inspect.isclass(current): + raise AttributeError( + _tr( + "Class '{class_name}' has no attribute '{attr_name}'", + class_name=current.__name__, + attr_name=attr_name, + ) + ) + raise AttributeError( + _tr( + "Object '{type_name}' has no attribute '{attr_name}'", + type_name=type(current).__name__, + attr_name=attr_name, + ) + ) + return matched_name + + +def _raise_non_wrapper_continuation( + current: Any, matched_name: str, next_segment: str | None +) -> None: + """Raise AttributeError for non-wrapper continuation.""" + continuation = ( + f"; cannot continue to '{next_segment}'" + if isinstance(next_segment, str) and next_segment + else "" + ) + if inspect.isclass(current): + raise AttributeError( + _tr( + "Attribute '{matched_name}' on class '{class_name}' returns a non-wrapper value{continuation}", + matched_name=matched_name, + class_name=current.__name__, + continuation=continuation, + ) + ) + raise AttributeError( + _tr( + "Attribute '{matched_name}' on object '{type_name}' returns a non-wrapper value{continuation}", + matched_name=matched_name, + type_name=type(current).__name__, + continuation=continuation, + ) + ) + + +def resolve_for_introspection(target: str) -> Any: + """ + Resolve a target for documentation/signature purposes only. + This MUST NOT instantiate Synergy or any COM objects. + + We operate purely on the moldflow module and its exported classes. + Targets can use either CamelCase class names (Synergy) or the + snake_case CLI names (synergy, import_options, mesh_generator, ...). + """ + parts, first_lower = _parse_target_parts(target) + obj = _resolve_first_segment(parts, first_lower) + rest = parts[1:] + if not rest: + return obj + + class_lookup = _public_class_lookup() + current = obj + for index, attr_name in enumerate(rest): + matched_name = _find_matched_attr_name(current, attr_name) + if index == len(rest) - 1: + return getattr(current, matched_name) + next_context = _resolve_next_chain_context(current, matched_name, class_lookup) + if next_context is None: + next_segment = rest[index + 1] if index + 1 < len(rest) else None + _raise_non_wrapper_continuation(current, matched_name, next_segment) + current = next_context + + return current + + +def get_signature_string(obj: Any) -> str: + try: + sig = inspect.signature(obj) + return str(sig) + except (TypeError, ValueError): + return "(...)" + + +def get_docstring(obj: Any) -> str: + doc = inspect.getdoc(obj) or "" + return doc.strip() + + +def split_structured_doc(doc: str, *, obj_type: str | None) -> tuple[str | None, str | None]: + """Return compact summary/details text for structured payloads.""" + lines = [line.rstrip() for line in doc.splitlines()] + if obj_type == "property": + lines = [line for line in lines if not _RST_FIELD_LINE.match(line.strip())] + + paragraphs: list[str] = [] + current_paragraph: list[str] = [] + for line in lines: + stripped = line.strip() + if not stripped: + if current_paragraph: + paragraphs.append(" ".join(current_paragraph)) + current_paragraph = [] + continue + current_paragraph.append(stripped) + if current_paragraph: + paragraphs.append(" ".join(current_paragraph)) + + if not paragraphs: + return None, None + return paragraphs[0], " ".join(paragraphs[1:]) or None + + diff --git a/src/moldflow_cli/invoke_batching.py b/src/moldflow_cli/invoke_batching.py new file mode 100644 index 0000000..055fa34 --- /dev/null +++ b/src/moldflow_cli/invoke_batching.py @@ -0,0 +1,277 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any, Callable, Optional +import json as _json + +import typer + +from moldflow.i18n import get_text + + +_T = get_text() + + +def _tr(message: str, **kwargs: Any) -> str: + text = _T(message) + return text.format(**kwargs) if kwargs else text + + +def _batch_result_entry( + *, + index: int, + target: str | None, + request: dict[str, Any] | None, + ok: bool, + result: Any = None, + result_type: str | None = None, + plan: Any = None, + diagnostics: list[Any] | None = None, + error_type: str | None = None, + error: str | None = None, +) -> dict[str, Any]: + """Return a schema-stable batch result entry.""" + return { + "index": index, + "target": target, + "request": request, + "ok": ok, + "result": result, + "result_type": result_type, + "plan": plan, + "diagnostics": diagnostics if isinstance(diagnostics, list) else [], + "error_type": error_type, + "error": error, + } + + +def _validate_batch_mode_inputs( + *, + target: Optional[str], + args: list[str], + json_input: Optional[str], + json_file_input: Optional[str], + translate: Any, +) -> None: + if target is not None: + raise typer.BadParameter(translate("Do not pass TARGET when --batch-file is used.")) + if args or json_input is not None or json_file_input is not None: + raise typer.BadParameter( + translate("Do not pass positional args/JSON input with --batch-file.")) + + +def _load_batch_payload(batch_file: str) -> list[Any]: + try: + with open(batch_file, "r", encoding="utf-8-sig") as input_file: + batch_payload = _json.load(input_file) + except (OSError, UnicodeError, _json.JSONDecodeError, RecursionError) as exc: + raise typer.BadParameter( + _tr("Cannot read batch file '{path}': {error}", path=batch_file, error=exc) + ) from exc + if not isinstance(batch_payload, list): + raise typer.BadParameter( + _T("Batch file must contain a JSON array of invoke call objects.")) + return batch_payload + + +def _parse_batch_call( + index: int, + item: Any, +) -> tuple[tuple[str, list[str], Optional[str], Optional[str]] | None, dict[str, Any] | None]: + if not isinstance(item, dict): + return None, _batch_result_entry( + index=index, + target=None, + request=None, + ok=False, + error_type="batch_item_validation", + error=_tr("Batch item must be a JSON object."), + ) + allowed_keys = {"target", "args", "params_json", "params_json_file"} + unknown_keys = sorted([k for k in item.keys() if k not in allowed_keys]) + if unknown_keys: + return None, _batch_result_entry( + index=index, + target=item.get("target") if isinstance(item.get("target"), str) else None, + request=item, + ok=False, + error_type="batch_item_validation", + error=_tr( + "Unknown batch item field(s): {fields}.", + fields=", ".join(unknown_keys), + ), + ) + + call_target = item.get("target") + if not isinstance(call_target, str) or not call_target.strip(): + return None, _batch_result_entry( + index=index, + target=None, + request=item, + ok=False, + error_type="batch_item_validation", + error=_tr("Batch item requires string field 'target'."), + ) + + call_args = item.get("args", []) + if not isinstance(call_args, list) or any(not isinstance(v, str) for v in call_args): + return None, _batch_result_entry( + index=index, + target=call_target, + request=item, + ok=False, + error_type="batch_item_validation", + error=_tr("Batch item field 'args' must be a list of strings."), + ) + + call_json_input = item.get("params_json") + call_json_file_input = item.get("params_json_file") + if call_json_input is not None and not isinstance(call_json_input, str): + call_json_input = _json.dumps(call_json_input) + if call_json_file_input is not None and not isinstance(call_json_file_input, str): + return None, _batch_result_entry( + index=index, + target=call_target, + request=item, + ok=False, + error_type="batch_item_validation", + error=_tr("Batch item field 'params_json_file' must be a string path."), + ) + + return (call_target, call_args, call_json_input, call_json_file_input), None + + +def _batch_request_preview( + *, + target: str, + call_args: list[str], + call_json_input: Optional[str], + call_json_file_input: Optional[str], +) -> dict[str, Any]: + request: dict[str, Any] = {"target": target} + if call_args: + request["args"] = call_args + if call_json_input is not None: + try: + request["params_json"] = _json.loads(call_json_input) + except (_json.JSONDecodeError, TypeError, ValueError, RecursionError): + request["params_json"] = call_json_input + if call_json_file_input is not None: + request["params_json_file"] = call_json_file_input + return request + + +def _build_batch_result( + *, + index: int, + target: str, + request: dict[str, Any], + call_result: Any, + dry_run: bool, + fail_on_false: bool, + build_invoke_envelope: Callable[[Any], dict[str, Any]], +) -> tuple[dict[str, Any], bool]: + if dry_run: + return _batch_result_entry( + index=index, + target=target, + request=request, + ok=True, + result=None, + result_type="dry_run", + plan=call_result, + diagnostics=[], + error_type=None, + error=None, + ), False + envelope = build_invoke_envelope(call_result) + any_error = bool(fail_on_false and envelope["ok"] is False) + error_type = None + if envelope["ok"] is False: + error_type = "business_failure" + return ( + _batch_result_entry( + index=index, + target=target, + request=request, + ok=bool(envelope["ok"]), + result=envelope["result"], + result_type=envelope["result_type"], + plan=None, + diagnostics=envelope.get("diagnostics", []), + error_type=error_type, + error=None, + ), + any_error, + ) + + +def _build_batch_summary(results: list[dict[str, Any]]) -> dict[str, int]: + total = len(results) + succeeded = sum(1 for item in results if item.get("ok") is True) + failed = total - succeeded + return {"total": total, "succeeded": succeeded, "failed": failed} + + +def _run_batch_invoke( + *, + batch_payload: list[Any], + invoke_once: Callable[[str, list[str], Optional[str], Optional[str], Optional[int]], Any], + dry_run: bool, + fail_on_false: bool, + build_invoke_envelope: Callable[[Any], dict[str, Any]], + emit_batch_trace: Callable[[int, str | None, str, dict[str, Any]], None] | None = None, +) -> tuple[list[dict[str, Any]], bool]: + results: list[dict[str, Any]] = [] + any_error = False + for idx, item in enumerate(batch_payload): + parsed_call, parse_error = _parse_batch_call(idx, item) + if parse_error is not None: + if emit_batch_trace is not None: + emit_batch_trace(idx, parse_error.get("target"), "error", parse_error) + results.append(parse_error) + any_error = True + continue + + call_target, call_args, call_json_input, call_json_file_input = parsed_call + request = _batch_request_preview( + target=call_target, + call_args=call_args, + call_json_input=call_json_input, + call_json_file_input=call_json_file_input, + ) + try: + call_result = invoke_once(call_target, call_args, call_json_input, call_json_file_input, idx) + result_entry, result_error = _build_batch_result( + index=idx, + target=call_target, + request=request, + call_result=call_result, + dry_run=dry_run, + fail_on_false=fail_on_false, + build_invoke_envelope=build_invoke_envelope, + ) + results.append(result_entry) + any_error = any_error or result_error + except Exception as exc: + error_type = "invoke_validation" if isinstance(exc, typer.BadParameter) else "runtime_error" + error_entry = _batch_result_entry( + index=idx, + target=call_target, + request=request, + ok=False, + result=None, + result_type=None, + plan=None, + diagnostics=[], + error_type=error_type, + error=str(exc) or exc.__class__.__name__, + ) + if emit_batch_trace is not None: + emit_batch_trace(idx, call_target, "error", error_entry) + results.append(error_entry) + any_error = True + + return results, any_error diff --git a/src/moldflow_cli/invoke_binding.py b/src/moldflow_cli/invoke_binding.py new file mode 100644 index 0000000..4af7a99 --- /dev/null +++ b/src/moldflow_cli/invoke_binding.py @@ -0,0 +1,1025 @@ +from __future__ import annotations + +import inspect +import json as _json +from difflib import get_close_matches +from types import NoneType +from typing import Annotated, Any, Optional, get_args, get_origin + +import typer + +from moldflow.i18n import get_text + +from .constants import CLI_FIELD_VALUE, CLI_INPUT_SOURCE_JSON +from .factories import build_wrapper_instance +from .target_resolution import split_dotted_path +from .type_annotations import annotation_text_allows_str, extract_non_none_type_names +from .wrapper_registry import build_wrapper_input_hints, resolve_first_wrapper_name +from .wrapper_input_adapters import ( + apply_wrapper_input_adapter, + apply_wrapper_shorthand_adapter, +) + + +ParsedItem = tuple[list[str], Any, str] +MethodStep = dict[str, Any] +_T = get_text() + + +def _tr(message: str, **kwargs: Any) -> str: + text = _T(message) + return text.format(**kwargs) if kwargs else text + + +def _closest_name(name: str, candidates: list[str] | set[str] | tuple[str, ...]) -> str | None: + matches = get_close_matches(name, list(candidates), n=1, cutoff=0.6) + return matches[0] if matches else None + + +def _multi_step_json_example(step_name: str) -> str: + return _json.dumps({step_name: {"": ""}}, ensure_ascii=True) + + +def _wrapper_json_guidance(param_name: str, param: inspect.Parameter | None) -> str | None: + if param is None: + return None + type_names = extract_non_none_type_names(param.annotation) + if not type_names: + return None + wrapper_type = resolve_first_wrapper_name(type_names) + if wrapper_type is None: + return None + hints = build_wrapper_input_hints(wrapper_type) + if not isinstance(hints, dict): + return None + friendly_json_input = hints.get("friendly_json_input") + if not isinstance(friendly_json_input, dict): + return None + preferred_field = friendly_json_input.get("preferred_field") + if not isinstance(preferred_field, str) or not preferred_field: + return None + return _tr( + "Use JSON field '{preferred_field}' for '{param_name}'.", + param_name=param_name, + preferred_field=preferred_field, + ) + + +def _wrapper_non_json_guidance(param_name: str, param: inspect.Parameter | None) -> str | None: + if param is None: + return None + guidance = _wrapper_json_guidance(param_name, param) + if guidance is None: + return None + return _tr( + "If shorthand input is ambiguous, switch to --params-json. {guidance}", + guidance=guidance, + ) + + +def _is_annotated_origin(origin: Any) -> bool: + """Return True when origin represents typing.Annotated across Python versions.""" + if origin is None: + return False + if origin is Annotated: + return True + origin_name = getattr(origin, "__name__", "") + if origin_name == "Annotated": + return True + return "Annotated" in str(origin) + + +def _parse_scalar(value: str) -> Any: + """Best-effort conversion of a string CLI value into a Python scalar.""" + text = value.strip() + lower = text.lower() + if lower in {"true", "yes", "on"}: + return True + if lower in {"false", "no", "off"}: + return False + if lower in {"none", "null"}: + return None + try: + return int(text) + except ValueError: + pass + try: + return float(text) + except ValueError: + pass + return text + + +def _validate_cli_param(param_name: str, value: Any) -> Any: + """Light validation for string CLI argument values.""" + if not isinstance(value, str): + return value + if "\x00" in value: + raise typer.BadParameter( + _T("Parameter '{param_name}' contains a null byte which is not allowed.").format( + param_name=param_name + ) + ) + if any(ch in value for ch in ("\n", "\r", "\t")): + raise typer.BadParameter( + _T( + "Parameter '{param_name}' contains control characters (newline/tab/carriage return); " + "please provide a single-line value or quote/escape as needed." + ).format(param_name=param_name) + ) + return value + + +def _validate_json_param(param_name: str, value: Any) -> Any: + """Validation for JSON-derived string parameters.""" + if not isinstance(value, str): + return value + if "\x00" in value: + raise typer.BadParameter( + _T("Parameter '{param_name}' contains a null byte which is not allowed.").format( + param_name=param_name + ) + ) + return value + + +def _split_dotted_path(path_text: str, *, field_name: str) -> list[str]: + """Split dotted identifiers while rejecting empty segments.""" + return split_dotted_path(path_text, field_name=field_name, translate=_T) + + +def _validate_invoke_input_sources( + raw_args: list[str], json_input: Optional[str], json_file_input: Optional[str] +) -> None: + _ = get_text() + if json_input and json_file_input: + raise typer.BadParameter( + _("Only one of --params-json or --params-json-file may be specified.") + ) + if (json_input or json_file_input) and raw_args: + raise typer.BadParameter( + _( + "When JSON input is provided via --params-json or --params-json-file, no positional " + "key=value args may be given." + ) + ) + + +def _append_payload_items( + parsed_items: list[ParsedItem], payload: Any, method_steps: list[MethodStep] +) -> None: + _ = get_text() + if not isinstance(payload, dict): + raise typer.BadParameter( + _( + "JSON parameters must be a JSON object of named arguments. " + "Example: --params-json '{\"param\": 1}' " + "or --params-json '{\"step\": {\"param\": 1}}' for chained targets." + ) + ) + + if len(method_steps) == 1: + single_step_name = method_steps[0]["name"] + single_step_params = method_steps[0].get("params") + single_step_params_lower = ( + {str(name).lower() for name in single_step_params} + if isinstance(single_step_params, dict) + else set() + ) + if ( + isinstance(single_step_params, dict) + and len(payload) == 1 + and isinstance(next(iter(payload.values())), dict) + ): + only_key = str(next(iter(payload.keys()))) + if only_key.lower() == single_step_name.lower() and only_key.lower() not in single_step_params_lower: + payload = next(iter(payload.values())) + + for key, value in payload.items(): + if str(key).startswith("_"): + raise typer.BadParameter( + _tr("Non-public argument path '{path}' is not allowed.", path=key) + ) + parsed_items.append(([str(key)], value, CLI_INPUT_SOURCE_JSON)) + return + + valid_step_names = {step["name"] for step in method_steps} + valid_step_names_lower = {name.lower(): name for name in valid_step_names} + for step_name, step_payload in payload.items(): + if str(step_name).startswith("_"): + raise typer.BadParameter( + _tr("Non-public argument path '{path}' is not allowed.", path=step_name) + ) + canonical_step_name = valid_step_names_lower.get(str(step_name).lower()) + if canonical_step_name is None: + raise typer.BadParameter( + _tr( + "Argument '{argument}' must start with one of: {valid_steps}", + argument=step_name, + valid_steps=", ".join(sorted(valid_step_names)), + ) + ) + if not isinstance(step_payload, dict): + raise typer.BadParameter( + _tr( + "Arguments for step '{step_name}' must be a JSON object of parameters.", + step_name=step_name, + ) + ) + for key, value in step_payload.items(): + if str(key).startswith("_"): + raise typer.BadParameter( + _tr( + "Non-public argument path '{path}' is not allowed.", + path=f"{canonical_step_name}.{key}", + ) + ) + parsed_items.append(([canonical_step_name, str(key)], value, CLI_INPUT_SOURCE_JSON)) + + +def _parse_positional_key_value_items(raw_args: list[str]) -> list[ParsedItem]: + _ = get_text() + parsed_items: list[ParsedItem] = [] + for item in raw_args: + if "=" not in item: + raise typer.BadParameter( + _("Invalid argument '{item}'. Expected key=value or param.attr=value.").format( + item=item + ) + ) + left, value = item.split("=", 1) + path = _split_dotted_path(left, field_name="argument path") + if any(seg.startswith("_") for seg in path): + raise typer.BadParameter( + _tr("Non-public argument path '{path}' is not allowed.", path=left) + ) + parsed_items.append((path, value, "cli")) + return parsed_items + + +def _parse_invoke_items( + method_steps: list[MethodStep], + raw_args: list[str], + json_input: Optional[str], + json_file_input: Optional[str], +) -> list[ParsedItem]: + _validate_invoke_input_sources(raw_args, json_input, json_file_input) + parsed_items: list[ParsedItem] = [] + + if json_input is not None: + try: + payload = _json.loads(json_input) + except (_json.JSONDecodeError, RecursionError) as exc: + raise typer.BadParameter( + _tr("Invalid JSON payload for parameters: {error}", error=exc) + ) from exc + _append_payload_items(parsed_items, payload, method_steps) + elif json_file_input is not None: + try: + with open(json_file_input, "r", encoding="utf-8-sig") as input_file: + payload = _json.load(input_file) + except (OSError, UnicodeError, _json.JSONDecodeError, RecursionError) as exc: + raise typer.BadParameter( + _tr("Cannot read JSON file '{path}': {error}", path=json_file_input, error=exc) + ) from exc + _append_payload_items(parsed_items, payload, method_steps) + elif len(raw_args) == 1 and isinstance(raw_args[0], str) and raw_args[0].strip().startswith(("{", "[")): + try: + payload = _json.loads(raw_args[0]) + except (_json.JSONDecodeError, RecursionError) as exc: + raise typer.BadParameter( + _tr( + "Invalid JSON payload for parameters: {error}. A single positional " + "argument beginning with '{{' or '[' is treated as JSON shorthand; " + "use --params-json for clearer intent.", + error=exc, + ) + ) from exc + _append_payload_items(parsed_items, payload, method_steps) + else: + parsed_items.extend(_parse_positional_key_value_items(raw_args)) + + return parsed_items + + +def _parse_terminal_property_assignment_value( + raw_args: list[str], + json_input: Optional[str], + json_file_input: Optional[str], +) -> Any: + """Parse assignment input for terminal property targets. + + Accepted inputs are: + - `value=...` + - `--params-json '{"value": ...}'` + - `--params-json-file` containing `{"value": ...}` + """ + _ = get_text() + _validate_invoke_input_sources(raw_args, json_input, json_file_input) + + if json_input is not None or json_file_input is not None: + if json_input is not None: + try: + payload = _json.loads(json_input) + except (_json.JSONDecodeError, RecursionError) as exc: + raise typer.BadParameter( + _tr("Invalid JSON payload for parameters: {error}", error=exc) + ) from exc + else: + try: + with open(json_file_input, "r", encoding="utf-8-sig") as input_file: + payload = _json.load(input_file) + except (OSError, UnicodeError, _json.JSONDecodeError, RecursionError) as exc: + raise typer.BadParameter( + _tr("Cannot read JSON file '{path}': {error}", path=json_file_input, error=exc) + ) from exc + + if not isinstance(payload, dict): + raise typer.BadParameter( + _( + "Property assignment JSON must be an object with a single 'value' field." + ) + ) + + payload_keys = list(payload.keys()) + if len(payload_keys) != 1 or str(payload_keys[0]).lower() != CLI_FIELD_VALUE: + raise typer.BadParameter( + _( + "Property assignment requires exactly one 'value' argument " + "(e.g., value=... or --params-json '{\"value\": ...}')." + ) + ) + + from .factories import convert_value + + try: + return _validate_json_param(CLI_FIELD_VALUE, convert_value(payload[payload_keys[0]])) + except (AttributeError, TypeError, ValueError, RecursionError) as exc: + raise typer.BadParameter( + _tr("Invalid JSON value for parameter '{param_name}': {error}", param_name=CLI_FIELD_VALUE, error=exc) + ) from exc + + parsed_items = _parse_positional_key_value_items(raw_args) + if len(parsed_items) != 1: + raise typer.BadParameter( + _( + "Property assignment requires exactly one 'value' argument " + "(e.g., value=... or --params-json '{\"value\": ...}')." + ) + ) + path, value, _origin = parsed_items[0] + if len(path) != 1 or path[0].lower() != CLI_FIELD_VALUE: + raise typer.BadParameter( + _( + "Property assignment requires exactly one 'value' argument " + "(e.g., value=... or --params-json '{\"value\": ...}')." + ) + ) + raw_value = _validate_cli_param(CLI_FIELD_VALUE, value) + return _validate_cli_param(CLI_FIELD_VALUE, _parse_scalar(raw_value)) + + +def _bucket_items_by_step( + method_steps: list[MethodStep], parsed_items: list[ParsedItem] +) -> dict[str, list[ParsedItem]]: + step_args: dict[str, list[ParsedItem]] = {step["name"]: [] for step in method_steps} + if len(method_steps) == 1: + single_name = method_steps[0]["name"] + single_name_lower = single_name.lower() + for path, value, origin in parsed_items: + if path and path[0].lower() == single_name_lower and len(path) > 1: + path = path[1:] + step_args[single_name].append((path, value, origin)) + return step_args + + valid_step_names = {step["name"] for step in method_steps} + valid_step_names_lower = {name.lower(): name for name in valid_step_names} + for path, value, origin in parsed_items: + step_name = valid_step_names_lower.get(path[0].lower()) + if step_name is None: + suggestion = _closest_name(path[0], valid_step_names) + extra = "" + if suggestion is not None: + extra += _tr("\nDid you mean step '{step_name}'?", step_name=suggestion) + example_step = suggestion or sorted(valid_step_names)[0] + if origin == CLI_INPUT_SOURCE_JSON: + extra += _tr( + "\nFor JSON input on multi-step targets, group parameters by step name, e.g. {example}", + example=_multi_step_json_example(example_step), + ) + message = _tr( + "For JSON input, group parameters by step name. Argument '{argument}' must start with one of: {valid_steps}.{extra}", + argument=".".join(path), + valid_steps=", ".join(sorted(valid_step_names)), + extra=extra, + ) + else: + message = _tr( + "Argument '{argument}' must start with one of: {valid_steps}.{extra}", + argument=".".join(path), + valid_steps=", ".join(sorted(valid_step_names)), + extra=extra, + ) + raise typer.BadParameter(message) + if len(path) == 1: + extra = "" + if origin == CLI_INPUT_SOURCE_JSON: + extra = _tr( + "\nFor JSON input, this step key must map to an object of parameter names, e.g. {example}", + example=_multi_step_json_example(step_name), + ) + raise typer.BadParameter( + _tr( + "Argument '{argument}' must specify a parameter name after the step " + "(e.g., {step_name}.param=...).{extra}", + argument=".".join(path), + step_name=step_name, + extra=extra, + ) + ) + step_args[step_name].append((path[1:], value, origin)) + return step_args + + +def _build_kwargs_per_step( + method_steps: list[MethodStep], + step_args: dict[str, list[ParsedItem]], + target: str, + *, + dry_run: bool = False, +) -> dict[str, dict[str, Any]]: + kwargs_per_step: dict[str, dict[str, Any]] = {step["name"]: {} for step in method_steps} + qualify_parameter_names = len(method_steps) > 1 + for step in method_steps: + items = step_args.get(step["name"], []) + if step.get("signature") is None and step.get("params") is None: + kwargs_per_step[step["name"]] = {"__deferred_items__": items} + continue + kwargs_per_step[step["name"]] = _build_step_kwargs( + step, + items, + target, + dry_run=dry_run, + qualify_parameter_names=qualify_parameter_names, + ) + return kwargs_per_step + + +def _required_parameter_label(step_name: str, parameter_name: str, *, qualify: bool) -> str: + """Return the user-facing missing-parameter label for a step.""" + if not qualify: + return parameter_name + return f"{step_name}.{parameter_name}" + + +def _ensure_required_step_params( + step_name: str, + param_map: dict[str, inspect.Parameter] | None, + items: list[ParsedItem], + target: str, + sig: Any, + *, + qualify_parameter_names: bool, +) -> None: + if param_map is None: + return + positional_only = [ + name for name, param in param_map.items() if param.kind == inspect.Parameter.POSITIONAL_ONLY + ] + if positional_only: + raise typer.BadParameter( + _tr( + "Step '{step_name}' in target '{target}' has positional-only parameters " + "({parameters}), which are not supported by CLI named-argument " + "routing. Use the Python API for this target.", + step_name=step_name, + target=target, + parameters=", ".join(positional_only), + ) + ) + provided_param_names_lower = {path[0].lower() for path, _, _ in items if path} + for name, param in param_map.items(): + if name.lower() in provided_param_names_lower: + continue + if param.default is not inspect._empty: + continue + if _annotation_allows_none(param.annotation): + continue + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + raise typer.BadParameter( + _tr( + "Missing required parameter '{parameter}' for {target}{signature}. " + "Run 'describe {target}' to inspect accepted parameters and JSON examples.", + parameter=_required_parameter_label( + step_name, + name, + qualify=qualify_parameter_names, + ), + target=target, + signature=sig, + ) + ) + + +def _annotation_allows_none(annotation: Any) -> bool: + """Return True when the annotation explicitly allows None/null.""" + if annotation is inspect._empty: + return False + if annotation is None or annotation is NoneType: + return True + if isinstance(annotation, str): + normalized = annotation.replace(" ", "").replace("typing.", "").lower() + return ( + "optional[" in normalized + or "|none" in normalized + or "nonetype" in normalized + or "none]" in normalized + or ",none" in normalized + ) + origin = get_origin(annotation) + if _is_annotated_origin(origin): + annotated_args = get_args(annotation) + if annotated_args: + return _annotation_allows_none(annotated_args[0]) + args = get_args(annotation) + if args: + return any(arg is NoneType for arg in args) + return False + + +def _validate_step_item_path_conflicts(step_name: str, items: list[ParsedItem]) -> None: + seen_paths: list[tuple[str, ...]] = [] + for path, _, _ in items: + path_tuple = tuple(path) + if path_tuple in seen_paths: + raise typer.BadParameter( + _tr( + "Duplicate argument path '{path}' is not allowed.", + path=f"{step_name}.{'.'.join(path)}", + ) + ) + for existing in seen_paths: + if len(existing) <= len(path_tuple) and path_tuple[: len(existing)] == existing: + raise typer.BadParameter( + _tr( + "Conflicting argument paths '{left_path}' and '{right_path}' are not allowed.", + left_path=f"{step_name}.{'.'.join(existing)}", + right_path=f"{step_name}.{'.'.join(path)}", + ) + ) + if len(path_tuple) < len(existing) and existing[: len(path_tuple)] == path_tuple: + raise typer.BadParameter( + _tr( + "Conflicting argument paths '{left_path}' and '{right_path}' are not allowed.", + left_path=f"{step_name}.{'.'.join(path)}", + right_path=f"{step_name}.{'.'.join(existing)}", + ) + ) + seen_paths.append(path_tuple) + + +def _coerce_step_value_for_dry_run(param_name: str, value: Any, origin: str) -> Any: + if origin == CLI_INPUT_SOURCE_JSON: + return _validate_json_param(param_name, value) + raw_value = _validate_cli_param(param_name, value) + return _validate_cli_param(param_name, _parse_scalar(raw_value)) + + +def _annotation_prefers_raw_string(param: inspect.Parameter | None) -> bool: + if param is None: + return False + annotation = param.annotation + if annotation is str: + return True + if isinstance(annotation, str) and annotation_text_allows_str(annotation): + return True + annotation_args = get_args(annotation) + return bool(annotation_args and any(arg is str for arg in annotation_args)) + + +def _coerce_non_json_step_value( + param_name: str, + value: Any, + param: inspect.Parameter | None, +) -> Any: + raw_value = _validate_cli_param(param_name, value) + if _annotation_prefers_raw_string(param): + return raw_value + if param is not None: + try: + obj = build_wrapper_instance(param.annotation) + except (AttributeError, TypeError, ValueError, RecursionError): + obj = None + if obj is not None: + try: + if apply_wrapper_shorthand_adapter(obj, raw_value, applied_adapters={}): + return obj + except (AttributeError, TypeError, ValueError, RecursionError) as exc: + guidance = _wrapper_non_json_guidance(param_name, param) + error_text = str(exc) + if guidance: + error_text = f"{error_text} {guidance}" + raise typer.BadParameter( + _tr( + "Invalid value for parameter '{param_name}': {error}", + param_name=param_name, + error=error_text, + ) + ) from exc + return _validate_cli_param(param_name, _parse_scalar(raw_value)) + + +def _raise_invalid_json_step_value( + param_name: str, + exc: Exception, + param: inspect.Parameter | None = None, +) -> None: + message = _tr("Invalid JSON value for parameter '{param_name}': {error}", param_name=param_name, error=exc) + guidance = _wrapper_json_guidance(param_name, param) + if guidance: + message = f"{message} {guidance}" + raise typer.BadParameter(message) from exc + + +def _coerce_json_step_value( + param_name: str, + value: Any, + param: inspect.Parameter | None, +) -> Any: + from .factories import configure_object_from_dict, convert_value + + if isinstance(value, dict) and param is not None and "__type__" not in value: + type_names = extract_non_none_type_names(param.annotation) + expected_wrapper_type = type_names[0] if len(type_names) == 1 else None + try: + obj = build_wrapper_instance(param.annotation) + except (AttributeError, TypeError, ValueError, RecursionError): + try: + return _validate_json_param(param_name, convert_value(value)) + except (AttributeError, TypeError, ValueError, RecursionError) as inner_exc: + _raise_invalid_json_step_value(param_name, inner_exc, param) + + try: + obj = configure_object_from_dict(obj, value, expected_type=expected_wrapper_type) + return _validate_json_param(param_name, obj) + except (AttributeError, TypeError, ValueError, RecursionError) as exc: + _raise_invalid_json_step_value(param_name, exc, param) + + try: + return _validate_json_param(param_name, convert_value(value)) + except (AttributeError, TypeError, ValueError, RecursionError) as exc: + _raise_invalid_json_step_value(param_name, exc, param) + + +def _coerce_step_value( + param_name: str, + value: Any, + origin: str, + param: inspect.Parameter | None, + *, + dry_run: bool = False, +) -> Any: + if dry_run: + return _coerce_step_value_for_dry_run(param_name, value, origin) + + if origin != CLI_INPUT_SOURCE_JSON: + return _coerce_non_json_step_value(param_name, value, param) + + return _coerce_json_step_value(param_name, value, param) + + +def _coerce_kwargs_value(param_name: str, value: Any, origin: str, *, dry_run: bool = False) -> Any: + if dry_run: + if origin == CLI_INPUT_SOURCE_JSON: + return _validate_json_param(param_name, value) + raw_value = _validate_cli_param(param_name, value) + return _validate_cli_param(param_name, _parse_scalar(raw_value)) + + if origin == CLI_INPUT_SOURCE_JSON: + from .factories import convert_value + + try: + return _validate_json_param(param_name, convert_value(value)) + except (AttributeError, TypeError, ValueError, RecursionError) as exc: + raise typer.BadParameter( + _tr("Invalid JSON value for parameter '{param_name}': {error}", param_name=param_name, error=exc) + ) from exc + raw_value = _validate_cli_param(param_name, value) + return _validate_cli_param(param_name, _parse_scalar(raw_value)) + + +def _navigate_to_parent_dry_run( + step_name: str, step_kwargs: dict[str, Any], path: list[str] +) -> tuple[dict[str, Any], str]: + """Navigate to parent dict for dry-run nested set; return (parent_dict, final_attr).""" + param_name = path[0] + current_obj: Any = step_kwargs[param_name] + for attr in path[1:-1]: + if not isinstance(current_obj, dict): + raise typer.BadParameter( + _tr( + "Invalid nested argument path '{path}': cannot nest into non-object '{obj_type}'.", + path=f"{step_name}.{'.'.join(path)}", + obj_type=type(current_obj).__name__, + ) + ) + current_obj = current_obj.setdefault(attr, {}) + final_attr = path[-1] + if not isinstance(current_obj, dict): + raise typer.BadParameter( + _tr( + "Invalid nested argument path '{path}': cannot set '{final_attr}' on non-object '{obj_type}'.", + path=f"{step_name}.{'.'.join(path)}", + final_attr=final_attr, + obj_type=type(current_obj).__name__, + ) + ) + return current_obj, final_attr + + +def _navigate_to_parent_live( + step_name: str, step_kwargs: dict[str, Any], path: list[str] +) -> tuple[Any, str]: + """Navigate to parent object for live nested set; return (parent_obj, final_attr).""" + param_name = path[0] + current_obj = step_kwargs[param_name] + for attr in path[1:-1]: + try: + current_obj = getattr(current_obj, attr) + except (AttributeError, TypeError, ValueError) as exc: + raise typer.BadParameter( + _tr( + "Invalid nested argument path '{path}': attribute '{attr}' does not exist on '{obj_type}'.", + path=f"{step_name}.{'.'.join(path)}", + attr=attr, + obj_type=type(current_obj).__name__, + ) + ) from exc + return current_obj, path[-1] + + +def _coerce_final_value( + param_name: str, final_attr: str, value: Any, origin: str +) -> Any: + """Coerce value for nested assignment (JSON or CLI scalar).""" + full_param = f"{param_name}.{final_attr}" + if origin == CLI_INPUT_SOURCE_JSON: + from .factories import convert_value + + try: + return _validate_json_param(full_param, convert_value(value)) + except (AttributeError, TypeError, ValueError, RecursionError) as exc: + raise typer.BadParameter( + _tr( + "Invalid JSON value for parameter '{param_name}': {error}", + param_name=full_param, + error=exc, + ) + ) from exc + raw_value = _validate_cli_param(full_param, value) + return _validate_cli_param(full_param, _parse_scalar(raw_value)) + + +def _set_nested_step_value_dry_run( + step_name: str, step_kwargs: dict[str, Any], path: list[str], value: Any, origin: str +) -> None: + """Handle dry-run nested step value assignment.""" + param_name = path[0] + parent, final_attr = _navigate_to_parent_dry_run(step_name, step_kwargs, path) + parent[final_attr] = _coerce_final_value(param_name, final_attr, value, origin) + + +def _set_nested_step_value_live( + step_name: str, + step_kwargs: dict[str, Any], + path: list[str], + value: Any, + origin: str, + adapter_states: dict[str, dict[str, str]] | None, +) -> None: + """Handle live nested step value assignment.""" + param_name = path[0] + parent, final_attr = _navigate_to_parent_live(step_name, step_kwargs, path) + adapter_state = None + if adapter_states is not None: + adapter_key = ".".join(path[:-1]) + adapter_state = adapter_states.setdefault(adapter_key, {}) + if origin != CLI_INPUT_SOURCE_JSON and adapter_state is not None: + raw_value = _validate_cli_param(f"{param_name}.{final_attr}", value) + try: + if apply_wrapper_input_adapter( + parent, final_attr, raw_value, applied_adapters=adapter_state + ): + return + except (AttributeError, TypeError, ValueError, RecursionError) as exc: + raise typer.BadParameter( + _tr( + "Cannot set nested argument '{path}': {error}", + path=f"{step_name}.{'.'.join(path)}", + error=exc, + ) + ) from exc + if not hasattr(parent, final_attr): + raise typer.BadParameter( + _tr( + "Invalid nested argument path '{path}': attribute '{attr}' does not exist on '{obj_type}'.", + path=f"{step_name}.{'.'.join(path)}", + attr=final_attr, + obj_type=type(parent).__name__, + ) + ) + safe_val = _coerce_final_value(param_name, final_attr, value, origin) + try: + setattr(parent, final_attr, safe_val) + except (AttributeError, TypeError, ValueError) as exc: + raise typer.BadParameter( + _tr( + "Cannot set nested argument '{path}': {error}", + path=f"{step_name}.{'.'.join(path)}", + error=exc, + ) + ) from exc + + +def _set_nested_step_value( + step_name: str, + step_kwargs: dict[str, Any], + path: list[str], + value: Any, + origin: str, + *, + adapter_states: dict[str, dict[str, str]] | None = None, + dry_run: bool = False, +) -> None: + if dry_run: + _set_nested_step_value_dry_run(step_name, step_kwargs, path, value, origin) + else: + _set_nested_step_value_live( + step_name, step_kwargs, path, value, origin, adapter_states + ) + + +def _normalize_step_items( + items: list[ParsedItem], + param_map: dict[str, inspect.Parameter] | None, +) -> list[ParsedItem]: + normalized_items: list[ParsedItem] = [] + for path, value, origin in items: + if not path: + normalized_items.append((path, value, origin)) + continue + param_name = path[0] + if param_map is not None and param_name not in param_map: + case_insensitive_name = next( + (name for name in param_map if name.lower() == param_name.lower()), + None, + ) + if case_insensitive_name is not None: + param_name = case_insensitive_name + normalized_items.append(([param_name, *path[1:]], value, origin)) + return normalized_items + + +def _assign_step_item( + *, + step_name: str, + path: list[str], + value: Any, + origin: str, + param_map: dict[str, inspect.Parameter] | None, + target: str, + sig: Any, + step_kwargs: dict[str, Any], + dry_run: bool, +) -> None: + if not path: + raise typer.BadParameter( + _tr( + "Invalid argument for step '{step_name}': missing parameter name.", + step_name=step_name, + ) + ) + + param_name = path[0] + if param_map is not None and param_name not in param_map: + accepts_kwargs = any(param.kind == inspect.Parameter.VAR_KEYWORD for param in param_map.values()) + if not accepts_kwargs: + known_params = ", ".join(sorted(param_map)) + suggestion = _closest_name(param_name, set(param_map)) + extra = _tr(" Known parameters: {known_params}.", known_params=known_params) + if suggestion is not None: + extra += _tr(" Did you mean '{parameter}'?", parameter=f"{step_name}.{suggestion}") + extra += _tr( + " Run 'describe {target}' to inspect accepted parameters and JSON examples.", + target=target, + ) + raise typer.BadParameter( + _tr( + "Unknown parameter '{parameter}' for {target}{signature}.{extra}", + parameter=f"{step_name}.{param_name}", + target=target, + signature=sig, + extra=extra, + ) + ) + if len(path) > 1: + raise typer.BadParameter( + _tr( + "Nested argument '{path}' is not supported for **kwargs on step '{step_name}'. " + "Use a single key (e.g., {example}=...).", + path=f"{step_name}.{'.'.join(path)}", + step_name=step_name, + example=f"{step_name}.{param_name}", + ) + ) + step_kwargs[param_name] = _coerce_kwargs_value(param_name, value, origin, dry_run=dry_run) + return + + param = param_map[param_name] if param_map is not None else None + if len(path) == 1: + step_kwargs[param_name] = _coerce_step_value(param_name, value, origin, param, dry_run=dry_run) + return + if param_name not in step_kwargs: + if param is None: + raise typer.BadParameter( + _tr( + "Cannot set nested attributes for '{param_name}' without signature info on '{step_name}'.", + param_name=param_name, + step_name=step_name, + ) + ) + step_kwargs[param_name] = {} if dry_run else build_wrapper_instance(param.annotation) + + +def _apply_nullable_defaults( + *, + step_kwargs: dict[str, Any], + param_map: dict[str, inspect.Parameter] | None, +) -> None: + if param_map is None: + return + for param_name, param in param_map.items(): + if param_name in step_kwargs: + continue + if param.default is not inspect._empty: + continue + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + if _annotation_allows_none(param.annotation): + step_kwargs[param_name] = None + + +def _build_step_kwargs( + step: MethodStep, + items: list[ParsedItem], + target: str, + *, + dry_run: bool = False, + qualify_parameter_names: bool = True, +) -> dict[str, Any]: + step_name = step["name"] + param_map = step["params"] + sig = step["signature"] + step_kwargs: dict[str, Any] = {} + adapter_states: dict[str, dict[str, str]] = {} + normalized_items = _normalize_step_items(items, param_map) + + _validate_step_item_path_conflicts(step_name, normalized_items) + _ensure_required_step_params( + step_name, + param_map, + normalized_items, + target, + sig, + qualify_parameter_names=qualify_parameter_names, + ) + + for path, value, origin in normalized_items: + _assign_step_item( + step_name=step_name, + path=path, + value=value, + origin=origin, + param_map=param_map, + target=target, + sig=sig, + step_kwargs=step_kwargs, + dry_run=dry_run, + ) + + for path, value, origin in normalized_items: + if len(path) > 1: + _set_nested_step_value( + step_name, + step_kwargs, + path, + value, + origin, + adapter_states=adapter_states, + dry_run=dry_run, + ) + + _apply_nullable_defaults(step_kwargs=step_kwargs, param_map=param_map) + + return step_kwargs diff --git a/src/moldflow_cli/invoke_engine.py b/src/moldflow_cli/invoke_engine.py new file mode 100644 index 0000000..18c7d3f --- /dev/null +++ b/src/moldflow_cli/invoke_engine.py @@ -0,0 +1,235 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +from typing import Any, Callable, Optional + +import typer + +from .invoke_trace import ( + _emit_invoke_trace_event, + _make_invoke_trace_hook, + _trace_error_payload, + _trace_result_payload, +) + + +MethodStep = dict[str, Any] +_UNHANDLED_INVOKE_RESULT = object() + + +@dataclass(frozen=True) +class _ResolvedInvokeTarget: + canonical_target: str + parts: list[str] + cli_to_class: dict[str, type] + method_steps: list[MethodStep] + terminal_target_info: dict[str, str] | None + + +@dataclass(frozen=True) +class _PlannedInvokeCall: + target: str + invoke_target: _ResolvedInvokeTarget + kwargs_per_step: dict[str, dict[str, Any]] + summarized_steps: list[dict[str, Any]] + + +@dataclass(frozen=True) +class _PlannedPropertyAssignment: + target: str + invoke_target: _ResolvedInvokeTarget + property_name: str + assignment_value: Any + + +def _invoke_plan_payload(planned_call: _PlannedInvokeCall) -> dict[str, Any]: + payload = { + "target": planned_call.target, + "parts": planned_call.invoke_target.parts, + "steps": planned_call.summarized_steps, + "kwargs_per_step": planned_call.kwargs_per_step, + } + if planned_call.invoke_target.terminal_target_info is not None: + payload["terminal_target"] = planned_call.invoke_target.terminal_target_info + return payload + + +def _execute_planned_invoke_call( + planned_call: _PlannedInvokeCall, + *, + execute_invoke_chain: Callable[..., Any], + build_invoke_envelope: Callable[[Any], dict[str, Any]], + trace_hook: Any | None = None, +) -> Any: + runtime_result = execute_invoke_chain( + planned_call.invoke_target.parts, + planned_call.invoke_target.cli_to_class, + planned_call.invoke_target.method_steps, + planned_call.kwargs_per_step, + planned_call.target, + trace_hook=trace_hook, + ) + if trace_hook is not None: + trace_hook( + "result", + _trace_result_payload(runtime_result, build_invoke_envelope=build_invoke_envelope), + ) + return runtime_result + + +def _execute_invoke_request( + *, + call_target: str, + call_args: list[str], + call_json_input: Optional[str], + call_json_file_input: Optional[str], + dry_run: bool, + trace_hook: Any | None, + resolve_invoke_steps: Callable[[str], _ResolvedInvokeTarget], + make_resolved_invoke_read_target: Callable[..., Any], + dispatch_terminal_invoke_target: Callable[..., Any], + plan_invoke_method_call: Callable[..., _PlannedInvokeCall], + build_dry_run_method_plan: Callable[[_PlannedInvokeCall], dict[str, Any]], + execute_invoke_chain: Callable[..., Any], + build_invoke_envelope: Callable[[Any], dict[str, Any]], +) -> Any: + invoke_target = resolve_invoke_steps(call_target) + canonical_call_target = invoke_target.canonical_target + resolved_target = make_resolved_invoke_read_target( + requested_target=canonical_call_target, + invoke_target=invoke_target, + ) + terminal_result = dispatch_terminal_invoke_target( + call_target=canonical_call_target, + invoke_target=invoke_target, + resolved_target=resolved_target, + call_args=call_args, + call_json_input=call_json_input, + call_json_file_input=call_json_file_input, + dry_run=dry_run, + trace_hook=trace_hook, + ) + if terminal_result is not _UNHANDLED_INVOKE_RESULT: + return terminal_result + planned_call = plan_invoke_method_call( + call_target=canonical_call_target, + invoke_target=invoke_target, + call_args=call_args, + call_json_input=call_json_input, + call_json_file_input=call_json_file_input, + dry_run=dry_run, + ) + plan_payload = _invoke_plan_payload(planned_call) + if trace_hook is not None: + trace_hook("plan", plan_payload) + if dry_run: + plan = build_dry_run_method_plan(planned_call) + if trace_hook is not None: + trace_hook( + "result", + _trace_result_payload( + plan, + mode="dry_run", + build_invoke_envelope=build_invoke_envelope, + ), + ) + return plan + return _execute_planned_invoke_call( + planned_call, + execute_invoke_chain=execute_invoke_chain, + build_invoke_envelope=build_invoke_envelope, + trace_hook=trace_hook, + ) + + +def _invoke_request_with_error_trace( + *, + call_target: str, + call_args: list[str], + call_json_input: Optional[str], + call_json_file_input: Optional[str], + dry_run: bool, + trace_hook: Any | None, + resolve_invoke_steps: Callable[[str], _ResolvedInvokeTarget], + make_resolved_invoke_read_target: Callable[..., Any], + dispatch_terminal_invoke_target: Callable[..., Any], + plan_invoke_method_call: Callable[..., _PlannedInvokeCall], + build_dry_run_method_plan: Callable[[_PlannedInvokeCall], dict[str, Any]], + execute_invoke_chain: Callable[..., Any], + build_invoke_envelope: Callable[[Any], dict[str, Any]], +) -> Any: + try: + return _execute_invoke_request( + call_target=call_target, + call_args=call_args, + call_json_input=call_json_input, + call_json_file_input=call_json_file_input, + dry_run=dry_run, + trace_hook=trace_hook, + resolve_invoke_steps=resolve_invoke_steps, + make_resolved_invoke_read_target=make_resolved_invoke_read_target, + dispatch_terminal_invoke_target=dispatch_terminal_invoke_target, + plan_invoke_method_call=plan_invoke_method_call, + build_dry_run_method_plan=build_dry_run_method_plan, + execute_invoke_chain=execute_invoke_chain, + build_invoke_envelope=build_invoke_envelope, + ) + except Exception as exc: + if trace_hook is not None: + error_type = "invoke_validation" if isinstance(exc, typer.BadParameter) else "runtime_error" + trace_hook("error", _trace_error_payload(exc, error_type=error_type)) + raise + + +def _invoke_single_target( + call_target: str, + call_args: list[str], + call_json_input: Optional[str], + call_json_file_input: Optional[str], + batch_index: Optional[int] = None, + *, + dry_run: bool, + trace_enabled: bool, + trace_state: dict[str, int], + canonicalize_invoke_target: Callable[[str], str], + serialize_trace_payload: Callable[[Any], Any], + schema_version: str, + resolve_invoke_steps: Callable[[str], _ResolvedInvokeTarget], + make_resolved_invoke_read_target: Callable[..., Any], + dispatch_terminal_invoke_target: Callable[..., Any], + plan_invoke_method_call: Callable[..., _PlannedInvokeCall], + build_dry_run_method_plan: Callable[[_PlannedInvokeCall], dict[str, Any]], + execute_invoke_chain: Callable[..., Any], + build_invoke_envelope: Callable[[Any], dict[str, Any]], +) -> Any: + canonical_target = canonicalize_invoke_target(call_target) + trace_hook = _make_invoke_trace_hook( + trace_enabled=trace_enabled, + trace_state=trace_state, + call_target=canonical_target, + emit_invoke_trace_event=partial( + _emit_invoke_trace_event, + serialize_payload=serialize_trace_payload, + schema_version=schema_version, + ), + batch_index=batch_index, + ) + return _invoke_request_with_error_trace( + call_target=canonical_target, + call_args=call_args, + call_json_input=call_json_input, + call_json_file_input=call_json_file_input, + dry_run=dry_run, + trace_hook=trace_hook, + resolve_invoke_steps=resolve_invoke_steps, + make_resolved_invoke_read_target=make_resolved_invoke_read_target, + dispatch_terminal_invoke_target=dispatch_terminal_invoke_target, + plan_invoke_method_call=plan_invoke_method_call, + build_dry_run_method_plan=build_dry_run_method_plan, + execute_invoke_chain=execute_invoke_chain, + build_invoke_envelope=build_invoke_envelope, + ) diff --git a/src/moldflow_cli/invoke_handlers.py b/src/moldflow_cli/invoke_handlers.py new file mode 100644 index 0000000..d8d15ac --- /dev/null +++ b/src/moldflow_cli/invoke_handlers.py @@ -0,0 +1,809 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from copy import deepcopy +from functools import partial +from typing import Any, Callable, List, Optional + +import typer + +from .invoke_binding import ( + _annotation_allows_none, + _bucket_items_by_step, + _build_kwargs_per_step, + _parse_invoke_items, + _parse_terminal_property_assignment_value, +) +from .invoke_batching import ( + _build_batch_summary, + _load_batch_payload, + _run_batch_invoke as _run_batched_calls, + _validate_batch_mode_inputs, +) +from .invoke_engine import ( + _PlannedInvokeCall, + _PlannedPropertyAssignment, + _ResolvedInvokeTarget, + _invoke_single_target as _invoke_single_target_engine, +) +from .invoke_output import ( + _emit_structured_or_human, + _render_batch_human_output, + _render_dry_run_human_output, + _render_invoke_result, +) +from .invoke_planning import ( + _build_dry_run_method_plan as _build_dry_run_method_plan_base, + _make_resolved_invoke_read_target as _make_resolved_invoke_read_target_base, + _plan_invoke_method_call as _plan_invoke_method_call_base, + _resolve_invoke_read_target as _resolve_invoke_read_target_base, + _summarize_steps as _summarize_steps_base, + _terminal_property_wrapper_class as _terminal_property_wrapper_class_base, + build_invoke_template_for_target as _build_invoke_template_for_target_base, +) +from .invoke_terminal import ( + _assign_terminal_property_target as _assign_terminal_property_target_base, + _build_property_assignment_plan as _build_property_assignment_plan_base, + _dispatch_terminal_invoke_target as _dispatch_terminal_invoke_target_base, + _execute_planned_property_assignment as _execute_planned_property_assignment_base, + _plan_property_assignment as _plan_property_assignment_base, + _property_assignment_plan_payload as _property_assignment_plan_payload_base, + _resolve_property_assignment_owner as _resolve_property_assignment_owner_base, +) +from .invoke_serialization import ( + _build_invoke_envelope as _build_invoke_envelope_serialization_base, + _serialize_trace_payload as _serialize_trace_payload_base, + _to_serializable, +) +from .invoke_resolution import ( + _canonicalize_invoke_target, + _resolve_invoke_steps, + _resolve_return_class, +) +from .invoke_runtime import ( + _execute_invoke_chain, +) +from .invoke_templates import ( + _apply_step_doc_fields as _apply_step_doc_fields_base, + _build_invoke_template as _build_invoke_template_base, + _build_property_target_template as _build_property_target_template_base, + _format_step_signature as _format_step_signature_base, + _property_return_wrapper_class as _property_return_wrapper_class_base, + _target_doc_fields_from_obj as _target_doc_fields_from_obj_base, +) +from .invoke_trace import ( + _emit_batch_invoke_trace, + _emit_invoke_trace_event, +) +from .output_utils import ( + get_console, +) +from .presentation import render_input_hints_summary as _shared_render_input_hints_summary +from .presentation import render_workflow_examples as _shared_render_workflow_examples +from .target_resolution import ( + ResolvedInvokeReadTarget, + resolve_target_for_introspection, +) +from moldflow.i18n import get_text + + +SCHEMA_VERSION = "1.0" +ParsedItem = tuple[list[str], Any, str] +MethodStep = dict[str, Any] +_T = get_text() + + +def _tr(message: str, **kwargs: Any) -> str: + text = _T(message) + return text.format(**kwargs) if kwargs else text + + +def _example_scalar_value(annotation_text: str | None, param_name: str) -> Any: + normalized = (annotation_text or "").lower() + name_lower = param_name.lower() + if "bool" in normalized: + return True + if "int" in normalized: + return 1 + if any(token in normalized for token in ("float", "double")): + return 1.0 + if name_lower == "path" or name_lower.endswith("_path"): + return f"<{param_name}>" + if "path" in name_lower: + return f"<{param_name}>" + return f"<{param_name}>" + + +def _example_cli_value(annotation_text: str | None, param_name: str) -> str: + value = _example_scalar_value(annotation_text, param_name) + return str(value).lower() if isinstance(value, bool) else str(value) + + +def _example_param_values( + *, + step_name: str, + param_name: str, + annotation_text: str | None, + input_hints: dict[str, Any] | None, + multi_step: bool, +) -> tuple[str | None, Any | None]: + examples = input_hints.get("examples") if isinstance(input_hints, dict) else None + preferred_non_json = examples.get("preferred_non_json") if isinstance(examples, dict) else None + preferred_params_json = examples.get("preferred_params_json") if isinstance(examples, dict) else None + cli_arg: str + if isinstance(preferred_non_json, str) and preferred_non_json: + cli_arg = preferred_non_json + else: + cli_prefix = f"{step_name}." if multi_step else "" + cli_arg = f"{cli_prefix}{param_name}={_example_cli_value(annotation_text, param_name)}" + if multi_step and not cli_arg.startswith(f"{step_name}."): + cli_arg = f"{step_name}.{cli_arg}" + if isinstance(preferred_params_json, dict) and preferred_params_json: + if param_name in preferred_params_json: + return cli_arg, preferred_params_json[param_name] + if len(preferred_params_json) == 1: + return cli_arg, next(iter(preferred_params_json.values())) + return cli_arg, _example_scalar_value(annotation_text, param_name) + + +def _is_variadic_param_kind(kind: str | None) -> bool: + return kind in {"VAR_POSITIONAL", "VAR_KEYWORD"} + + +def _include_param_in_example(param: dict[str, Any], *, include_optional: bool) -> bool: + if _is_variadic_param_kind(param.get("kind")): + return False + if bool(param.get("required")): + return True + return include_optional + + +def _build_example_payload( + *, + steps: list[dict[str, Any]], + include_optional: bool, +) -> tuple[list[str], dict[str, Any]]: + multi_step = len(steps) > 1 + cli_args: list[str] = [] + params_json: dict[str, Any] = {} + for step in steps: + step_json: dict[str, Any] = {} + for param in step.get("params", []): + if not _include_param_in_example(param, include_optional=include_optional): + continue + cli_arg, json_value = _example_param_values( + step_name=step["name"], + param_name=param["name"], + annotation_text=param.get("annotation"), + input_hints=param.get("input_hints") + if isinstance(param.get("input_hints"), dict) + else None, + multi_step=multi_step, + ) + if cli_arg is not None: + cli_args.append(cli_arg) + if json_value is not None: + step_json[param["name"]] = json_value + if multi_step: + params_json[step["name"]] = step_json + else: + params_json.update(step_json) + return cli_args, params_json + + +def _build_workflow_examples( + *, + target: str, + steps: list[dict[str, Any]], + mode: str, +) -> dict[str, Any]: + multi_step = len(steps) > 1 + minimal_cli_args, minimal_params_json = _build_example_payload( + steps=steps, + include_optional=False, + ) + preferred_cli_args, preferred_params_json = _build_example_payload( + steps=steps, + include_optional=True, + ) + command = f"invoke {target}" + minimal_command = command + preferred_command = command + if minimal_cli_args: + minimal_command = f"{command} {' '.join(minimal_cli_args)}" + if preferred_cli_args: + preferred_command = f"{command} {' '.join(preferred_cli_args)}" + workflow_examples: dict[str, Any] = { + "mode": mode, + "cli_command": preferred_command, + "cli_args": preferred_cli_args, + } + if preferred_params_json: + workflow_examples["params_json"] = deepcopy(preferred_params_json) + if minimal_cli_args and minimal_command != preferred_command: + workflow_examples["minimal_command"] = minimal_command + workflow_examples["minimal_args"] = minimal_cli_args + if minimal_params_json and minimal_params_json != preferred_params_json: + workflow_examples["minimal_params_json"] = deepcopy(minimal_params_json) + if multi_step: + workflow_examples["notes"] = [ + _T("For multi-step targets, group params-json fields by step name."), + ] + return workflow_examples + +def _property_return_wrapper_class( + raw_attr: property, + cli_to_class: dict[str, type], +) -> type | None: + return _property_return_wrapper_class_base( + raw_attr, + cli_to_class, + resolve_return_class=_resolve_return_class, + ) + + +def _build_property_target_template( + *, + target: str, + property_name: str, + raw_attr: property, + cli_to_class: dict[str, type], +) -> dict[str, Any]: + return _build_property_target_template_base( + target=target, + property_name=property_name, + raw_attr=raw_attr, + cli_to_class=cli_to_class, + resolve_return_class=_resolve_return_class, + annotation_allows_none=_annotation_allows_none, + build_workflow_examples=_build_workflow_examples, + schema_version=SCHEMA_VERSION, + ) + + +def _apply_step_doc_fields(step_payload: dict[str, Any], *, callable_obj: Any, obj_type: str | None = None) -> None: + return _apply_step_doc_fields_base(step_payload, callable_obj=callable_obj, obj_type=obj_type) + + +def _target_doc_fields_from_obj(obj: Any, *, obj_type: str | None = None) -> tuple[str | None, str | None]: + return _target_doc_fields_from_obj_base(obj, obj_type=obj_type) + + +def _assign_terminal_property_target( + *, + parts: list[str], + cli_to_class: dict[str, type], + call_target: str, + property_name: str, + raw_args: list[str], + json_input: Optional[str], + json_file_input: Optional[str], + trace_hook: Any | None, +) -> Any: + """Assign a value to a terminal property target and return the updated value.""" + return _assign_terminal_property_target_base( + parts=parts, + cli_to_class=cli_to_class, + call_target=call_target, + property_name=property_name, + raw_args=raw_args, + json_input=json_input, + json_file_input=json_file_input, + trace_hook=trace_hook, + parse_terminal_property_assignment_value=_parse_terminal_property_assignment_value, + execute_invoke_chain=_execute_invoke_chain, + ) + + +def _build_property_assignment_plan( + *, + call_target: str, + property_name: str, + raw_args: list[str], + json_input: Optional[str], + json_file_input: Optional[str], +) -> dict[str, Any]: + return _build_property_assignment_plan_base( + call_target=call_target, + property_name=property_name, + raw_args=raw_args, + json_input=json_input, + json_file_input=json_file_input, + parse_terminal_property_assignment_value=_parse_terminal_property_assignment_value, + build_invoke_template_for_target=build_invoke_template_for_target, + to_serializable=_to_serializable, + schema_version=SCHEMA_VERSION, + execute_invoke_chain=_execute_invoke_chain, + ) + + +def _plan_property_assignment( + *, + call_target: str, + invoke_target: _ResolvedInvokeTarget, + property_name: str, + raw_args: list[str], + json_input: Optional[str], + json_file_input: Optional[str], +) -> _PlannedPropertyAssignment: + return _plan_property_assignment_base( + call_target=call_target, + invoke_target=invoke_target, + property_name=property_name, + raw_args=raw_args, + json_input=json_input, + json_file_input=json_file_input, + parse_terminal_property_assignment_value=_parse_terminal_property_assignment_value, + execute_invoke_chain=_execute_invoke_chain, + ) + + +def _property_assignment_plan_payload( + planned_assignment: _PlannedPropertyAssignment, +) -> dict[str, Any]: + return _property_assignment_plan_payload_base( + planned_assignment, + build_invoke_template_for_target=build_invoke_template_for_target, + to_serializable=_to_serializable, + schema_version=SCHEMA_VERSION, + ) + + +def _resolve_property_assignment_owner( + *, + invoke_target: _ResolvedInvokeTarget, + call_target: str, + property_name: str, + trace_hook: Any | None, +) -> tuple[Any, property]: + return _resolve_property_assignment_owner_base( + invoke_target=invoke_target, + call_target=call_target, + property_name=property_name, + trace_hook=trace_hook, + execute_invoke_chain=_execute_invoke_chain, + ) + + +def _execute_planned_property_assignment( + planned_assignment: _PlannedPropertyAssignment, + *, + trace_hook: Any | None, +) -> Any: + return _execute_planned_property_assignment_base( + planned_assignment, + trace_hook=trace_hook, + execute_invoke_chain=_execute_invoke_chain, + ) +def _build_invoke_envelope(result: Any) -> dict[str, Any]: + return _build_invoke_envelope_serialization_base(result, schema_version=SCHEMA_VERSION) + + +def _serialize_trace_payload(payload: Any) -> Any: + return _serialize_trace_payload_base(payload, schema_version=SCHEMA_VERSION) + + +def _format_step_signature(signature: Any) -> str: + return _format_step_signature_base(signature) + + +def _build_invoke_template(target: str, method_steps: list[MethodStep]) -> dict[str, Any]: + return _build_invoke_template_base( + target, + method_steps, + annotation_allows_none=_annotation_allows_none, + build_workflow_examples=_build_workflow_examples, + schema_version=SCHEMA_VERSION, + ) + + +def _make_resolved_invoke_read_target( + *, + requested_target: str, + invoke_target: _ResolvedInvokeTarget, +) -> ResolvedInvokeReadTarget: + return _make_resolved_invoke_read_target_base( + requested_target=requested_target, + invoke_target=invoke_target, + resolve_target_for_introspection=resolve_target_for_introspection, + ) + + +def _resolve_invoke_read_target(target: str) -> ResolvedInvokeReadTarget: + return _resolve_invoke_read_target_base( + target, + resolve_invoke_steps=_resolve_invoke_steps, + resolve_target_for_introspection=resolve_target_for_introspection, + ) + + +def build_invoke_template_for_target(target: str) -> dict[str, Any]: + """Build template metadata for a target, including terminal property targets.""" + return _build_invoke_template_for_target_base( + target, + resolve_invoke_read_target=_resolve_invoke_read_target, + build_invoke_template=_build_invoke_template, + target_doc_fields_from_obj=_target_doc_fields_from_obj, + build_property_target_template=_build_property_target_template, + schema_version=SCHEMA_VERSION, + ) + + +def _terminal_property_wrapper_class( + resolved_target: ResolvedInvokeReadTarget, +) -> type | None: + return _terminal_property_wrapper_class_base( + resolved_target, + property_return_wrapper_class=_property_return_wrapper_class, + ) + + +def _summarize_steps(method_steps: list[MethodStep]) -> list[dict[str, Any]]: + return _summarize_steps_base( + method_steps, + format_step_signature=_format_step_signature, + apply_step_doc_fields=lambda payload, callable_obj: _apply_step_doc_fields( + payload, + callable_obj=callable_obj, + ), + ) + + +def _plan_invoke_method_call( + *, + call_target: str, + invoke_target: _ResolvedInvokeTarget, + call_args: list[str], + call_json_input: Optional[str], + call_json_file_input: Optional[str], + dry_run: bool, +) -> _PlannedInvokeCall: + return _plan_invoke_method_call_base( + call_target=call_target, + invoke_target=invoke_target, + call_args=call_args, + call_json_input=call_json_input, + call_json_file_input=call_json_file_input, + dry_run=dry_run, + parse_invoke_items=_parse_invoke_items, + bucket_items_by_step=_bucket_items_by_step, + build_kwargs_per_step=_build_kwargs_per_step, + summarize_steps=_summarize_steps, + ) + + +def _build_dry_run_method_plan(planned_call: _PlannedInvokeCall) -> dict[str, Any]: + return _build_dry_run_method_plan_base( + planned_call, + build_invoke_template_for_target=build_invoke_template_for_target, + serialize_value=_to_serializable, + schema_version=SCHEMA_VERSION, + ) + + +def _dispatch_terminal_invoke_target( + *, + call_target: str, + invoke_target: _ResolvedInvokeTarget, + resolved_target: ResolvedInvokeReadTarget, + call_args: list[str], + call_json_input: Optional[str], + call_json_file_input: Optional[str], + dry_run: bool, + trace_hook: Any | None, +) -> Any: + return _dispatch_terminal_invoke_target_base( + call_target=call_target, + invoke_target=invoke_target, + resolved_target=resolved_target, + call_args=call_args, + call_json_input=call_json_input, + call_json_file_input=call_json_file_input, + dry_run=dry_run, + trace_hook=trace_hook, + terminal_property_wrapper_class=_terminal_property_wrapper_class, + parse_terminal_property_assignment_value=_parse_terminal_property_assignment_value, + build_invoke_template_for_target=build_invoke_template_for_target, + to_serializable=_to_serializable, + schema_version=SCHEMA_VERSION, + execute_invoke_chain=_execute_invoke_chain, + build_invoke_envelope=_build_invoke_envelope, + ) + + +def _invoke_single_target( + call_target: str, + call_args: list[str], + call_json_input: Optional[str], + call_json_file_input: Optional[str], + batch_index: Optional[int] = None, + *, + dry_run: bool, + trace_enabled: bool, + trace_state: dict[str, int], +) -> Any: + return _invoke_single_target_engine( + call_target, + call_args=call_args, + call_json_input=call_json_input, + call_json_file_input=call_json_file_input, + batch_index=batch_index, + dry_run=dry_run, + trace_enabled=trace_enabled, + trace_state=trace_state, + canonicalize_invoke_target=_canonicalize_invoke_target, + serialize_trace_payload=_serialize_trace_payload, + schema_version=SCHEMA_VERSION, + resolve_invoke_steps=_resolve_invoke_steps, + make_resolved_invoke_read_target=_make_resolved_invoke_read_target, + dispatch_terminal_invoke_target=_dispatch_terminal_invoke_target, + plan_invoke_method_call=_plan_invoke_method_call, + build_dry_run_method_plan=_build_dry_run_method_plan, + execute_invoke_chain=_execute_invoke_chain, + build_invoke_envelope=_build_invoke_envelope, + ) + + +def _render_workflow_example_summary(console: Any, workflow_examples: dict[str, Any]) -> None: + _shared_render_workflow_examples( + console, + workflow_examples, + params_json_context="invoke-workflow-example", + minimal_params_json_context="invoke-workflow-example-minimal", + ) + + +def _render_input_hints_summary(console: Any, payload: dict[str, Any]) -> None: + """Render wrapper-specific input hints for human describe/template flows.""" + _shared_render_input_hints_summary(console, payload) + + +def _build_batch_trace_emitter( + *, + trace_enabled: bool, + trace_state: dict[str, int], +) -> Callable[[int, str | None, str, dict[str, Any]], None] | None: + if not trace_enabled: + return None + return partial( + _emit_batch_invoke_trace, + emit_invoke_trace_event=partial( + _emit_invoke_trace_event, + serialize_payload=_serialize_trace_payload, + schema_version=SCHEMA_VERSION, + ), + trace_enabled=trace_enabled, + trace_state=trace_state, + ) + + +def _run_batch_invoke_request( + *, + batch_payload: list[Any], + dry_run: bool, + fail_on_false: bool, + trace_enabled: bool, + trace_state: dict[str, int], +) -> tuple[list[dict[str, Any]], bool]: + return _run_batched_calls( + batch_payload=batch_payload, + invoke_once=partial( + _invoke_single_target, + dry_run=dry_run, + trace_enabled=trace_enabled, + trace_state=trace_state, + ), + dry_run=dry_run, + fail_on_false=fail_on_false, + build_invoke_envelope=_build_invoke_envelope, + emit_batch_trace=_build_batch_trace_emitter( + trace_enabled=trace_enabled, + trace_state=trace_state, + ), + ) + + +def _handle_batch_invoke_mode( + *, + console: Any, + batch_file: str, + target: Optional[str], + args: list[str], + json_input: Optional[str], + json_file_input: Optional[str], + dry_run: bool, + fail_on_false: bool, + trace_enabled: bool, + trace_state: dict[str, int], + json_output: bool, + json_file_output: Optional[str], + translate: Any, +) -> None: + _validate_batch_mode_inputs( + target=target, + args=args, + json_input=json_input, + json_file_input=json_file_input, + translate=translate, + ) + batch_payload = _load_batch_payload(batch_file) + results, any_error = _run_batch_invoke_request( + batch_payload=batch_payload, + dry_run=dry_run, + fail_on_false=fail_on_false, + trace_enabled=trace_enabled, + trace_state=trace_state, + ) + _emit_structured_or_human( + console=console, + payload={ + "schema_version": SCHEMA_VERSION, + "summary": _build_batch_summary(results), + "batch_results": results, + }, + context="invoke-batch", + json_output=json_output, + json_file_output=json_file_output, + human_renderer=_render_batch_human_output, + ) + if any_error: + raise typer.Exit(code=1) + + +def _handle_single_invoke_mode( + *, + console: Any, + target: Optional[str], + args: list[str], + json_input: Optional[str], + json_file_input: Optional[str], + dry_run: bool, + fail_on_false: bool, + trace_enabled: bool, + trace_state: dict[str, int], + json_output: bool, + json_file_output: Optional[str], + translate: Any, +) -> None: + if target is None: + raise typer.BadParameter(translate("TARGET is required unless --batch-file is used.")) + result = _invoke_single_target( + target, + args, + json_input, + json_file_input, + dry_run=dry_run, + trace_enabled=trace_enabled, + trace_state=trace_state, + ) + if dry_run: + _emit_structured_or_human( + console=console, + payload=result, + context="invoke-dry-run", + json_output=json_output, + json_file_output=json_file_output, + human_renderer=_render_dry_run_human_output, + ) + return + envelope = _render_invoke_result( + console, + result, + json_output, + json_file_output, + build_invoke_envelope=_build_invoke_envelope, + ) + if fail_on_false and envelope["ok"] is False: + raise typer.Exit(code=1) + + +def invoke_cmd( + target: Optional[str] = typer.Argument( + None, + help=_T( + "Dotted path to a method or function, optionally chained, for example 'synergy.new_project' or 'synergy.plot_manager.find_plot_by_name'." + ), + ), + args: List[str] = typer.Argument( + None, + help=_T( + "Duplicate/conflicting paths are rejected. Arguments are passed as key=value or " + "param.attr=value. For chained targets, prefix the parameter with the method name, " + "e.g. find_plot_by_name.plot_name=\"My Plot\". Nested routing uses step.param.attr=value " + "(param=1 conflicts with param.attr=2). Methods with positional-only parameters are " + "not supported by named CLI routing." + ), + ), + json_input: Optional[str] = typer.Option( + None, + "--params-json", + help=_T( + "JSON object containing parameter mappings (overrides positional args). Top-level arrays/scalars are not allowed." + ), + ), + json_file_input: Optional[str] = typer.Option( + None, + "--params-json-file", + "-J", + help=_T( + "Path to a JSON file containing parameter mappings (overrides positional args). The top-level payload must be an object, not arrays/scalars." + ), + ), + json_output: bool = typer.Option( + False, + "--json", + "--json-output", + help=_T( + "Emit structured result JSON to stdout. Use --json as the canonical flag; " + "--json-output is a legacy alias kept for compatibility." + ), + ), + json_file_output: Optional[str] = typer.Option( + None, + "--json-file-output", + help=_T("Write JSON output to the given file path without changing stdout mode."), + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help=_T("Parse/validate/build kwargs and emit a template summary call plan without executing invoke steps."), + ), + batch_file: Optional[str] = typer.Option( + None, + "--batch-file", + help=_T("Path to a JSON file containing an array of invoke calls for batch execution."), + ), + trace: bool = typer.Option( + False, + "--trace", + help=_T("Emit line-delimited JSON trace events to stderr for target resolution and runtime invoke binding."), + ), + fail_on_false: bool = typer.Option( + True, + "--fail-on-false/--no-fail-on-false", + help=_T( + "Treat False return values as CLI failures (exit 1). This is enabled by default for automation-friendly behavior." + ), + ), +) -> None: + "Invoke a moldflow target with named parameters." + _ = get_text() + console = get_console() + trace_state = {"sequence": 0} + raw_args: list[str] = args or [] + + if batch_file is not None: + _handle_batch_invoke_mode( + console=console, + batch_file=batch_file, + target=target, + args=raw_args, + json_input=json_input, + json_file_input=json_file_input, + dry_run=dry_run, + fail_on_false=fail_on_false, + trace_enabled=trace, + trace_state=trace_state, + json_output=json_output, + json_file_output=json_file_output, + translate=_, + ) + return + + _handle_single_invoke_mode( + console=console, + target=target, + args=raw_args, + json_input=json_input, + json_file_input=json_file_input, + dry_run=dry_run, + fail_on_false=fail_on_false, + trace_enabled=trace, + trace_state=trace_state, + json_output=json_output, + json_file_output=json_file_output, + translate=_, + ) + diff --git a/src/moldflow_cli/invoke_output.py b/src/moldflow_cli/invoke_output.py new file mode 100644 index 0000000..e9cafed --- /dev/null +++ b/src/moldflow_cli/invoke_output.py @@ -0,0 +1,244 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any, Callable, Optional + +import typer + +from .constants import CLI_KIND_PROPERTY, CLI_KIND_PROPERTY_GETTER +from .output_utils import ( + human_output_pager, + human_table_kwargs, + render_serialized_value, + to_json_text, +) +from .presentation import human_signature_text as _shared_human_signature_text +from moldflow.i18n import get_text + + +_T = get_text() + + +def _tr(message: str, **kwargs: Any) -> str: + text = _T(message) + return text.format(**kwargs) if kwargs else text + + +def _build_invoke_envelope( + result: Any, + *, + schema_version: str, + serialize_result: Callable[[Any], Any], +) -> dict[str, Any]: + """Create stable JSON envelope for invoke results.""" + ok = bool(result) if isinstance(result, bool) else True + envelope = { + "schema_version": schema_version, + "ok": ok, + "result_type": type(result).__name__ if result is not None else "NoneType", + "result": serialize_result(result), + } + if isinstance(result, bool) and result is False: + envelope["diagnostics"] = [ + _T("The target returned False, which indicates a business-level failure.") + ] + return envelope + + +def _render_invoke_result( + console: Any, + result: Any, + json_output: bool, + json_file_output: Optional[str], + *, + build_invoke_envelope: Callable[[Any], dict[str, Any]], +) -> dict[str, Any]: + envelope = build_invoke_envelope(result) + serializable_result = envelope + if json_file_output: + try: + with open(json_file_output, "w", encoding="utf-8") as output_file: + output_file.write(to_json_text(serializable_result, context="invoke", default=str)) + except (OSError, TypeError, ValueError) as exc: + raise typer.BadParameter( + _tr("Cannot write JSON file '{path}': {error}", path=json_file_output, error=exc) + ) from exc + + if json_output: + typer.echo(to_json_text(serializable_result, context="invoke", default=str)) + return envelope + with human_output_pager(console): + render_serialized_value(console, envelope["result"], context="invoke-human") + for diagnostic in envelope.get("diagnostics", []): + console.print(diagnostic, markup=False) + if json_file_output: + _emit_json_file_written_notice(console, json_file_output) + return envelope + + +def _emit_structured_json( + payload: Any, + *, + context: str, + json_file_output: Optional[str], + emit_stdout: bool = True, +) -> None: + json_text = to_json_text(payload, context=context, default=str) + if json_file_output: + try: + with open(json_file_output, "w", encoding="utf-8") as output_file: + output_file.write(json_text) + except (OSError, TypeError, ValueError) as exc: + raise typer.BadParameter( + _tr("Cannot write JSON file '{path}': {error}", path=json_file_output, error=exc) + ) from exc + if emit_stdout: + typer.echo(json_text) + + +def _emit_json_file_written_notice(console: Any, json_file_output: str) -> None: + """Tell human-mode users where the structured payload was written.""" + console.print( + _T("Wrote structured output to {path}.").format(path=json_file_output), + markup=False, + ) + + +def _build_dry_run_template_command(payload: dict[str, Any]) -> str | None: + """Build a compact placeholder command summary for dry-run human output.""" + target = payload.get("target") + if not isinstance(target, str) or not target: + return None + steps = payload.get("steps") + if not isinstance(steps, list): + return f"invoke {target}" + command_parts = ["invoke", target] + multi_step = len(steps) > 1 + for step in steps: + if not isinstance(step, dict): + continue + step_name = step.get("name") + params = step.get("params") + if not isinstance(params, list): + continue + for param in params: + if isinstance(param, dict): + param_name = param.get("name") + param_kind = param.get("kind") + if param_kind in {"VAR_POSITIONAL", "VAR_KEYWORD"}: + continue + elif isinstance(param, str): + param_name = param + else: + continue + if not isinstance(param_name, str) or not param_name: + continue + prefix = f"{step_name}." if multi_step and isinstance(step_name, str) and step_name else "" + command_parts.append(f"{prefix}{param_name}=<{param_name}>") + return " ".join(command_parts) + + +def _render_dry_run_human_output(console: Any, payload: dict[str, Any]) -> None: + console.print( + _T("Dry run for {target}").format(target=payload.get("target", "")), + markup=False, + ) + template_command = _build_dry_run_template_command(payload) + if isinstance(template_command, str) and template_command: + console.print(_T("Template summary:"), markup=False) + console.print(template_command, markup=False) + terminal_target = payload.get("terminal_target") + if isinstance(terminal_target, dict) and terminal_target.get("kind") in {CLI_KIND_PROPERTY, CLI_KIND_PROPERTY_GETTER}: + assignment = terminal_target.get("assignment") is True + if assignment: + console.print(_T("This dry run validates a property assignment."), markup=False) + else: + console.print(_T("This property is read-only and takes no arguments."), markup=False) + steps = payload.get("steps") + if isinstance(steps, list) and steps: + show_steps = len(steps) > 1 or any( + isinstance(step, dict) and bool(step.get("deferred")) for step in steps + ) + if show_steps: + console.print(_T("Planned steps:"), markup=False) + for step in steps: + if isinstance(step, dict): + name = step.get("name", "") + signature = _shared_human_signature_text(step.get("signature", "(...)")) or "(...)" + if step.get("deferred"): + console.print(f"- {name} [deferred] {signature}", markup=False) + else: + console.print(f"- {name}{signature}", markup=False) + assignment = payload.get("assignment") + if isinstance(assignment, dict) and assignment: + console.print(_T("Resolved assignment:"), markup=False) + console.print(to_json_text(assignment, context="invoke-dry-run-assignment"), markup=False) + kwargs_per_step = payload.get("kwargs_per_step") + if kwargs_per_step: + console.print(_T("Resolved kwargs:"), markup=False) + console.print(to_json_text(kwargs_per_step, context="invoke-dry-run-human"), markup=False) + + +def _render_batch_human_output(console: Any, payload: dict[str, Any]) -> None: + from rich.table import Table + + summary = payload.get("summary") if isinstance(payload, dict) else None + if isinstance(summary, dict): + console.print( + _T("Batch summary: {succeeded}/{total} succeeded, {failed} failed.").format( + succeeded=summary.get("succeeded", 0), + total=summary.get("total", 0), + failed=summary.get("failed", 0), + ), + markup=False, + ) + table = Table(title=_T("Batch results"), **human_table_kwargs(console)) + table.add_column(_T("Index")) + table.add_column(_T("Target")) + table.add_column(_T("Status")) + table.add_column(_T("Detail")) + for result in payload.get("batch_results", []): + if not isinstance(result, dict): + continue + status = _T("ok") if result.get("ok") else _T("failed") + detail = result.get("result_type") or result.get("error_type") or "" + table.add_row( + str(result.get("index", "")), + str(result.get("target", "")), + status, + str(detail), + ) + console.print(table) + for result in payload.get("batch_results", []): + if isinstance(result, dict) and result.get("ok") is False and result.get("error"): + console.print( + _T("Batch item {index} error: {error}").format( + index=result.get("index", "?"), + error=result.get("error"), + ), + markup=False, + ) + + +def _emit_structured_or_human( + *, + console: Any, + payload: dict[str, Any], + context: str, + json_output: bool, + json_file_output: Optional[str], + human_renderer: Callable[[Any, dict[str, Any]], None], +) -> None: + _emit_structured_json( + payload, + context=context, + json_file_output=json_file_output, + emit_stdout=json_output, + ) + if not json_output: + with human_output_pager(console): + human_renderer(console, payload) + if json_file_output: + _emit_json_file_written_notice(console, json_file_output) diff --git a/src/moldflow_cli/invoke_planning.py b/src/moldflow_cli/invoke_planning.py new file mode 100644 index 0000000..e4d4310 --- /dev/null +++ b/src/moldflow_cli/invoke_planning.py @@ -0,0 +1,190 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any, Callable + +from .constants import CLI_KIND_PROPERTY_GETTER +from .invoke_engine import _PlannedInvokeCall, _ResolvedInvokeTarget +from .target_resolution import ResolvedInvokeReadTarget + + +def _make_resolved_invoke_read_target( + *, + requested_target: str, + invoke_target: _ResolvedInvokeTarget, + resolve_target_for_introspection: Callable[[str], Any], +) -> ResolvedInvokeReadTarget: + resolved_object = None + if invoke_target.terminal_target_info is not None: + resolved_object = resolve_target_for_introspection(invoke_target.canonical_target) + return ResolvedInvokeReadTarget( + requested_target=requested_target, + canonical_target=invoke_target.canonical_target, + parts=invoke_target.parts, + cli_to_class=invoke_target.cli_to_class, + method_steps=invoke_target.method_steps, + terminal_target_info=invoke_target.terminal_target_info, + resolved_object=resolved_object, + ) + + +def _resolve_invoke_read_target( + target: str, + *, + resolve_invoke_steps: Callable[[str], _ResolvedInvokeTarget], + resolve_target_for_introspection: Callable[[str], Any], +) -> ResolvedInvokeReadTarget: + invoke_target = resolve_invoke_steps(target) + return _make_resolved_invoke_read_target( + requested_target=target, + invoke_target=invoke_target, + resolve_target_for_introspection=resolve_target_for_introspection, + ) + + +def build_invoke_template_for_target( + target: str, + *, + resolve_invoke_read_target: Callable[[str], ResolvedInvokeReadTarget], + build_invoke_template: Callable[[str, list[dict[str, Any]]], dict[str, Any]], + target_doc_fields_from_obj: Callable[[Any], tuple[str | None, str | None]], + build_property_target_template: Callable[..., dict[str, Any]], + schema_version: str, +) -> dict[str, Any]: + """Build template metadata for a target, including terminal property targets.""" + resolved_target = resolve_invoke_read_target(target) + canonical_target = resolved_target.canonical_target + terminal_target_info = resolved_target.terminal_target_info + if terminal_target_info is None: + return build_invoke_template(canonical_target, resolved_target.method_steps) + obj = resolved_target.resolved_object + if not isinstance(obj, property): + summary_value, details_value = target_doc_fields_from_obj(obj) + payload = { + "schema_version": schema_version, + "target": canonical_target, + "mode": "attribute_read", + "terminal_target": { + "kind": terminal_target_info.get("kind", "attribute"), + "property": terminal_target_info.get("name"), + }, + "steps": [], + "params_json_template": {}, + "workflow_examples": { + "mode": "attribute_read", + "cli_command": f"invoke {canonical_target}", + "cli_args": [], + }, + } + if summary_value is not None: + payload["summary"] = summary_value + if details_value is not None: + payload["details"] = details_value + return payload + return build_property_target_template( + target=canonical_target, + property_name=terminal_target_info["name"], + raw_attr=obj, + cli_to_class=resolved_target.cli_to_class, + ) + + +def _terminal_property_wrapper_class( + resolved_target: ResolvedInvokeReadTarget, + *, + property_return_wrapper_class: Callable[[property, dict[str, type]], type | None], +) -> type | None: + """Return the wrapper class for a terminal property target when it yields a wrapper handle.""" + terminal_target_info = resolved_target.terminal_target_info + if terminal_target_info is None or terminal_target_info.get("kind") != CLI_KIND_PROPERTY_GETTER: + return None + obj = resolved_target.resolved_object + if not isinstance(obj, property): + return None + return property_return_wrapper_class(obj, resolved_target.cli_to_class) + + +def _summarize_steps( + method_steps: list[dict[str, Any]], + *, + format_step_signature: Callable[[Any], str], + apply_step_doc_fields: Callable[[dict[str, Any], Any], None], +) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for step in method_steps: + param_map = step.get("params") + step_payload = { + "name": step["name"], + "deferred": step.get("signature") is None and step.get("params") is None, + "signature": format_step_signature(step.get("signature")), + "params": sorted(list(param_map.keys())) if isinstance(param_map, dict) else [], + } + callable_obj = step.get("callable") + if callable_obj is not None: + apply_step_doc_fields(step_payload, callable_obj) + out.append(step_payload) + return out + + +def _plan_invoke_method_call( + *, + call_target: str, + invoke_target: _ResolvedInvokeTarget, + call_args: list[str], + call_json_input: str | None, + call_json_file_input: str | None, + dry_run: bool, + parse_invoke_items: Callable[..., list[Any]], + bucket_items_by_step: Callable[[list[dict[str, Any]], list[Any]], Any], + build_kwargs_per_step: Callable[..., dict[str, dict[str, Any]]], + summarize_steps: Callable[[list[dict[str, Any]]], list[dict[str, Any]]], +) -> _PlannedInvokeCall: + parsed_items = parse_invoke_items( + invoke_target.method_steps, + call_args, + call_json_input, + call_json_file_input, + ) + step_args = bucket_items_by_step(invoke_target.method_steps, parsed_items) + kwargs_per_step = build_kwargs_per_step( + invoke_target.method_steps, + step_args, + call_target, + dry_run=dry_run, + ) + return _PlannedInvokeCall( + target=call_target, + invoke_target=invoke_target, + kwargs_per_step=kwargs_per_step, + summarized_steps=summarize_steps(invoke_target.method_steps), + ) + + +def _build_dry_run_method_plan( + planned_call: _PlannedInvokeCall, + *, + build_invoke_template_for_target: Callable[[str], dict[str, Any]], + serialize_value: Callable[[Any], Any], + schema_version: str, +) -> dict[str, Any]: + template_payload = build_invoke_template_for_target(planned_call.target) + dry_run_steps = template_payload.get("steps") + if not isinstance(dry_run_steps, list): + dry_run_steps = planned_call.summarized_steps + plan = { + "schema_version": schema_version, + "mode": "dry_run", + "target": planned_call.target, + "parts": planned_call.invoke_target.parts, + "steps": dry_run_steps, + "kwargs_per_step": serialize_value(planned_call.kwargs_per_step), + } + for doc_field in ("summary", "details"): + doc_value = template_payload.get(doc_field) + if isinstance(doc_value, str) and doc_value: + plan[doc_field] = doc_value + if planned_call.invoke_target.terminal_target_info is not None: + plan["terminal_target"] = planned_call.invoke_target.terminal_target_info + return plan diff --git a/src/moldflow_cli/invoke_resolution.py b/src/moldflow_cli/invoke_resolution.py new file mode 100644 index 0000000..acca136 --- /dev/null +++ b/src/moldflow_cli/invoke_resolution.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import inspect +from typing import Any, Optional + +import typer + +from moldflow.i18n import get_text + +from .constants import CLI_KIND_PROPERTY_GETTER, CLI_KIND_PROPERTY_SETTER_ONLY +from .factories import camel_to_snake +from .introspection import HiddenCliTargetError, validate_cli_target_visible +from .invoke_engine import _ResolvedInvokeTarget +from .invoke_runtime import _signature_and_param_map +from .target_resolution import ( + canonicalize_invoke_target, + resolve_attr_name_case_insensitive, + resolve_callable_attr_case_insensitive, + split_dotted_path, +) +from .type_annotations import extract_non_none_type_names +from .wrapper_registry import public_wrapper_cli_map + + +MethodStep = dict[str, Any] +_T = get_text() + + +def _match_wrapper_classes_by_type_names( + type_names: list[str], + cli_to_class: dict[str, type], +) -> set[type]: + matches: set[type] = set() + classes = set(cli_to_class.values()) + for type_name in type_names: + normalized_type_name = type_name.lower() + for key, cls in cli_to_class.items(): + if isinstance(key, str) and key.lower() == normalized_type_name: + matches.add(cls) + for cls in classes: + class_name = getattr(cls, "__name__", "") + if not isinstance(class_name, str) or not class_name: + continue + if class_name.lower() == normalized_type_name: + matches.add(cls) + continue + if camel_to_snake(class_name).lower() == normalized_type_name: + matches.add(cls) + return matches + + +def _extract_wrapper_type_names_from_annotation(annotation: Any) -> list[str]: + return extract_non_none_type_names(annotation) + + +def _resolve_return_class(ret: Any, cli_to_class: dict[str, type]) -> Optional[type]: + """Best-effort resolve of a return annotation to a known wrapper class.""" + if ret is inspect._empty: + return None + + if isinstance(ret, type): + for cls in cli_to_class.values(): + if cls is ret: + return cls + + matches = _match_wrapper_classes_by_type_names( + _extract_wrapper_type_names_from_annotation(ret), + cli_to_class, + ) + if len(matches) == 1: + return next(iter(matches)) + return None + + +def _has_ambiguous_wrapper_union(ret: Any, cli_to_class: dict[str, type]) -> bool: + """Return True when return annotation names multiple possible wrapper classes.""" + if ret is inspect._empty: + return False + + matches = _match_wrapper_classes_by_type_names( + _extract_wrapper_type_names_from_annotation(ret), + cli_to_class, + ) + return len(matches) > 1 + + +def _classify_terminal_class_attr(cls: type, attr_name: str) -> str: + """Classify a terminal class attribute for invoke target validation.""" + try: + raw_attr = inspect.getattr_static(cls, attr_name) + except AttributeError: + return "attribute" + if isinstance(raw_attr, property): + if raw_attr.fget is None: + return CLI_KIND_PROPERTY_SETTER_ONLY + return CLI_KIND_PROPERTY_GETTER + return "attribute" + + +def _canonicalize_invoke_target(target: str) -> str: + return canonicalize_invoke_target(target, translate=get_text()) + + +def _normalize_invoke_target_parts(target: str) -> list[str]: + canonical_target = _canonicalize_invoke_target(target) + return split_dotted_path(canonical_target, field_name="target", translate=_T) + + +def _build_cli_to_class_map() -> dict[str, type]: + return dict(public_wrapper_cli_map()) + + +def _validate_unique_step_names(method_steps: list[MethodStep]) -> None: + method_name_counts: dict[str, int] = {} + for step in method_steps: + key = step["name"].lower() + method_name_counts[key] = method_name_counts.get(key, 0) + 1 + duplicate_step_names = sorted( + {step["name"] for step in method_steps if method_name_counts[step["name"].lower()] > 1} + ) + if duplicate_step_names: + raise typer.BadParameter( + _T( + "Chained targets with repeated method names are ambiguous for argument routing: " + "{duplicate_names}. " + "Please use an equivalent target path where each invoked step name is unique." + ).format(duplicate_names=", ".join(duplicate_step_names)) + ) + + +def _append_deferred_method_step(method_steps: list[MethodStep], segment: str) -> None: + method_steps.append( + { + "name": segment, + "callable": None, + "signature": None, + "params": None, + "class": None, + } + ) + + +def _resolve_callable_candidate_for_segment( + current_cls: type | None, + segment: str, +) -> tuple[str | None, Any | None]: + if current_cls is None: + return None, None + try: + return resolve_callable_attr_case_insensitive(current_cls, segment) + except AttributeError: + return None, None + + +def _resolve_class_alias_segment( + *, + parts: list[str], + index: int, + segment: str, + current_cls: type | None, + cli_to_class: dict[str, type], + target: str, +) -> type: + if current_cls is not None: + try: + canonical_attr = resolve_attr_name_case_insensitive(current_cls, segment) + if canonical_attr is None: + raise AttributeError(segment) + except AttributeError as exc: + raise typer.BadParameter( + _T( + "Segment '{segment}' does not resolve as an attribute on class " + "'{class_name}' when resolving target '{target}'" + ).format(segment=segment, class_name=current_cls.__name__, target=target) + ) from exc + parts[index] = canonical_attr + return cli_to_class[segment.lower()] + + +def _record_callable_step( + *, + parts: list[str], + index: int, + step_name: str, + callable_attr: Any, + method_steps: list[MethodStep], + current_cls: type, + cli_to_class: dict[str, type], + runtime_deferred_mode: bool, +) -> tuple[type | None, bool]: + parts[index] = step_name + sig, param_map = _signature_and_param_map(callable_attr) + method_steps.append( + { + "name": step_name, + "callable": callable_attr, + "signature": sig, + "params": param_map, + "class": current_cls, + } + ) + if sig is None: + return current_cls, runtime_deferred_mode + + ret_cls = _resolve_return_class(sig.return_annotation, cli_to_class) + if ret_cls is not None: + return ret_cls, False + if _has_ambiguous_wrapper_union(sig.return_annotation, cli_to_class): + return None, True + return current_cls, runtime_deferred_mode + + +def _resolve_non_callable_segment( + *, + parts: list[str], + index: int, + segment: str, + current_cls: type, + runtime_deferred_mode: bool, + method_steps: list[MethodStep], + target: str, +) -> dict[str, str] | None: + canonical_attr = resolve_attr_name_case_insensitive(current_cls, segment) + + if canonical_attr is not None and index == len(parts) - 1: + attr_kind = _classify_terminal_class_attr(current_cls, canonical_attr) + if attr_kind == CLI_KIND_PROPERTY_SETTER_ONLY: + raise typer.BadParameter( + _T( + "Target '{target}' resolves to write-only property '{property_name}' " + "and cannot be read via invoke." + ).format(target=target, property_name=canonical_attr) + ) + parts[index] = canonical_attr + return {"name": canonical_attr, "kind": attr_kind} + + if runtime_deferred_mode: + _append_deferred_method_step(method_steps, segment) + return None + + raise typer.BadParameter( + _T( + "Segment '{segment}' is not a callable method on class '{class_name}' " + "when resolving target '{target}'" + ).format(segment=segment, class_name=current_cls.__name__, target=target) + ) + + +def _resolve_invoke_steps(target: str) -> _ResolvedInvokeTarget: + canonical_target = _canonicalize_invoke_target(target) + try: + validate_cli_target_visible(canonical_target) + except HiddenCliTargetError as exc: + raise typer.BadParameter(str(exc)) from exc + parts = _normalize_invoke_target_parts(canonical_target) + cli_to_class = _build_cli_to_class_map() + method_steps: list[MethodStep] = [] + current_cls = None + runtime_deferred_mode = False + terminal_target_info: dict[str, str] | None = None + + for idx, seg in enumerate(parts): + seg_lower = seg.lower() + callable_candidate_name, callable_candidate = _resolve_callable_candidate_for_segment( + current_cls, + seg, + ) + + if seg_lower in cli_to_class and callable_candidate is None: + prefer_terminal_attr = False + if current_cls is not None and idx == len(parts) - 1: + prefer_terminal_attr = ( + resolve_attr_name_case_insensitive(current_cls, seg) is not None + ) + if not prefer_terminal_attr: + current_cls = _resolve_class_alias_segment( + parts=parts, + index=idx, + segment=seg, + current_cls=current_cls, + cli_to_class=cli_to_class, + target=canonical_target, + ) + continue + if current_cls is None: + if runtime_deferred_mode: + _append_deferred_method_step(method_steps, seg) + continue + raise typer.BadParameter( + _T( + "Cannot resolve segment '{segment}' in target '{target}' without a class context. " + "Use a Synergy-rooted target such as 'synergy.some_method'." + ).format(segment=seg, target=canonical_target) + ) + if callable_candidate is None or callable_candidate_name is None: + terminal_info = _resolve_non_callable_segment( + parts=parts, + index=idx, + segment=seg, + current_cls=current_cls, + runtime_deferred_mode=runtime_deferred_mode, + method_steps=method_steps, + target=canonical_target, + ) + if terminal_info is not None: + terminal_target_info = terminal_info + continue + + current_cls, runtime_deferred_mode = _record_callable_step( + parts=parts, + index=idx, + step_name=callable_candidate_name, + callable_attr=callable_candidate, + method_steps=method_steps, + current_cls=current_cls, + cli_to_class=cli_to_class, + runtime_deferred_mode=runtime_deferred_mode, + ) + + _validate_unique_step_names(method_steps) + canonical_target = ".".join(parts) + return _ResolvedInvokeTarget( + canonical_target=canonical_target, + parts=parts, + cli_to_class=cli_to_class, + method_steps=method_steps, + terminal_target_info=terminal_target_info, + ) diff --git a/src/moldflow_cli/invoke_runtime.py b/src/moldflow_cli/invoke_runtime.py new file mode 100644 index 0000000..f1f38a6 --- /dev/null +++ b/src/moldflow_cli/invoke_runtime.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import inspect +from typing import Any + +import typer + +from moldflow.i18n import get_text + +from .constants import CLI_ROOT_SYNERGY + +from .invoke_binding import _build_step_kwargs +from .target_resolution import resolve_callable_attr_case_insensitive + + +MethodStep = dict[str, Any] +_T = get_text() + + +def _tr(message: str, **kwargs: Any) -> str: + text = _T(message) + return text.format(**kwargs) if kwargs else text + + +def _strip_receiver_from_signature(signature: inspect.Signature) -> inspect.Signature: + """Remove an implicit receiver parameter from inspectable method signatures.""" + params = list(signature.parameters.values()) + if params and params[0].name in {"self", "cls"}: + signature = signature.replace(parameters=params[1:]) + return signature + + +def _signature_and_param_map(callable_obj: Any) -> tuple[Any, dict[str, inspect.Parameter] | None]: + """Return (signature, parameter map) for a callable, if inspectable.""" + try: + sig = _strip_receiver_from_signature(inspect.signature(callable_obj)) + return sig, {p.name: p for p in sig.parameters.values()} + except (TypeError, ValueError): + return None, None + + +def _bind_runtime_step_kwargs( + *, + step: MethodStep, + method_callable: Any, + kwargs_per_step: dict[str, dict[str, Any]], + target: str, + trace_hook: Any | None, +) -> tuple[Any, dict[str, Any]]: + sig = step["signature"] + step_kwargs = kwargs_per_step.get(step["name"], {}) + if "__deferred_items__" not in step_kwargs: + return sig, step_kwargs + + deferred_items = step_kwargs.get("__deferred_items__", []) + runtime_sig, runtime_param_map = _signature_and_param_map(method_callable) + runtime_step: MethodStep = { + "name": step["name"], + "callable": method_callable, + "signature": runtime_sig, + "params": runtime_param_map, + "class": step.get("class"), + } + step_kwargs = _build_step_kwargs(runtime_step, deferred_items, target) + if trace_hook is not None: + trace_hook( + "deferred_runtime_bind", + { + "step": step["name"], + "signature": str(runtime_sig) if runtime_sig is not None else "(...)", + "params": sorted(list(runtime_param_map.keys())) + if isinstance(runtime_param_map, dict) + else [], + }, + ) + return runtime_sig, step_kwargs + + +def _execute_method_step_segment( + *, + current_obj: Any | None, + seg: str, + step: MethodStep, + kwargs_per_step: dict[str, dict[str, Any]], + target: str, + owner_target: str | None, + trace_hook: Any | None, +) -> Any: + if current_obj is None: + owner_label = owner_target or _tr("the owning object") + raise typer.BadParameter( + _tr( + "Cannot invoke method '{segment}' for target '{target}' because " + "'{owner}' is unavailable in the current session (it resolved to None). " + "This target only works when that object exists.", + segment=seg, + target=target, + owner=owner_label, + ) + ) + + try: + _, method_callable = resolve_callable_attr_case_insensitive(current_obj, seg) + except AttributeError: + raise typer.BadParameter( + _tr( + "Resolved object has no callable attribute '{segment}' when executing target '{target}'", + segment=seg, + target=target, + ) + ) + + sig, step_kwargs = _bind_runtime_step_kwargs( + step=step, + method_callable=method_callable, + kwargs_per_step=kwargs_per_step, + target=target, + trace_hook=trace_hook, + ) + if trace_hook is not None: + trace_hook("invoke_step", {"step": step["name"], "kwargs": step_kwargs}) + try: + return method_callable(**step_kwargs) + except TypeError as exc: + raise typer.BadParameter( + _tr( + "Argument error calling {target}{signature}: {error}", + target=target, + signature=sig, + error=exc, + ) + ) from exc + + +def _resolve_runtime_segment( + *, + seg: str, + current_obj: Any | None, + cli_to_class: dict[str, type], + target: str, +) -> Any: + from .context import get_synergy + + seg_lower = seg.lower() + if seg_lower in cli_to_class: + if seg_lower == CLI_ROOT_SYNERGY: + return get_synergy() + if current_obj is None: + return getattr(get_synergy(), seg) + return getattr(current_obj, seg) + + if current_obj is None: + raise typer.BadParameter( + _tr( + "Cannot resolve attribute '{segment}' without an object instance when resolving target '{target}'", + segment=seg, + target=target, + ) + ) + try: + return getattr(current_obj, seg) + except AttributeError as exc: + raise typer.BadParameter( + _tr( + "Cannot resolve attribute '{segment}' on '{class_name}' when executing target '{target}': {error}", + segment=seg, + class_name=type(current_obj).__name__, + target=target, + error=exc, + ) + ) from exc + + +def _execute_invoke_chain( + parts: list[str], + cli_to_class: dict[str, type], + method_steps: list[MethodStep], + kwargs_per_step: dict[str, dict[str, Any]], + target: str, + trace_hook: Any | None = None, +) -> Any: + current_obj: Any | None = None + method_index = 0 + resolved_parts: list[str] = [] + for seg in parts: + if method_index < len(method_steps) and seg == method_steps[method_index]["name"]: + current_obj = _execute_method_step_segment( + current_obj=current_obj, + seg=seg, + step=method_steps[method_index], + kwargs_per_step=kwargs_per_step, + target=target, + owner_target=".".join(resolved_parts) if resolved_parts else None, + trace_hook=trace_hook, + ) + resolved_parts.append(seg) + method_index += 1 + continue + current_obj = _resolve_runtime_segment( + seg=seg, + current_obj=current_obj, + cli_to_class=cli_to_class, + target=target, + ) + resolved_parts.append(seg) + + return current_obj diff --git a/src/moldflow_cli/invoke_serialization.py b/src/moldflow_cli/invoke_serialization.py new file mode 100644 index 0000000..6e64784 --- /dev/null +++ b/src/moldflow_cli/invoke_serialization.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json as _json +from typing import Any + +from .invoke_output import _build_invoke_envelope as _build_invoke_envelope_base + + +def _serialize_entlist_like(obj: Any) -> Any | None: + if not (hasattr(obj, "convert_to_string") and hasattr(obj, "size")): + return None + try: + return {"type": obj.__class__.__name__, "size": getattr(obj, "size", None), "string": obj.convert_to_string()} + except (AttributeError, TypeError, ValueError, IndexError): + return None + + +def _serialize_vector_like(obj: Any) -> Any | None: + if not all(hasattr(obj, attr) for attr in ("x", "y", "z")): + return None + try: + x_attr = getattr(obj, "x") + y_attr = getattr(obj, "y") + z_attr = getattr(obj, "z") + if not callable(x_attr) and not callable(y_attr) and not callable(z_attr): + return {"type": obj.__class__.__name__, "x": x_attr, "y": y_attr, "z": z_attr} + except (AttributeError, TypeError, ValueError, IndexError): + return None + return None + + +def _serialize_array_like(obj: Any) -> Any | None: + if not (hasattr(obj, "to_list") and hasattr(obj, "size")): + return None + try: + values = obj.to_list() # type: ignore[attr-defined] + return {"type": obj.__class__.__name__, "size": getattr(obj, "size", None), "values": values} + except (AttributeError, TypeError, ValueError, IndexError): + return None + + +def _serialize_vector_array_like(obj: Any) -> Any | None: + if not (hasattr(obj, "size") and all(hasattr(obj, m) for m in ("x", "y", "z"))): + return None + try: + coords = [] + for i in range(getattr(obj, "size")): # type: ignore[attr-defined] + coords.append({"x": obj.x(i), "y": obj.y(i), "z": obj.z(i)}) # type: ignore[attr-defined] + return {"type": obj.__class__.__name__, "size": getattr(obj, "size", None), "values": coords} + except (AttributeError, TypeError, ValueError, IndexError): + return None + + +def _serialize_property_like(obj: Any) -> Any | None: + if not all(hasattr(obj, attr) for attr in ("id", "name", "type")): + return None + try: + return { + "type": obj.__class__.__name__, + "id": obj.id, # type: ignore[attr-defined] + "name": obj.name, # type: ignore[attr-defined] + "prop_type": obj.type, # type: ignore[attr-defined] + } + except (AttributeError, TypeError, ValueError): + return None + + +def _serialize_public_attrs(obj: Any, *, depth: int, seen: set[int]) -> Any | None: + obj_dict = getattr(obj, "__dict__", None) + if not isinstance(obj_dict, dict) or not obj_dict: + return None + public_attrs: dict[str, Any] = {} + for attr_name in sorted(obj_dict): + if attr_name.startswith("_"): + continue + attr_value = obj_dict[attr_name] + if callable(attr_value): + continue + public_attrs[attr_name] = _to_serializable(attr_value, _depth=depth + 1, _seen=seen) + if len(public_attrs) >= 25: + public_attrs["_truncated"] = True + break + if public_attrs: + return {"type": obj.__class__.__name__, "attributes": public_attrs} + return None + + +def _to_serializable( + obj: Any, + *, + _depth: int = 0, + _seen: set[int] | None = None, +) -> Any: + if _seen is None: + _seen = set() + if _depth > 6: + return {"type": obj.__class__.__name__, "max_depth_exceeded": True, "repr": repr(obj)} + if isinstance(obj, (str, int, float, bool)) or obj is None: + return obj + obj_id = id(obj) + if obj_id in _seen: + return {"type": obj.__class__.__name__, "circular_ref": True} + _seen.add(obj_id) + try: + if isinstance(obj, dict): + return { + str(k): _to_serializable(v, _depth=_depth + 1, _seen=_seen) for k, v in obj.items() + } + if isinstance(obj, (list, tuple)): + return [_to_serializable(v, _depth=_depth + 1, _seen=_seen) for v in obj] + if isinstance(obj, set): + values = [_to_serializable(v, _depth=_depth + 1, _seen=_seen) for v in obj] + return sorted(values, key=lambda value: _json.dumps(value, sort_keys=True, default=str)) + for serializer in ( + _serialize_entlist_like, + _serialize_vector_like, + _serialize_array_like, + _serialize_vector_array_like, + _serialize_property_like, + ): + serialized = serializer(obj) + if serialized is not None: + return serialized + public_attrs = _serialize_public_attrs(obj, depth=_depth, seen=_seen) + if public_attrs is not None: + return public_attrs + return {"type": obj.__class__.__name__, "unserializable_repr": repr(obj)} + finally: + _seen.discard(obj_id) + + +def _apply_schema_version(payload: Any, *, schema_version: str) -> Any: + if isinstance(payload, dict): + with_schema = dict(payload) + with_schema.setdefault("schema_version", schema_version) + return with_schema + return payload + + +def _serialize_trace_payload(payload: Any, *, schema_version: str) -> Any: + return _apply_schema_version(_to_serializable(payload), schema_version=schema_version) + + +def _build_invoke_envelope(result: Any, *, schema_version: str) -> dict[str, Any]: + return _build_invoke_envelope_base( + result, + schema_version=schema_version, + serialize_result=lambda payload: _serialize_trace_payload(payload, schema_version=schema_version), + ) diff --git a/src/moldflow_cli/invoke_templates.py b/src/moldflow_cli/invoke_templates.py new file mode 100644 index 0000000..88ca218 --- /dev/null +++ b/src/moldflow_cli/invoke_templates.py @@ -0,0 +1,366 @@ +from __future__ import annotations + +import inspect +from typing import Any + +from moldflow.i18n import get_text + +from .constants import CLI_FIELD_VALUE, CLI_KIND_PROPERTY, CLI_MODE_PROPERTY_ASSIGNMENT +from .introspection import get_docstring, split_structured_doc +from .type_annotations import ( + extract_non_none_type_names, + format_annotation_text, + format_signature_for_display, +) +from .wrapper_registry import build_wrapper_input_hints, resolve_first_wrapper_name + + +_T = get_text() +MethodStep = dict[str, Any] + + +def _property_value_signature(raw_attr: property) -> tuple[str | None, inspect.Parameter | None]: + if raw_attr.fset is None: + return None, None + try: + setter_sig = inspect.signature(raw_attr.fset) + except (TypeError, ValueError): + return "(value)", None + params = list(setter_sig.parameters.values()) + value_param = params[1] if len(params) >= 2 else None + if value_param is None: + return "(value)", None + return str(inspect.Signature(parameters=[value_param])), value_param + + +def _property_return_wrapper_class( + raw_attr: property, + cli_to_class: dict[str, type], + *, + resolve_return_class: Any, +) -> type | None: + """Resolve a property getter return annotation to a public wrapper class when possible.""" + if raw_attr.fget is None: + return None + try: + getter_sig = inspect.signature(raw_attr.fget) + except (TypeError, ValueError): + return None + return resolve_return_class(getter_sig.return_annotation, cli_to_class) + + +def _build_property_target_template( + *, + target: str, + property_name: str, + raw_attr: property, + cli_to_class: dict[str, type], + resolve_return_class: Any, + annotation_allows_none: Any, + build_workflow_examples: Any, + schema_version: str, +) -> dict[str, Any]: + signature_value, value_param = _property_value_signature(raw_attr) + settable = raw_attr.fset is not None + readable = raw_attr.fget is not None + return_wrapper_class = _property_return_wrapper_class( + raw_attr, + cli_to_class, + resolve_return_class=resolve_return_class, + ) + property_doc = get_docstring(raw_attr) + property_summary, property_details = split_structured_doc(property_doc, obj_type="property") if property_doc else (None, None) + steps: list[dict[str, Any]] = [] + params_json_template: dict[str, Any] = {} + mode = "property_read" + if return_wrapper_class is not None: + mode = "wrapper_property" + if settable: + mode = CLI_MODE_PROPERTY_ASSIGNMENT + annotation = value_param.annotation if value_param is not None else inspect._empty + nullable = annotation_allows_none(annotation) + input_hints = _build_param_template_hints( + target=target, + step_name=property_name, + param_name=CLI_FIELD_VALUE, + annotation=annotation, + nullable=nullable, + ) + step_payload = { + "name": property_name, + "kind": CLI_MODE_PROPERTY_ASSIGNMENT, + "signature": signature_value, + "params": [ + { + "name": CLI_FIELD_VALUE, + "kind": value_param.kind.name if value_param is not None else "POSITIONAL_OR_KEYWORD", + "annotation": _annotation_text_for_template(annotation), + "required": True, + "nullable": nullable, + "default": None, + "input_hints": input_hints, + } + ], + } + if property_summary is not None: + step_payload["summary"] = property_summary + if property_details is not None: + step_payload["details"] = property_details + steps.append(step_payload) + params_json_template = {CLI_FIELD_VALUE: None} + workflow_examples = build_workflow_examples(target=target, steps=steps, mode=mode) + if return_wrapper_class is not None: + workflow_examples = { + "mode": mode, + "notes": [ + _T( + "This property returns a {class_name} wrapper. Continue with describe {target}. or invoke {target}.." + ).format(class_name=return_wrapper_class.__name__, target=target) + ], + } + if readable and settable: + workflow_examples["read_command"] = f"invoke {target}" + workflow_examples["read_args"] = [] + if not settable and return_wrapper_class is None: + workflow_examples = { + "mode": mode, + "cli_command": f"invoke {target}", + "cli_args": [], + } + payload = { + "schema_version": schema_version, + "target": target, + "mode": mode, + "terminal_target": { + "kind": CLI_KIND_PROPERTY, + "property": property_name, + "readable": readable, + "settable": settable, + }, + "steps": steps, + "params_json_template": params_json_template, + "workflow_examples": workflow_examples, + } + if property_summary is not None: + payload["summary"] = property_summary + if property_details is not None: + payload["details"] = property_details + if return_wrapper_class is not None: + payload["terminal_target"]["wrapper_class"] = return_wrapper_class.__name__ + return payload + + +def _apply_step_doc_fields(step_payload: dict[str, Any], *, callable_obj: Any, obj_type: str | None = None) -> None: + """Attach structured summary/details fields to a step payload when docs exist.""" + doc = get_docstring(callable_obj) + if not doc: + return + summary_value, details_value = split_structured_doc(doc, obj_type=obj_type) + if summary_value is not None: + step_payload["summary"] = summary_value + if details_value is not None: + step_payload["details"] = details_value + + +def _target_doc_fields_from_obj(obj: Any, *, obj_type: str | None = None) -> tuple[str | None, str | None]: + """Return top-level structured summary/details for an invoke target object.""" + doc = get_docstring(obj) + if not doc: + return None, None + return split_structured_doc(doc, obj_type=obj_type) + + +def _annotation_text_for_template(annotation: Any) -> str | None: + return format_annotation_text(annotation) + + +def _format_step_signature(signature: Any) -> str: + """Render stable step signatures for templates and dry-run summaries.""" + if isinstance(signature, inspect.Signature): + return format_signature_for_display(signature) or "()" + if signature is None: + return "(...)" + return str(signature).rsplit(" -> ", 1)[0] + + +def _annotation_type_names_for_template(annotation: Any) -> list[str]: + """Return non-None type names used by a parameter annotation.""" + return extract_non_none_type_names(annotation) + + +def _replace_template_placeholders(value: Any, *, param_name: str) -> Any: + """Recursively replace template placeholders in hint/example payloads.""" + if isinstance(value, str): + return value.replace("", param_name) + if isinstance(value, list): + return [_replace_template_placeholders(item, param_name=param_name) for item in value] + if isinstance(value, dict): + return { + ( + key.replace("", param_name) + if isinstance(key, str) + else key + ): _replace_template_placeholders(item, param_name=param_name) + for key, item in value.items() + } + return value + + +def _build_param_template_hints( + *, + target: str, + step_name: str, + param_name: str, + annotation: Any, + nullable: bool, +) -> dict[str, Any] | None: + """Build actionable invoke-template hints for complex/object parameters.""" + type_names = _annotation_type_names_for_template(annotation) + if not type_names: + return None + + wrapper_type = resolve_first_wrapper_name(type_names) + if wrapper_type is None: + return None + + hints: dict[str, Any] = { + "wrapper_type": wrapper_type, + "recommended": _T( + "Prefer chaining invoke targets so this parameter is produced by a previous step, " + "instead of constructing it manually in JSON." + ), + "advanced_fallbacks": { + "tagged_json": { + "shape": {"__type__": wrapper_type, "": ""}, + "note": _T( + "Advanced fallback only. Use this tagged shape when annotation context is unavailable, " + "when a nested payload is truly generic, or when multiple wrapper families would be ambiguous." + ), + }, + }, + } + adapter_hints = build_wrapper_input_hints(wrapper_type) + if adapter_hints is not None: + typed_json_shape = adapter_hints.get("typed_json_shape") + if isinstance(typed_json_shape, dict): + hints["advanced_fallbacks"]["tagged_json"]["shape"] = typed_json_shape + friendly_json_input = adapter_hints.get("friendly_json_input") + if isinstance(friendly_json_input, dict): + hints["friendly_json_input"] = friendly_json_input + non_json_input = adapter_hints.get("non_json_input") + if isinstance(non_json_input, dict): + hints["non_json_input"] = _replace_template_placeholders( + non_json_input, + param_name=param_name, + ) + examples = adapter_hints.get("examples") + if isinstance(examples, dict): + hints["examples"] = _replace_template_placeholders(examples, param_name=param_name) + if nullable: + hints["nullable"] = _T("This parameter can be null to indicate no value.") + + if wrapper_type.lower() == "plot": + hints["chain_example"] = { + "target": "synergy.plot_manager.find_plot_by_name.{step}".format(step=step_name), + "args": [ + "find_plot_by_name.plot_name=", + ], + "maps_to": _T("{param} receives the Plot returned by find_plot_by_name").format( + param=param_name + ), + } + + return hints + + +def _build_invoke_template( + target: str, + method_steps: list[MethodStep], + *, + annotation_allows_none: Any, + build_workflow_examples: Any, + schema_version: str, +) -> dict[str, Any]: + steps: list[dict[str, Any]] = [] + template_payload: dict[str, Any] = {} + for step in method_steps: + param_map = step.get("params") + step_name = step["name"] + step_template: dict[str, Any] = {} + params: list[dict[str, Any]] = [] + if isinstance(param_map, dict): + for param_name, param in param_map.items(): + nullable = annotation_allows_none(param.annotation) + required = ( + param.default is inspect._empty + and param.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) + and not nullable + ) + default_value: Any = None + if param.default is not inspect._empty: + if isinstance(param.default, (str, int, float, bool)) or param.default is None: + default_value = param.default + else: + default_value = repr(param.default) + params.append( + { + "name": param_name, + "kind": param.kind.name, + "annotation": _annotation_text_for_template(param.annotation), + "required": required, + "nullable": nullable, + "default": default_value, + "input_hints": _build_param_template_hints( + target=target, + step_name=step_name, + param_name=param_name, + annotation=param.annotation, + nullable=nullable, + ), + } + ) + if required: + step_template[param_name] = None + elif param.default is not inspect._empty: + step_template[param_name] = default_value + elif nullable: + step_template[param_name] = None + steps.append( + step_payload := { + "name": step_name, + "signature": _format_step_signature(step.get("signature")), + "params": params, + } + ) + callable_obj = step.get("callable") + if callable_obj is not None: + _apply_step_doc_fields(step_payload, callable_obj=callable_obj) + template_payload[step_name] = step_template + + if len(method_steps) == 1 and method_steps: + template_payload = template_payload[method_steps[0]["name"]] + + workflow_examples = build_workflow_examples( + target=target, + steps=steps, + mode="invoke", + ) + final_callable = next( + (step.get("callable") for step in reversed(method_steps) if step.get("callable") is not None), + None, + ) + summary_value, details_value = ( + _target_doc_fields_from_obj(final_callable) if final_callable is not None else (None, None) + ) + payload = { + "schema_version": schema_version, + "target": target, + "steps": steps, + "params_json_template": template_payload, + "workflow_examples": workflow_examples, + } + if summary_value is not None: + payload["summary"] = summary_value + if details_value is not None: + payload["details"] = details_value + return payload diff --git a/src/moldflow_cli/invoke_terminal.py b/src/moldflow_cli/invoke_terminal.py new file mode 100644 index 0000000..cb2447e --- /dev/null +++ b/src/moldflow_cli/invoke_terminal.py @@ -0,0 +1,344 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any, Callable, Optional +import inspect + +import typer + +from .constants import CLI_FIELD_VALUE, CLI_KIND_PROPERTY, CLI_KIND_PROPERTY_GETTER, CLI_MODE_PROPERTY_ASSIGNMENT +from .invoke_engine import ( + _PlannedPropertyAssignment, + _ResolvedInvokeTarget, + _UNHANDLED_INVOKE_RESULT, +) +from .invoke_trace import _trace_result_payload +from .target_resolution import ResolvedInvokeReadTarget +from moldflow.i18n import get_text + + +_T = get_text() + + +def _tr(message: str, **kwargs: Any) -> str: + text = _T(message) + return text.format(**kwargs) if kwargs else text + + +def _resolve_property_assignment_owner( + *, + invoke_target: _ResolvedInvokeTarget, + call_target: str, + property_name: str, + trace_hook: Any | None, + execute_invoke_chain: Callable[..., Any], +) -> tuple[Any, property]: + owner_parts = invoke_target.parts[:-1] + owner_target = ".".join(owner_parts) if owner_parts else invoke_target.parts[0] + owner_obj = execute_invoke_chain( + owner_parts, + invoke_target.cli_to_class, + [], + {}, + owner_target, + trace_hook=trace_hook, + ) + if owner_obj is None: + raise typer.BadParameter( + _tr( + "Cannot assign property '{property_name}' while resolving target '{target}' " + "because the owner object resolved to None.", + property_name=property_name, + target=call_target, + ) + ) + + raw_attr = inspect.getattr_static(type(owner_obj), property_name, None) + if not isinstance(raw_attr, property) or raw_attr.fset is None: + raise typer.BadParameter( + _tr( + "Target '{target}' resolves to property '{property_name}' " + "(getter) and does not accept arguments.", + target=call_target, + property_name=property_name, + ) + ) + return owner_obj, raw_attr + + +def _plan_property_assignment( + *, + call_target: str, + invoke_target: _ResolvedInvokeTarget, + property_name: str, + raw_args: list[str], + json_input: Optional[str], + json_file_input: Optional[str], + parse_terminal_property_assignment_value: Callable[[list[str], Optional[str], Optional[str]], Any], + execute_invoke_chain: Callable[..., Any], +) -> _PlannedPropertyAssignment: + _resolve_property_assignment_owner( + invoke_target=invoke_target, + call_target=call_target, + property_name=property_name, + trace_hook=None, + execute_invoke_chain=execute_invoke_chain, + ) + return _PlannedPropertyAssignment( + target=call_target, + invoke_target=invoke_target, + property_name=property_name, + assignment_value=parse_terminal_property_assignment_value( + raw_args, + json_input, + json_file_input, + ), + ) + + +def _property_assignment_plan_payload( + planned_assignment: _PlannedPropertyAssignment, + *, + build_invoke_template_for_target: Callable[[str], dict[str, Any]], + to_serializable: Callable[[Any], Any], + schema_version: str, +) -> dict[str, Any]: + template_payload = build_invoke_template_for_target(planned_assignment.target) + return { + "schema_version": schema_version, + "mode": "dry_run", + "target": planned_assignment.target, + "terminal_target": { + "kind": CLI_KIND_PROPERTY, + "property": planned_assignment.property_name, + "assignment": True, + }, + "assignment": {CLI_FIELD_VALUE: to_serializable(planned_assignment.assignment_value)}, + "workflow_examples": template_payload.get("workflow_examples", {}), + "params_json_template": template_payload.get("params_json_template", {}), + } + + +def _execute_planned_property_assignment( + planned_assignment: _PlannedPropertyAssignment, + *, + trace_hook: Any | None, + execute_invoke_chain: Callable[..., Any], +) -> Any: + owner_obj, _ = _resolve_property_assignment_owner( + invoke_target=planned_assignment.invoke_target, + call_target=planned_assignment.target, + property_name=planned_assignment.property_name, + trace_hook=trace_hook, + execute_invoke_chain=execute_invoke_chain, + ) + + if trace_hook is not None: + trace_hook( + "property_assign", + { + "target": planned_assignment.target, + "property": planned_assignment.property_name, + CLI_FIELD_VALUE: planned_assignment.assignment_value, + }, + ) + try: + setattr(owner_obj, planned_assignment.property_name, planned_assignment.assignment_value) + except (AttributeError, TypeError, ValueError) as exc: + raise typer.BadParameter( + _tr( + "Cannot set property '{property_name}' on target '{target}': {error}", + property_name=planned_assignment.property_name, + target=planned_assignment.target, + error=exc, + ) + ) from exc + + try: + return getattr(owner_obj, planned_assignment.property_name) + except (AttributeError, TypeError, ValueError): + return None + + +def _assign_terminal_property_target( + *, + parts: list[str], + cli_to_class: dict[str, type], + call_target: str, + property_name: str, + raw_args: list[str], + json_input: Optional[str], + json_file_input: Optional[str], + trace_hook: Any | None, + parse_terminal_property_assignment_value: Callable[[list[str], Optional[str], Optional[str]], Any], + execute_invoke_chain: Callable[..., Any], +) -> Any: + planned_assignment = _plan_property_assignment( + call_target=call_target, + invoke_target=_ResolvedInvokeTarget( + canonical_target=call_target, + parts=parts, + cli_to_class=cli_to_class, + method_steps=[], + terminal_target_info={"kind": CLI_KIND_PROPERTY_GETTER, "name": property_name}, + ), + property_name=property_name, + raw_args=raw_args, + json_input=json_input, + json_file_input=json_file_input, + parse_terminal_property_assignment_value=parse_terminal_property_assignment_value, + execute_invoke_chain=execute_invoke_chain, + ) + return _execute_planned_property_assignment( + planned_assignment, + trace_hook=trace_hook, + execute_invoke_chain=execute_invoke_chain, + ) + + +def _build_property_assignment_plan( + *, + call_target: str, + property_name: str, + raw_args: list[str], + json_input: Optional[str], + json_file_input: Optional[str], + parse_terminal_property_assignment_value: Callable[[list[str], Optional[str], Optional[str]], Any], + build_invoke_template_for_target: Callable[[str], dict[str, Any]], + to_serializable: Callable[[Any], Any], + schema_version: str, + execute_invoke_chain: Callable[..., Any], +) -> dict[str, Any]: + planned_assignment = _plan_property_assignment( + call_target=call_target, + invoke_target=_ResolvedInvokeTarget( + canonical_target=call_target, + parts=call_target.split("."), + cli_to_class={}, + method_steps=[], + terminal_target_info={"kind": CLI_KIND_PROPERTY_GETTER, "name": property_name}, + ), + property_name=property_name, + raw_args=raw_args, + json_input=json_input, + json_file_input=json_file_input, + parse_terminal_property_assignment_value=parse_terminal_property_assignment_value, + execute_invoke_chain=execute_invoke_chain, + ) + return _property_assignment_plan_payload( + planned_assignment, + build_invoke_template_for_target=build_invoke_template_for_target, + to_serializable=to_serializable, + schema_version=schema_version, + ) + + +def _dispatch_terminal_invoke_target( + *, + call_target: str, + invoke_target: _ResolvedInvokeTarget, + resolved_target: ResolvedInvokeReadTarget, + call_args: list[str], + call_json_input: Optional[str], + call_json_file_input: Optional[str], + dry_run: bool, + trace_hook: Any | None, + terminal_property_wrapper_class: Callable[[ResolvedInvokeReadTarget], type | None], + parse_terminal_property_assignment_value: Callable[[list[str], Optional[str], Optional[str]], Any], + build_invoke_template_for_target: Callable[[str], dict[str, Any]], + to_serializable: Callable[[Any], Any], + schema_version: str, + execute_invoke_chain: Callable[..., Any], + build_invoke_envelope: Callable[[Any], dict[str, Any]], +) -> Any: + _ = get_text() + if invoke_target.method_steps: + return _UNHANDLED_INVOKE_RESULT + + wrapper_property_class = terminal_property_wrapper_class(resolved_target) + if wrapper_property_class is not None: + raise typer.BadParameter( + _( + "Target '{target}' resolves to a {class_name} wrapper property. Continue to one of its members, for example 'describe {target}.'." + ).format(target=call_target, class_name=wrapper_property_class.__name__) + ) + + if not (call_args or call_json_input is not None or call_json_file_input is not None): + return _UNHANDLED_INVOKE_RESULT + + if ( + invoke_target.terminal_target_info is not None + and invoke_target.terminal_target_info.get("kind") == CLI_KIND_PROPERTY_GETTER + ): + if dry_run: + property_name = invoke_target.terminal_target_info["name"] + raw_attr = resolved_target.resolved_object + if not isinstance(raw_attr, property) or raw_attr.fset is None: + raise typer.BadParameter( + _tr( + "Target '{target}' resolves to property '{property_name}' " + "(getter) and does not accept arguments.", + target=call_target, + property_name=property_name, + ) + ) + planned_assignment = _PlannedPropertyAssignment( + target=call_target, + invoke_target=invoke_target, + property_name=property_name, + assignment_value=parse_terminal_property_assignment_value( + call_args, + call_json_input, + call_json_file_input, + ), + ) + plan = _property_assignment_plan_payload( + planned_assignment, + build_invoke_template_for_target=build_invoke_template_for_target, + to_serializable=to_serializable, + schema_version=schema_version, + ) + if trace_hook is not None: + trace_hook("plan", plan) + trace_hook( + "result", + _trace_result_payload( + plan, + mode="dry_run", + build_invoke_envelope=build_invoke_envelope, + ), + ) + return plan + planned_assignment = _plan_property_assignment( + call_target=call_target, + invoke_target=invoke_target, + property_name=invoke_target.terminal_target_info["name"], + raw_args=call_args, + json_input=call_json_input, + json_file_input=call_json_file_input, + parse_terminal_property_assignment_value=parse_terminal_property_assignment_value, + execute_invoke_chain=execute_invoke_chain, + ) + property_result = _execute_planned_property_assignment( + planned_assignment, + trace_hook=trace_hook, + execute_invoke_chain=execute_invoke_chain, + ) + if trace_hook is not None: + trace_hook( + "result", + _trace_result_payload( + property_result, + mode=CLI_MODE_PROPERTY_ASSIGNMENT, + build_invoke_envelope=build_invoke_envelope, + ), + ) + return property_result + + raise typer.BadParameter( + _( + "Target '{target}' resolves to a property/attribute and does not accept arguments." + ).format(target=call_target) + ) diff --git a/src/moldflow_cli/invoke_trace.py b/src/moldflow_cli/invoke_trace.py new file mode 100644 index 0000000..24106a9 --- /dev/null +++ b/src/moldflow_cli/invoke_trace.py @@ -0,0 +1,111 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any, Callable + +import typer + +from .output_utils import to_json_text + + +def _trace_result_payload( + result: Any, + *, + mode: str = "invoke", + build_invoke_envelope: Callable[[Any], dict[str, Any]], +) -> dict[str, Any]: + envelope = build_invoke_envelope(result) + return { + "mode": mode, + "ok": envelope["ok"], + "result_type": envelope["result_type"], + "result": envelope["result"], + "diagnostics": envelope.get("diagnostics", []), + } + + +def _trace_error_payload(exc: Exception, *, error_type: str) -> dict[str, Any]: + return { + "error_type": error_type, + "message": str(exc), + "exception_type": type(exc).__name__, + } + + +def _emit_invoke_trace_event( + *, + trace_enabled: bool, + trace_state: dict[str, int], + call_target: str | None, + label: str, + payload: Any, + serialize_payload: Callable[[Any], Any], + schema_version: str, + batch_index: int | None = None, +) -> None: + if not trace_enabled: + return + trace_state["sequence"] += 1 + trace_payload: dict[str, Any] = { + "schema_version": schema_version, + "sequence": trace_state["sequence"], + "event": label, + "target": call_target or "", + "payload": serialize_payload(payload), + } + if batch_index is not None: + trace_payload["batch_index"] = batch_index + if isinstance(payload, dict): + step_name = payload.get("step") + property_name = payload.get("property") + if isinstance(step_name, str) and step_name: + trace_payload["step"] = step_name + if isinstance(property_name, str) and property_name: + trace_payload["property"] = property_name + typer.echo(to_json_text(trace_payload, context="invoke-trace", default=str), err=True) + + +def _make_invoke_trace_hook( + *, + trace_enabled: bool, + trace_state: dict[str, int], + call_target: str, + emit_invoke_trace_event: Callable[..., None], + batch_index: int | None = None, +) -> Callable[[str, Any], None] | None: + if not trace_enabled: + return None + + def _emit_trace(label: str, payload: Any) -> None: + emit_invoke_trace_event( + trace_enabled=trace_enabled, + trace_state=trace_state, + call_target=call_target, + label=label, + payload=payload, + batch_index=batch_index, + ) + + return _emit_trace + + +def _emit_batch_invoke_trace( + batch_index: int, + call_target: str | None, + label: str, + payload: dict[str, Any], + *, + emit_invoke_trace_event: Callable[..., None], + trace_enabled: bool, + trace_state: dict[str, int], +) -> None: + emit_invoke_trace_event( + trace_enabled=trace_enabled, + trace_state=trace_state, + call_target=call_target, + label=label, + payload=payload, + batch_index=batch_index, + ) diff --git a/src/moldflow_cli/listing.py b/src/moldflow_cli/listing.py new file mode 100644 index 0000000..d05881c --- /dev/null +++ b/src/moldflow_cli/listing.py @@ -0,0 +1,260 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import inspect +import re +from typing import Any, Sequence + +from moldflow.i18n import get_text + +from .constants import CLI_KIND_PROPERTY, CLI_KIND_SETTABLE_PROPERTY, CLI_ROOT_SYNERGY +from .factories import camel_to_snake +from .introspection import is_cli_target_hidden, iter_public_classes, resolve_for_introspection +from .invoke_resolution import _resolve_return_class +from .target_resolution import iter_static_members, public_static_attr_lookup +from .wrapper_registry import public_wrapper_classes_by_name + + +_T = get_text() +_MAX_FILTERED_MATCH_SUMMARY_ROWS = 10 + + +def _matches_list_filter(filter_text: str | None, *candidates: str) -> bool: + """Return True when a list row matches the user's filter text.""" + if not filter_text: + return True + needle = filter_text.lower() + lower_candidates = [candidate.lower() for candidate in candidates] + if not any(token in needle for token in "*?"): + return any(needle in candidate for candidate in lower_candidates) + pattern = re.escape(needle).replace(r"\*", ".*").replace(r"\?", ".") + return any(re.search(pattern, candidate) is not None for candidate in lower_candidates) + + +def _matches_any_list_filter(filters: Sequence[str] | None, *candidates: str) -> bool: + """Return True when a row matches at least one provided filter value.""" + normalized_filters = [filter_text for filter_text in (filters or []) if filter_text] + if not normalized_filters: + return True + return any(_matches_list_filter(filter_text, *candidates) for filter_text in normalized_filters) + + +def _resolve_list_target(cli_name: str, attr_name: str) -> tuple[str, bool]: + """Return a best-effort CLI target and whether it is Synergy-rooted.""" + base_target = f"{cli_name}.{attr_name}" + if cli_name == CLI_ROOT_SYNERGY: + return base_target, True + + import moldflow + + synergy_cls = getattr(moldflow, "Synergy", None) + if not isinstance(synergy_cls, type): + return base_target, False + + synergy_attrs = public_static_attr_lookup(synergy_cls) + direct_owner = synergy_attrs.get(cli_name.lower()) + if isinstance(direct_owner, str): + return f"{CLI_ROOT_SYNERGY}.{direct_owner}.{attr_name}", True + + factory_name = synergy_attrs.get(f"create_{cli_name}".lower()) + if isinstance(factory_name, str): + return f"{CLI_ROOT_SYNERGY}.{factory_name}.{attr_name}", True + + return base_target, False + + +def _target_returns_wrapper_property(target: str) -> bool: + """Return True when a target resolves to a wrapper-handle property, not a scalar end value.""" + try: + obj = resolve_for_introspection(target) + except (AttributeError, ValueError, TypeError): + return False + if not isinstance(obj, property) or obj.fget is None: + return False + try: + getter_sig = inspect.signature(obj.fget) + except (TypeError, ValueError): + return False + cli_to_class = public_wrapper_classes_by_name() + return _resolve_return_class(getter_sig.return_annotation, cli_to_class) is not None + + +def _list_commands_for_target(*, target: str, kind: str, rooted: bool) -> tuple[dict[str, str], str]: + """Return relevant follow-up commands and the best suggested starting command.""" + commands = {"describe": f"describe {target}"} + if kind == CLI_KIND_PROPERTY: + if rooted and not _target_returns_wrapper_property(target): + commands["invoke"] = f"invoke {target}" + return commands, commands["describe"] + if kind == CLI_KIND_SETTABLE_PROPERTY: + if rooted: + commands["invoke"] = f"invoke {target}" + return commands, commands["describe"] + if rooted: + commands["invoke"] = f"invoke {target}" + return commands, commands["describe"] + + +def _is_directly_invokable_list_target(commands: dict[str, str]) -> bool: + """Return True when list metadata exposes an actual invoke path for the target.""" + invoke_command = commands.get("invoke") + return isinstance(invoke_command, str) and bool(invoke_command) + + +def _prefer_method_rows_over_settable_properties(cls: type) -> bool: + """Return True for builder-style wrappers where settable knobs would drown out the action methods.""" + method_count = 0 + settable_property_count = 0 + for attr_name, raw_attr in iter_static_members(cls): + if attr_name.startswith("_"): + continue + if isinstance(raw_attr, property): + if raw_attr.fget is None or raw_attr.fset is None: + continue + settable_property_count += 1 + continue + attr = raw_attr.__func__ if isinstance(raw_attr, (staticmethod, classmethod)) else raw_attr + if callable(attr): + method_count += 1 + return method_count > 0 and method_count <= 2 and settable_property_count >= 3 + + +def _hide_config_only_property_surface(cls: type) -> bool: + """Return True for rooted option-bag wrappers that expose only settable properties.""" + method_count = 0 + settable_property_count = 0 + for attr_name, raw_attr in iter_static_members(cls): + if attr_name.startswith("_"): + continue + if isinstance(raw_attr, property): + if raw_attr.fget is None or raw_attr.fset is None: + continue + settable_property_count += 1 + continue + attr = raw_attr.__func__ if isinstance(raw_attr, (staticmethod, classmethod)) else raw_attr + if callable(attr): + method_count += 1 + return method_count == 0 and settable_property_count >= 3 + + +def _try_add_property_row( + rows: list[dict[str, Any]], + *, + target: str, + name: str, + kind: str, + rooted: bool, + suppress_settable: bool, + hide_config_only: bool, +) -> None: + """Append a property row if it passes filters and is invokable.""" + if rooted and hide_config_only: + return + if kind == CLI_KIND_SETTABLE_PROPERTY and rooted and suppress_settable: + return + commands, suggested_command = _list_commands_for_target( + target=target, kind=kind, rooted=rooted + ) + if not _is_directly_invokable_list_target(commands): + return + rows.append({ + "target": target, + "owner_class": name, + "kind": kind, + "suggested_command": suggested_command, + "commands": commands, + }) + + +def _try_add_method_row( + rows: list[dict[str, Any]], + *, + target: str, + name: str, + rooted: bool, +) -> None: + """Append a method row if it passes filters and is invokable.""" + commands, suggested_command = _list_commands_for_target( + target=target, kind="method", rooted=rooted + ) + if not _is_directly_invokable_list_target(commands): + return + rows.append({ + "target": target, + "owner_class": name, + "kind": "method", + "suggested_command": suggested_command, + "commands": commands, + }) + + +def collect_list_rows(filters: Sequence[str] | None) -> list[dict[str, Any]]: + """Collect invokable method/property rows for list output.""" + import enum + + rows: list[dict[str, Any]] = [] + for name, cls in sorted(iter_public_classes(), key=lambda t: t[0].lower()): + if isinstance(cls, type) and issubclass(cls, enum.Enum): + continue + cli_name = camel_to_snake(name) + suppress_settable = _prefer_method_rows_over_settable_properties(cls) + hide_config_only = _hide_config_only_property_surface(cls) + for attr_name, raw_attr in iter_static_members(cls): + if attr_name.startswith("_"): + continue + target, rooted = _resolve_list_target(cli_name, attr_name) + if is_cli_target_hidden(target): + continue + if isinstance(raw_attr, property): + if raw_attr.fget is None or not _matches_any_list_filter(filters, target, name): + continue + kind = CLI_KIND_SETTABLE_PROPERTY if raw_attr.fset is not None else CLI_KIND_PROPERTY + _try_add_property_row( + rows, + target=target, + name=name, + kind=kind, + rooted=rooted, + suppress_settable=suppress_settable, + hide_config_only=hide_config_only, + ) + continue + attr = raw_attr.__func__ if isinstance(raw_attr, (staticmethod, classmethod)) else raw_attr + if not callable(attr) or not _matches_any_list_filter(filters, target, name): + continue + _try_add_method_row(rows, target=target, name=name, rooted=rooted) + return rows + + +def has_list_filters(filters: Sequence[str] | None) -> bool: + """Return True when the list command was given one or more non-empty filters.""" + return any(filter_text for filter_text in (filters or [])) + + +def human_list_kind(row: dict[str, Any]) -> str: + """Return a compact type label for human list tables.""" + if row.get("kind") == CLI_KIND_SETTABLE_PROPERTY: + return _T("settable") + return row.get("kind", "") + + +def human_list_target(row: dict[str, Any]) -> str: + """Return a compact display target for human list tables.""" + target = row.get("target", "") + if target.lower().startswith(f"{CLI_ROOT_SYNERGY}."): + return target[len(f"{CLI_ROOT_SYNERGY}.") :] + return target + + +def render_filtered_list_matches(console: Any, rows: list[dict[str, Any]]) -> None: + """Show full target strings when a human filtered list would otherwise crop them.""" + if not rows or len(rows) > _MAX_FILTERED_MATCH_SUMMARY_ROWS: + return + console.print(_T("Filtered matches:"), markup=False) + for row in rows: + console.print( + f"- {human_list_target(row)}", + markup=False, + ) diff --git a/src/moldflow_cli/output_utils.py b/src/moldflow_cli/output_utils.py new file mode 100644 index 0000000..7b94b72 --- /dev/null +++ b/src/moldflow_cli/output_utils.py @@ -0,0 +1,230 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from contextlib import nullcontext +from typing import Any +import json as _json +import os +import sys +import textwrap +import typer +from moldflow.i18n import get_text + + +_T = get_text() + + +_console_singleton = None +_console_no_color = False + + +def should_use_ascii_output(*, stderr: bool = False) -> bool: + """Return True when output should avoid Unicode box-drawing characters.""" + stream = sys.stderr if stderr else sys.stdout + isatty = getattr(stream, "isatty", None) + try: + is_terminal = bool(isatty()) if callable(isatty) else False + except (OSError, ValueError): + is_terminal = False + if not is_terminal: + return True + return os.environ.get("TERM", "").lower() == "dumb" + + +def configure_console(*, no_color: bool) -> None: + """Configure the shared Rich console used by CLI commands.""" + global _console_no_color, _console_singleton + if _console_singleton is not None and _console_no_color != no_color: + _console_singleton = None + _console_no_color = no_color + + +def get_console(): + """Return a shared rich Console instance.""" + global _console_singleton + if _console_singleton is None: + from rich.console import Console + + _console_singleton = Console(no_color=_console_no_color) + return _console_singleton + + +def human_output_pager(console: Any): + """Return a pager context for human terminal output when stdout is interactive.""" + if not bool(getattr(console, "is_terminal", False)): + return nullcontext() + if bool(getattr(console, "is_dumb_terminal", False)): + return nullcontext() + try: + return console.pager(styles=False) + except (AttributeError, OSError, RuntimeError): + return nullcontext() + + +def human_table_kwargs(console: Any) -> dict[str, Any]: + """Return table kwargs that stay readable in the active human-output environment.""" + from rich import box + + if should_use_ascii_output() or bool(getattr(console, "is_dumb_terminal", False)): + return {"box": box.ASCII, "safe_box": True} + if sys.platform != "win32": + return {} + if not bool(getattr(console, "is_terminal", False)): + return {} + if bool(getattr(console, "is_dumb_terminal", False)): + return {"box": box.ASCII, "safe_box": True} + + # Rich's Windows pager path can backslash-escape box-drawing characters when the + # console code page cannot encode them, so prefer ASCII table borders there. + return {"box": box.ASCII, "safe_box": True} + + +def to_json_text(payload: Any, *, context: str, default: Any | None = None) -> str: + """Serialize payload to JSON with consistent error handling for CLI UX.""" + try: + if default is None: + return _json.dumps(payload, indent=2, ensure_ascii=False) + return _json.dumps(payload, indent=2, default=default, ensure_ascii=False) + except (TypeError, ValueError) as exc: + raise typer.BadParameter( + _T("Failed to render JSON output for {context}: {error}").format( + context=context, error=exc + ) + ) from exc + + +def emit_yaml_text(payload: Any, *, context: str) -> str: + """Serialize payload to YAML with consistent error handling.""" + try: + import yaml # type: ignore + except ModuleNotFoundError as exc: # pragma: no cover - dependent on PyYAML availability + raise typer.BadParameter( + _T("--yaml requested but PyYAML is not installed: {error}").format(error=exc) + ) from exc + try: + return yaml.safe_dump(payload, sort_keys=False) + except Exception as exc: # pragma: no cover - serializer-specific failure + raise typer.BadParameter( + _T("Failed to render YAML output for {context}: {error}").format( + context=context, error=exc + ) + ) from exc + + +def print_wrapped_command(console: Any, command: str, *, indent: str = " ") -> None: + """Render a CLI command with a hanging indent for easier terminal scanning.""" + width = getattr(console, "width", None) + if not isinstance(width, int): + width = 80 + width = max(width, len(indent) + 20) + console.print( + textwrap.fill( + command, + width=width, + initial_indent=indent, + subsequent_indent=indent, + ), + markup=False, + ) + + +def _vector_text(value: dict[str, Any]) -> str | None: + if not all(axis in value for axis in ("x", "y", "z")): + return None + return f"({value['x']}, {value['y']}, {value['z']})" + + +def _render_list_text(console: Any, values: list[Any], *, context: str, heading: str) -> None: + console.print(heading, markup=False) + if not values: + console.print("[]", markup=False) + return + if all(isinstance(item, (str, int, float, bool)) or item is None for item in values): + for index, item in enumerate(values, start=1): + console.print(f"{index}. {item}", markup=False) + return + console.print(to_json_text(values, context=context), markup=False) + + +def render_serialized_value(console: Any, value: Any, *, context: str) -> None: + """Render a serialized invoke payload in a concise human-readable form.""" + if isinstance(value, str): + console.print(value, markup=False) + return + if isinstance(value, (int, float, bool)) or value is None: + console.print(value) + return + if isinstance(value, list): + _render_list_text(console, value, context=context, heading=_T("List result:")) + return + if not isinstance(value, dict): + console.print(repr(value), markup=False) + return + + if isinstance(value.get("string"), str) and "size" in value: + console.print( + _T("{type_name} ({size} items): {value}").format( + type_name=value.get("type", _T("Selection")), + size=value.get("size", "?"), + value=value["string"], + ), + markup=False, + ) + return + + vector_text = _vector_text(value) + if vector_text is not None: + console.print( + "{type_name}: {value}".format( + type_name=value.get("type", _T("Vector")), + value=vector_text, + ), + markup=False, + ) + return + + array_values = value.get("values") + if isinstance(array_values, list): + heading = _T("{type_name} values ({count} items):").format( + type_name=value.get("type", _T("Array")), + count=len(array_values), + ) + if all(isinstance(item, dict) for item in array_values): + vector_items = [_vector_text(item) for item in array_values] + if all(isinstance(item, str) for item in vector_items): + console.print(heading, markup=False) + for index, item in enumerate(vector_items, start=1): + console.print(f"{index}. {item}", markup=False) + return + _render_list_text(console, array_values, context=context, heading=heading) + return + + if all(field in value for field in ("id", "name", "prop_type")): + console.print( + _T("Property {name} (id={id}, type={prop_type})").format( + name=value["name"], + id=value["id"], + prop_type=value["prop_type"], + ), + markup=False, + ) + return + + attributes = value.get("attributes") + if isinstance(attributes, dict): + console.print( + _T("{type_name} attributes:").format( + type_name=value.get("type", _T("Object")), + ), + markup=False, + ) + console.print(to_json_text(attributes, context=context), markup=False) + return + + type_name = value.get("type") + if isinstance(type_name, str) and type_name: + console.print(_T("{type_name} result:").format(type_name=type_name), markup=False) + console.print(to_json_text(value, context=context), markup=False) + diff --git a/src/moldflow_cli/presentation.py b/src/moldflow_cli/presentation.py new file mode 100644 index 0000000..85fd0bb --- /dev/null +++ b/src/moldflow_cli/presentation.py @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any + +from moldflow.i18n import get_text + +from .output_utils import print_wrapped_command, to_json_text + + +_T = get_text() + + +def human_signature_text(signature: str | None) -> str: + """Remove return annotations from human-facing signature strings.""" + if not isinstance(signature, str) or not signature: + return "" + return signature.rsplit(" -> ", 1)[0] + + +def render_workflow_examples( + console: Any, + workflow_examples: dict[str, Any], + *, + params_json_context: str, + minimal_params_json_context: str, +) -> None: + """Render preferred and minimal workflow examples for human CLI output.""" + read_command = workflow_examples.get("read_command") or workflow_examples.get("read_cli_command") + primary_command = workflow_examples.get("cli_command") or workflow_examples.get("preferred_cli_command") + minimal_command = workflow_examples.get("minimal_command") or workflow_examples.get("minimal_cli_command") + if isinstance(read_command, str) and read_command and read_command != primary_command: + console.print(_T("Read current value:"), markup=False) + print_wrapped_command(console, read_command) + if isinstance(primary_command, str) and primary_command: + primary_label = _T("Set it with:") if read_command else _T("Try this:") + console.print(primary_label, markup=False) + print_wrapped_command(console, primary_command) + if isinstance(minimal_command, str) and minimal_command and minimal_command != primary_command: + minimal_label = _T("Shorter form:") + console.print(minimal_label, markup=False) + print_wrapped_command(console, minimal_command) + primary_params_json = workflow_examples.get("params_json") or workflow_examples.get("preferred_params_json") + if isinstance(primary_params_json, dict) and primary_params_json: + console.print(_T("JSON example:"), markup=False) + console.print(to_json_text(primary_params_json, context=params_json_context), markup=False) + minimal_params_json = workflow_examples.get("minimal_params_json") + if ( + isinstance(minimal_params_json, dict) + and minimal_params_json + and minimal_params_json != primary_params_json + ): + console.print(_T("Shorter JSON example:"), markup=False) + console.print( + to_json_text(minimal_params_json, context=minimal_params_json_context), + markup=False, + ) + for note in workflow_examples.get("notes", []): + if isinstance(note, str): + console.print(note, markup=False) + + +def render_input_hints_summary(console: Any, payload: dict[str, Any]) -> None: + """Render wrapper-specific input hints for human describe and template flows.""" + steps = payload.get("steps") + if not isinstance(steps, list) or not steps: + return + multi_step = len(steps) > 1 + rendered_header = False + for step in steps: + if not isinstance(step, dict): + continue + step_name = step.get("name") + params = step.get("params") + if not isinstance(params, list): + continue + for param in params: + if not isinstance(param, dict): + continue + param_name = param.get("name") + input_hints = param.get("input_hints") + if not isinstance(param_name, str) or not isinstance(input_hints, dict): + continue + wrapper_type = input_hints.get("wrapper_type") + examples = input_hints.get("examples") if isinstance(input_hints.get("examples"), dict) else {} + preferred_non_json = examples.get("preferred_non_json") + explicit_field_non_json = examples.get("explicit_field_non_json") + preferred_params_json = examples.get("preferred_params_json") + if not any( + isinstance(value, (str, dict)) and bool(value) + for value in (preferred_non_json, explicit_field_non_json, preferred_params_json) + ): + continue + if not rendered_header: + console.print(_T("Input hints:"), markup=False) + rendered_header = True + label = param_name + if multi_step and isinstance(step_name, str) and step_name: + label = f"{step_name}.{param_name}" + if isinstance(wrapper_type, str) and wrapper_type: + console.print( + "{label} ({wrapper_type}):".format( + label=label, + wrapper_type=wrapper_type, + ), + markup=False, + ) + else: + console.print("{label}:".format(label=label), markup=False) + if isinstance(preferred_params_json, dict) and preferred_params_json: + console.print(_T("JSON value:"), markup=False) + console.print( + to_json_text(preferred_params_json, context="input-hint"), + markup=False, + ) + if isinstance(preferred_non_json, str) and preferred_non_json: + console.print(_T("CLI argument: {value}").format(value=preferred_non_json), markup=False) + if ( + isinstance(explicit_field_non_json, str) + and explicit_field_non_json + and explicit_field_non_json != preferred_non_json + ): + console.print( + _T("Explicit field form: {value}").format(value=explicit_field_non_json), + markup=False, + ) diff --git a/src/moldflow_cli/repl.py b/src/moldflow_cli/repl.py new file mode 100644 index 0000000..11eb1ee --- /dev/null +++ b/src/moldflow_cli/repl.py @@ -0,0 +1,369 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging +import shlex + +import click +import typer +from moldflow.i18n import get_text + +from .output_utils import get_console + + +_T = get_text() + +_BUILTIN_COMMANDS = ("help", "?", "exit", "quit", "clear", "reset") + +_BUILTIN_HELP_ENTRIES: list[dict[str, object]] = [ + { + "names": ["help", "?"], + "summary": _T("Shows available commands and usage information."), + "detail": _T("Use 'help ' for detailed help on a specific command."), + }, + { + "names": ["exit", "quit"], + "summary": _T("Exits the REPL."), + "detail": _T("Ctrl+D also exits."), + }, + { + "names": ["clear"], + "summary": _T("Clears the terminal screen and redraws the banner."), + }, + { + "names": ["reset"], + "summary": _T("Closes Synergy and resets the session for a fresh start."), + "detail": _T("Tab completion targets are refreshed automatically."), + }, +] + +_BUILTIN_NAMES = frozenset( + name for entry in _BUILTIN_HELP_ENTRIES for name in entry["names"] +) + + +def _get_version() -> str: + try: + from importlib.metadata import PackageNotFoundError, version + + return version("moldflow") + except PackageNotFoundError: + return "unknown" + + +def _get_cli_command_names(app) -> list[str]: + """Derive registered command names from the Typer app's Click metadata.""" + from typer.main import get_command + + click_app = get_command(app) + return sorted(name for name in click_app.commands if name != "repl") + + +def _get_cli_help_entries(app) -> list[tuple[str, str]]: + """Get (name, help_text) pairs for help display, derived from the app.""" + from typer.main import get_command + + click_app = get_command(app) + entries = [] + for name in sorted(click_app.commands): + if name == "repl": + continue + cmd = click_app.commands[name] + help_text = (cmd.help or cmd.short_help or "").split("\n")[0] + entries.append((name, help_text)) + return entries + + +def _print_banner(console) -> None: + from rich.panel import Panel + + hint = _T("Type 'help' for available commands, 'exit' to quit.") + console.print( + Panel( + f"[bold]moldflow[/bold] {_T('interactive shell')} [dim]v{_get_version()}[/dim]\n" + f"[dim]{hint}[/dim]", + border_style="blue", + ) + ) + + +def _print_help(console, cli_entries: list[tuple[str, str]]) -> None: + console.print(f"\n[bold]{_T('Commands:')}[/bold]") + for name, help_text in cli_entries: + console.print(f" [green]{name:<20s}[/green] {help_text}") + console.print() + console.print(f"[bold]{_T('Session:')}[/bold]") + session_items = [ + ("reset", _T("Close Synergy and reset the session")), + ("clear", _T("Clear the screen")), + ("help", _T("Show this help message")), + ("help ", _T("Show detailed help for a command")), + ("exit / quit", _T("Exit the REPL")), + ] + for label, desc in session_items: + console.print(f" [green]{label:<20s}[/green] {desc}") + console.print() + + +def _print_builtin_help(console, subcmd: str) -> None: + for entry in _BUILTIN_HELP_ENTRIES: + if subcmd in entry["names"]: + aliases = " / ".join(f"[green]{n}[/green]" for n in entry["names"]) + console.print(f"\n {aliases}") + console.print(f" {entry['summary']}") + detail = entry.get("detail") + if detail: + console.print(f" [dim]{detail}[/dim]") + console.print() + return + + +def _import_readline(console=None): + """Try to import a readline-compatible module. + + Returns the module, or ``None`` when no implementation is available. + """ + try: + import readline + + return readline + except ImportError: + pass + try: + import pyreadline3 as readline # Windows fallback + + return readline + except ImportError: + if console is not None: + console.print( + "[dim]" + + _T("Tip: install pyreadline3 for tab completion support on Windows.") + + "[/dim]" + ) + return None + + +def _bind_tab_complete(readline) -> None: + """Bind the tab key using the syntax appropriate for the readline backend.""" + # macOS ships libedit instead of GNU readline; it needs different syntax. + if getattr(readline, "__doc__", "") and "libedit" in (readline.__doc__ or ""): + readline.parse_and_bind("bind ^I rl_complete") + else: + readline.parse_and_bind("tab: complete") + + +def _build_completer(readline, all_commands: list[str], load_targets): + """Return a readline-compatible completer function.""" + + def completer(text: str, state: int) -> str | None: + try: + buffer = readline.get_line_buffer().lstrip() + has_context = True + except AttributeError: + # Without the full line buffer we cannot distinguish first-word + # from second-word completion, so only command names are offered. + buffer = text + has_context = False + parts = buffer.split() + + if len(parts) <= 1 and not buffer.endswith(" "): + options = [c + " " for c in all_commands if c.startswith(text)] + elif has_context and parts[0] in ("describe", "invoke") and not text.startswith("-"): + targets = load_targets() + options = [t + " " for t in targets if t.startswith(text)] + else: + options = [] + + return options[state] if state < len(options) else None + + return completer + + +def _setup_completion(all_commands: list[str], console=None): + """Best-effort readline setup for tab completion and history. + + Returns a callable that invalidates the cached target list, or a + no-op lambda when readline is unavailable. + """ + readline = _import_readline(console) + if readline is None: + return lambda: None + + _targets: list[str] | None = None + + def invalidate_targets() -> None: + nonlocal _targets + _targets = None + + def _load_targets() -> list[str]: + nonlocal _targets + if _targets is None: + try: + from .commands import collect_list_rows + + _targets = [r["target"] for r in collect_list_rows(None)] + except (ImportError, AttributeError, KeyError, TypeError) as exc: + logging.getLogger(__name__).debug( + "Failed to load completion targets: %s", exc + ) + _targets = [] + return _targets + + readline.set_completer(_build_completer(readline, all_commands, _load_targets)) + readline.set_completer_delims(" \t\n") + _bind_tab_complete(readline) + return invalidate_targets + + +def _normalise_args(args: list[str]) -> list[str] | None: + """Strip a leading ``moldflow`` prefix and reject ``repl`` re-entry. + + Returns the cleaned arg list, an empty list when the command should be + silently skipped, or ``None`` when the REPL re-entry message should be + shown. + """ + if not args: + return [] + if args[0].lower() == "moldflow": + args = args[1:] + if not args: + return [] + if args[0].lower() == "repl": + return None + return args + + +def _run_app_safely(app, console, args: list[str], *, debug: bool) -> None: + """Invoke the Typer/Click app and translate exceptions to user messages.""" + try: + app(args, standalone_mode=False) + except SystemExit as exc: + # 0/None = success; 1 = app-level failure (already reported); + # 2 = Click usage error (already printed). Only surface unexpected codes. + if exc.code not in (0, None, 1, 2): + console.print( + f"[red]{_T('Command exited with status {code}').format(code=exc.code)}[/red]" + ) + except KeyboardInterrupt: + console.print(f"\n[dim]{_T('Interrupted.')}[/dim]") + except click.exceptions.Abort: + console.print(f"\n[dim]{_T('Aborted.')}[/dim]") + except click.exceptions.ClickException as exc: + exc.show() + except Exception as exc: + if debug: + console.print_exception() + else: + console.print(f"[red]{_T('Error:')}[/red] {exc}") + + +def _dispatch(app, console, args: list[str], *, debug: bool = False) -> None: + """Dispatch pre-parsed args to the Typer/Click app.""" + args = _normalise_args(args) + if args is None: + console.print(f"[dim]{_T('You are already in the REPL.')}[/dim]") + return + if not args: + return + _run_app_safely(app, console, args, debug=debug) + + +def _read_line(console) -> str | None: + """Read one line of input. Returns ``None`` on EOF (exit).""" + try: + return input("moldflow> ") + except EOFError: + console.print(f"\n{_T('Goodbye!')}") + return None + except KeyboardInterrupt: + console.print() + return "" + + +def _handle_help(console, parts, cli_command_names, cli_help_entries, app, *, debug): + """Process ``help`` / ``?`` commands.""" + if len(parts) <= 1: + _print_help(console, cli_help_entries) + return + subcmd = parts[1].lower() + if subcmd in _BUILTIN_NAMES: + _print_builtin_help(console, subcmd) + elif subcmd in cli_command_names: + _dispatch(app, console, [parts[1], "--help"], debug=debug) + else: + console.print(f"[yellow]{_T('Unknown command:')}[/yellow] {parts[1]}") + console.print(f"[dim]{_T('Type help to see available commands.')}[/dim]") + + +def _handle_reset(console, reset_synergy, invalidate_completion_cache, *, debug): + """Process the ``reset`` command.""" + try: + reset_synergy() + invalidate_completion_cache() + console.print(f"[dim]{_T('Synergy session reset.')}[/dim]") + except Exception as exc: + if debug: + console.print_exception() + else: + console.print(f"[red]{_T('Failed to reset session:')}[/red] {exc}") + + +def _parse_input(console, line: str) -> list[str] | None: + """Strip and tokenise a raw input line. Returns ``None`` on parse error.""" + line = line.strip() + if not line: + return [] + try: + return shlex.split(line) + except ValueError as exc: + console.print(f"[red]{_T('Parse error:')}[/red] {exc}") + return None + + +def repl_cmd( + debug: bool = typer.Option( + False, + "--debug", + help=_T("Show full tracebacks on errors instead of short messages."), + ), +) -> None: + """Start an interactive moldflow shell session.""" + from .commands import build_cli_app + from .context import reset_synergy + + console = get_console() + app = build_cli_app() + + cli_command_names = _get_cli_command_names(app) + cli_help_entries = _get_cli_help_entries(app) + + all_commands = sorted(set(list(_BUILTIN_COMMANDS) + cli_command_names)) + invalidate_completion_cache = _setup_completion(all_commands, console) + _print_banner(console) + + while True: + line = _read_line(console) + if line is None: + break + + parts = _parse_input(console, line) + if parts is None or not parts: + continue + + first = parts[0].lower() + + if first in ("exit", "quit"): + console.print(_T("Goodbye!")) + break + + if first in ("help", "?"): + _handle_help(console, parts, cli_command_names, cli_help_entries, app, debug=debug) + elif first == "clear": + console.clear() + _print_banner(console) + elif first == "reset": + _handle_reset(console, reset_synergy, invalidate_completion_cache, debug=debug) + else: + _dispatch(app, console, parts, debug=debug) diff --git a/src/moldflow_cli/target_resolution.py b/src/moldflow_cli/target_resolution.py new file mode 100644 index 0000000..b289c40 --- /dev/null +++ b/src/moldflow_cli/target_resolution.py @@ -0,0 +1,240 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from dataclasses import dataclass +import inspect +from typing import Any, Callable, Sequence + +import typer + +from .constants import CLI_ROOT_MOLDFLOW, CLI_ROOT_SYNERGY + +@dataclass(frozen=True) +class ResolvedReadTarget: + requested_target: str + canonical_target: str + resolved_object: Any + + +@dataclass(frozen=True) +class ResolvedInvokeReadTarget: + requested_target: str + canonical_target: str + parts: list[str] + cli_to_class: dict[str, type] + method_steps: list[dict[str, Any]] + terminal_target_info: dict[str, str] | None + resolved_object: Any | None + + +def strip_optional_moldflow_prefix(parts: Sequence[str]) -> tuple[list[str], bool]: + """Strip a leading moldflow prefix and report whether it was present.""" + normalized_parts = list(parts) + had_moldflow_prefix = False + if normalized_parts and normalized_parts[0].lower() == CLI_ROOT_MOLDFLOW: + had_moldflow_prefix = True + normalized_parts = normalized_parts[1:] + return normalized_parts, had_moldflow_prefix + + +def validate_public_target_segments( + parts: Sequence[str], + *, + target: str, + translate: Callable[[str], str], +) -> None: + """Reject hidden/private target segments consistently across CLI entrypoints.""" + for segment in parts: + if segment.startswith("_"): + raise typer.BadParameter( + translate("Non-public segment '{segment}' is not allowed in target '{target}'.").format( + segment=segment, + target=target, + ) + ) + + +def split_dotted_path( + path_text: str, + *, + field_name: str, + translate: Callable[[str], str], +) -> list[str]: + """Split dotted identifiers while rejecting empty or invalid segments.""" + if not path_text: + raise typer.BadParameter( + translate("Invalid {field_name}: value cannot be empty.").format( + field_name=field_name + ) + ) + path_text = path_text.strip() + segments = [seg.strip() for seg in path_text.split(".")] + if any(seg == "" for seg in segments): + raise typer.BadParameter( + translate("Invalid {field_name} '{path_text}': empty path segment is not allowed.").format( + field_name=field_name, + path_text=path_text, + ) + ) + for segment in segments: + if not segment.isidentifier(): + raise typer.BadParameter( + translate( + "Invalid {field_name} '{path_text}': segment '{segment}' must be a valid identifier." + ).format( + field_name=field_name, + path_text=path_text, + segment=segment, + ) + ) + return segments + + +def public_static_attr_lookup(cls: type) -> dict[str, str]: + """Build a case-insensitive lookup of a class's public static attributes.""" + lookup: dict[str, str] = {} + for attr_name, _ in iter_static_members(cls): + if attr_name.startswith("_"): + continue + lookup.setdefault(attr_name.lower(), attr_name) + return lookup + + +def iter_static_members(obj: Any) -> list[tuple[str, Any]]: + """Return static members without invoking descriptors, including on Python 3.10.""" + try: + return list(inspect.getmembers_static(obj)) + except AttributeError: + pass + + members: list[tuple[str, Any]] = [] + for name in dir(obj): + try: + members.append((name, inspect.getattr_static(obj, name))) + except AttributeError: + continue + return members + + +def canonicalize_describe_target_parts( + target_parts: Sequence[str], + *, + target: str, + translate: Callable[[str], str], +) -> str: + """Canonicalize describe targets while preserving bare public class targets.""" + parts, _had_moldflow_prefix = strip_optional_moldflow_prefix(target_parts) + if not parts: + return "" + validate_public_target_segments(parts, target=target, translate=translate) + if parts[0].lower() == CLI_ROOT_SYNERGY: + return ".".join(parts) + + import moldflow + + synergy_cls = getattr(moldflow, "Synergy", None) + if not isinstance(synergy_cls, type): + return ".".join(parts) + synergy_attrs = public_static_attr_lookup(synergy_cls) + direct_attr = synergy_attrs.get(parts[0].lower()) + if isinstance(direct_attr, str): + return ".".join(["synergy", direct_attr, *parts[1:]]) + return ".".join(parts) + + +def canonicalize_invoke_target( + target: str, + *, + translate: Callable[[str], str], +) -> str: + """Canonicalize invoke targets, auto-prefixing bare targets to synergy.*.""" + parts = split_dotted_path(target, field_name="target", translate=translate) + parts, had_moldflow_prefix = strip_optional_moldflow_prefix(parts) + if not parts: + raise typer.BadParameter(translate("Target must include at least one segment")) + if parts[0].lower() != CLI_ROOT_SYNERGY: + if had_moldflow_prefix: + raise typer.BadParameter( + translate( + "Target must start with 'synergy' after the optional 'moldflow.' prefix. " + "Bare targets such as 'open_project' are accepted and are interpreted as " + "'synergy.open_project'." + ) + ) + parts = [CLI_ROOT_SYNERGY, *parts] + canonical_target = ".".join(parts) + validate_public_target_segments(parts, target=canonical_target, translate=translate) + return canonical_target + + +def resolve_attr_name_case_insensitive( + current: Any, + attr_name: str, + *, + static_lookup: bool = False, +) -> str | None: + """Resolve an attribute name exactly or case-insensitively.""" + if static_lookup: + try: + inspect.getattr_static(current, attr_name) + return attr_name + except AttributeError: + pass + else: + if hasattr(current, attr_name): + return attr_name + attr_lower = attr_name.lower() + return next((name for name in dir(current) if name.lower() == attr_lower), None) + + +def resolve_callable_attr_case_insensitive(obj: Any, name: str) -> tuple[str, Any]: + """Resolve a callable attribute by exact or case-insensitive name.""" + attr = getattr(obj, name, None) + if callable(attr): + return name, attr + name_lower = name.lower() + for candidate in dir(obj): + if candidate.lower() != name_lower: + continue + resolved = getattr(obj, candidate, None) + if callable(resolved): + return candidate, resolved + raise AttributeError(name) + + +def resolve_target_for_introspection( + target: str, + *, + require_visible: bool = False, +) -> Any: + """Resolve a target for read-only CLI introspection with consistent CLI errors.""" + from .introspection import HiddenCliTargetError, resolve_for_introspection, validate_cli_target_visible + + try: + if require_visible: + validate_cli_target_visible(target) + return resolve_for_introspection(target) + except (AttributeError, HiddenCliTargetError, ValueError) as exc: + raise typer.BadParameter(str(exc)) from exc + + +def resolve_describe_read_target( + target: str, + *, + translate: Callable[[str], str], +) -> ResolvedReadTarget: + """Resolve a describe target into its canonical target and introspected object.""" + target_parts = split_dotted_path(target, field_name="target", translate=translate) + canonical_target = canonicalize_describe_target_parts( + target_parts, + target=target, + translate=translate, + ) + resolved_object = resolve_target_for_introspection(canonical_target, require_visible=True) + return ResolvedReadTarget( + requested_target=target, + canonical_target=canonical_target, + resolved_object=resolved_object, + ) diff --git a/src/moldflow_cli/type_annotations.py b/src/moldflow_cli/type_annotations.py new file mode 100644 index 0000000..ea1fa0a --- /dev/null +++ b/src/moldflow_cli/type_annotations.py @@ -0,0 +1,227 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from types import NoneType, UnionType +from typing import Annotated, Any, Union, get_args, get_origin +import inspect +import re + + +_NONE_TYPE_NAMES = {"None", "NoneType", "types.NoneType"} +_MOLDFLOW_QUALIFIED_TYPE = re.compile(r"\bmoldflow\.[A-Za-z_][\w]*\.") + + +def _is_annotated_origin(origin: Any) -> bool: + if origin is None: + return False + if origin is Annotated: + return True + origin_name = getattr(origin, "__name__", "") + if origin_name == "Annotated": + return True + return "Annotated" in str(origin) + + +def _is_union_origin(origin: Any) -> bool: + if origin is None: + return False + if origin is UnionType: + return True + return origin is Union + + +def _normalize_annotation_text(annotation_text: str) -> str: + text = annotation_text.strip().strip("'\"") + if not text: + return "" + return text.replace("typing.", "") + + +def format_annotation_text(annotation: Any) -> str | None: + """Return concise, user-facing annotation text for CLI signatures and payloads.""" + if annotation is inspect._empty: + return None + if isinstance(annotation, str): + text = annotation + elif isinstance(annotation, type): + return annotation.__name__ + else: + text = str(annotation) + text = _normalize_annotation_text(text) + if not text: + return None + return _MOLDFLOW_QUALIFIED_TYPE.sub("", text) + + +def format_signature_for_display( + signature: inspect.Signature | None, + *, + empty_as_none: bool = False, +) -> str | None: + """Render a receiver-free signature with concise annotation text and no return arrow.""" + if signature is None: + return None + params = list(signature.parameters.values()) + if params and params[0].name in {"self", "cls"}: + signature = signature.replace(parameters=params[1:]) + params = list(signature.parameters.values()) + if not params and empty_as_none: + return None + formatted_params: list[str] = [] + last_positional_only = max( + (index for index, param in enumerate(params) if param.kind is inspect.Parameter.POSITIONAL_ONLY), + default=-1, + ) + needs_keyword_only_separator = any( + param.kind is inspect.Parameter.KEYWORD_ONLY for param in params + ) and not any(param.kind is inspect.Parameter.VAR_POSITIONAL for param in params) + for index, param in enumerate(params): + prefix = "" + if param.kind is inspect.Parameter.VAR_POSITIONAL: + prefix = "*" + elif param.kind is inspect.Parameter.VAR_KEYWORD: + prefix = "**" + if param.kind is inspect.Parameter.KEYWORD_ONLY and needs_keyword_only_separator: + formatted_params.append("*") + needs_keyword_only_separator = False + formatted = f"{prefix}{param.name}" + annotation_text = format_annotation_text(param.annotation) + if annotation_text is not None: + formatted += f": {annotation_text}" + if param.default is not inspect._empty: + formatted += f" = {param.default!r}" + formatted_params.append(formatted) + if index == last_positional_only: + formatted_params.append("/") + return f"({', '.join(formatted_params)})" + + +def _leaf_type_name(text: str) -> str: + return text.strip().strip("'\"").rsplit(".", 1)[-1] + + +def _split_top_level(text: str, delimiter: str) -> list[str]: + parts: list[str] = [] + depth = 0 + start = 0 + for idx, char in enumerate(text): + if char == "[": + depth += 1 + elif char == "]": + depth = max(0, depth - 1) + elif char == delimiter and depth == 0: + parts.append(text[start:idx].strip()) + start = idx + 1 + parts.append(text[start:].strip()) + return [part for part in parts if part] + + +def _unwrap_generic(text: str, generic_name: str) -> str | None: + prefix = f"{generic_name}[" + if text.startswith(prefix) and text.endswith("]"): + return text[len(prefix) : -1] + return None + + +def _collect_non_none_type_names(parts: list[str]) -> list[str]: + results: list[str] = [] + for part in parts: + results.extend(extract_non_none_annotation_type_names(part)) + return results + + +def extract_non_none_annotation_type_names(annotation_text: str) -> list[str]: + """ + Extract candidate non-None type names from a string annotation. + + Supports common forward-ref forms such as: + - "str | None", "str|None" + - "Optional[str]", "typing.Optional[str]" + - "Union[A, B, None]", "typing.Union[A, B]" + """ + text = _normalize_annotation_text(annotation_text) + if not text: + return [] + + optional_inner = _unwrap_generic(text, "Optional") + if optional_inner is not None: + return extract_non_none_annotation_type_names(optional_inner) + + union_inner = _unwrap_generic(text, "Union") + if union_inner is not None: + return _collect_non_none_type_names(_split_top_level(union_inner, ",")) + + annotated_inner = _unwrap_generic(text, "Annotated") + if annotated_inner is not None: + parts = _split_top_level(annotated_inner, ",") + if not parts: + return [] + return extract_non_none_annotation_type_names(parts[0]) + + if "|" in text: + return _collect_non_none_type_names(_split_top_level(text, "|")) + + type_name = _leaf_type_name(text) + if type_name in _NONE_TYPE_NAMES: + return [] + return [type_name] + + +def extract_non_none_type_names(annotation: Any) -> list[str]: + """Extract top-level non-None type names from a runtime annotation object.""" + if annotation is inspect._empty: + return [] + if isinstance(annotation, str): + return extract_non_none_annotation_type_names(annotation) + if isinstance(annotation, type): + if annotation in {NoneType, type(None)}: + return [] + return [annotation.__name__] + + origin = get_origin(annotation) + if _is_annotated_origin(origin): + annotated_args = get_args(annotation) + if annotated_args: + return extract_non_none_type_names(annotated_args[0]) + return [] + if origin is not None and not _is_union_origin(origin): + return [] + + args = get_args(annotation) + if args: + names: list[str] = [] + for arg in args: + if arg in {NoneType, type(None)}: + continue + arg_origin = get_origin(arg) + if _is_annotated_origin(arg_origin) or _is_union_origin(arg_origin): + names.extend(extract_non_none_type_names(arg)) + continue + if arg_origin is not None: + continue + if isinstance(arg, type): + names.append(arg.__name__) + continue + forward_arg = getattr(arg, "__forward_arg__", None) + if isinstance(forward_arg, str): + names.extend(extract_non_none_annotation_type_names(forward_arg)) + continue + if isinstance(arg, str): + names.extend(extract_non_none_annotation_type_names(arg)) + return names + + forward_arg = getattr(annotation, "__forward_arg__", None) + if isinstance(forward_arg, str): + return extract_non_none_annotation_type_names(forward_arg) + + text = str(annotation).replace("typing.", "") + if not text: + return [] + return extract_non_none_annotation_type_names(text) + + +def annotation_text_allows_str(annotation_text: str) -> bool: + """Return True when a string annotation includes str (directly or in a union).""" + return any(name == "str" for name in extract_non_none_annotation_type_names(annotation_text)) diff --git a/src/moldflow_cli/wrapper_input_adapters.py b/src/moldflow_cli/wrapper_input_adapters.py new file mode 100644 index 0000000..077155b --- /dev/null +++ b/src/moldflow_cli/wrapper_input_adapters.py @@ -0,0 +1,775 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +import inspect +import json as _json +from typing import Any + +from moldflow.i18n import get_text +from moldflow.cli_input_metadata import ( + CLI_VALUE_KIND_LIST_VALUES, + CLI_VALUE_KIND_SELECTION_TEXT, + CLI_VALUE_KIND_VECTOR_ARRAY_VALUES, + CLI_VALUE_KIND_VECTOR_TRIPLET, +) + +from .constants import CLI_FIELD_VALUE +from .wrapper_registry import resolve_wrapper_class, resolve_wrapper_name + +_CLI_INPUT_ADAPTER_ATTR = "__moldflow_cli_input_adapter__" +_DIRECT_PARAM_SHORTHAND_KEY = CLI_FIELD_VALUE +_T = get_text() + + +def _tr(message: str, **kwargs: Any) -> str: + text = _T(message) + return text.format(**kwargs) if kwargs else text + + +@dataclass(frozen=True) +class _AdapterSpec: + name: str + method_name: str + preferred_field: str + value_kind: str + shorthand_supported: bool + + +def _callable_params_from_callable(method: Any) -> tuple[inspect.Parameter, ...] | None: + if not callable(method): + return None + try: + signature = inspect.signature(method) + except (TypeError, ValueError): + return None + return tuple(param for param in signature.parameters.values() if param.name != "self") + + +def _callable_params(target: Any, method_name: str) -> tuple[inspect.Parameter, ...] | None: + method = getattr(target, method_name, None) + return _callable_params_from_callable(method) + + +def _method_cli_input_metadata(method: Any) -> Any | None: + return getattr(method, _CLI_INPUT_ADAPTER_ATTR, None) + + +def _annotation_text(annotation: Any) -> str: + if annotation is inspect._empty: + return "" + if isinstance(annotation, str): + return annotation.replace("typing.", "") + if isinstance(annotation, type): + return annotation.__name__ + return str(annotation).replace("typing.", "") + + +def _annotation_is_str(annotation: Any) -> bool: + text = _annotation_text(annotation) + return text == "" or text == "str" + + +def _annotation_is_list_like(annotation: Any) -> bool: + text = _annotation_text(annotation).lower() + if text == "": + return True + return text.startswith("list[") or text.startswith("tuple[") or text in {"list", "tuple"} + + +def _preferred_field_for_metadata(metadata: Any, params: tuple[inspect.Parameter, ...]) -> str | None: + preferred_field = getattr(metadata, "preferred_field", None) + if isinstance(preferred_field, str) and preferred_field: + return preferred_field + if len(params) == 1: + return params[0].name + return None + + +def _is_valid_metadata_shape(value_kind: str, params: tuple[inspect.Parameter, ...]) -> bool: + if value_kind == CLI_VALUE_KIND_SELECTION_TEXT: + return len(params) == 1 and _annotation_is_str(params[0].annotation) + if value_kind == CLI_VALUE_KIND_LIST_VALUES: + return len(params) == 1 and _annotation_is_list_like(params[0].annotation) + if value_kind in {CLI_VALUE_KIND_VECTOR_TRIPLET, CLI_VALUE_KIND_VECTOR_ARRAY_VALUES}: + return [param.name for param in params] == ["x", "y", "z"] + return False + + +def _adapter_spec_from_method(method_name: str, method: Any) -> _AdapterSpec | None: + metadata = _method_cli_input_metadata(method) + if metadata is None: + return None + params = _callable_params_from_callable(method) + if params is None: + return None + value_kind = getattr(metadata, "value_kind", None) + if not isinstance(value_kind, str) or not _is_valid_metadata_shape(value_kind, params): + return None + preferred_field = _preferred_field_for_metadata(metadata, params) + if preferred_field is None: + return None + return _AdapterSpec( + name=value_kind, + method_name=method_name, + preferred_field=preferred_field, + value_kind=value_kind, + shorthand_supported=bool(getattr(metadata, "shorthand_supported", False)), + ) + + +@lru_cache(maxsize=None) +def _adapter_specs_for_class(wrapper_cls: type) -> tuple[_AdapterSpec, ...]: + specs: list[_AdapterSpec] = [] + seen_methods: set[str] = set() + for cls in wrapper_cls.__mro__: + for method_name, member in vars(cls).items(): + if method_name in seen_methods: + continue + seen_methods.add(method_name) + candidate = _adapter_spec_from_method(method_name, member) + if candidate is not None: + specs.append(candidate) + return tuple(specs) + + +def _adapter_specs_for_target(target: Any) -> tuple[_AdapterSpec, ...]: + wrapper_cls = target if inspect.isclass(target) else type(target) + return _adapter_specs_for_class(wrapper_cls) + + +def _adapter_spec_for_field(target: Any, key: str) -> _AdapterSpec | None: + for spec in _adapter_specs_for_target(target): + if spec.preferred_field == key: + return spec + return None + + +def _shorthand_adapter_spec(target: Any) -> _AdapterSpec | None: + candidates = [spec for spec in _adapter_specs_for_target(target) if spec.shorthand_supported] + if len(candidates) != 1: + return None + return candidates[0] + + +def _ensure_adapter_unused( + adapter_spec: _AdapterSpec, + key: str, + *, + applied_adapters: dict[str, str], + obj: Any, +) -> None: + previous_key = applied_adapters.get(adapter_spec.name) + if previous_key is not None: + raise ValueError( + _tr( + "Fields '{previous_key}' and '{key}' both map to the same input for '{type_name}'. Provide only one of: {preferred_field} or direct parameter shorthand.", + previous_key=previous_key, + key=key, + type_name=type(obj).__name__, + preferred_field=adapter_spec.preferred_field, + ) + ) + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _parse_shorthand_scalar(value: str) -> Any: + text = value.strip() + lower = text.lower() + if lower in {"true", "yes", "on"}: + return True + if lower in {"false", "no", "off"}: + return False + if lower in {"none", "null"}: + return None + try: + return int(text) + except ValueError: + pass + try: + return float(text) + except ValueError: + pass + return text + + +def _list_element_kind(annotation: Any) -> str | None: + args = getattr(annotation, "__args__", ()) + if args: + elem_type = args[0] + if elem_type is int: + return "int" + if elem_type is float: + return "float" + if elem_type is str: + return "str" + text = _annotation_text(annotation).replace(" ", "") + lower = text.lower() + for prefix in ("list[", "tuple["): + if lower.startswith(prefix) and lower.endswith("]"): + inner = lower[len(prefix) : -1].split(",", maxsplit=1)[0] + if inner in {"int", "float", "str"}: + return inner + return None + + +def _list_element_kind_for_spec(target: Any, spec: _AdapterSpec) -> str | None: + params = _callable_params(target, spec.method_name) + if params is None or not params: + return None + return _list_element_kind(params[0].annotation) + + +def _coerce_list_item(raw_value: str, *, element_kind: str | None, key: str, obj: Any) -> Any: + if element_kind == "str": + return raw_value + if element_kind == "int": + try: + return int(raw_value) + except ValueError as exc: + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must contain integer values.", + key=key, + type_name=type(obj).__name__, + ) + ) from exc + if element_kind == "float": + try: + return float(raw_value) + except ValueError as exc: + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must contain numeric values.", + key=key, + type_name=type(obj).__name__, + ) + ) from exc + return _parse_shorthand_scalar(raw_value) + + +def _second_list_example_item(example_item: Any) -> Any: + if isinstance(example_item, float): + return 2.5 + if isinstance(example_item, int): + return 2 + if isinstance(example_item, str): + return "beta" + return example_item + + +def _list_values_shorthand_example(target: Any, spec: _AdapterSpec) -> str: + example_item = _example_item_for_list_spec(target, spec) + second_item = _second_list_example_item(example_item) + return f"{example_item},{second_item}" + + +def _coerce_list_values(value: Any, *, spec: _AdapterSpec, key: str, obj: Any) -> list[Any]: + if isinstance(value, (list, tuple)): + return list(value) + if not isinstance(value, str): + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list.", + key=key, + type_name=type(obj).__name__, + ) + ) + text = value.strip() + if not text: + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list.", + key=key, + type_name=type(obj).__name__, + ) + ) + if text.startswith("["): + try: + parsed = _json.loads(text) + except _json.JSONDecodeError as exc: + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must be a valid JSON array or a comma-separated list.", + key=key, + type_name=type(obj).__name__, + ) + ) from exc + if not isinstance(parsed, list): + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list.", + key=key, + type_name=type(obj).__name__, + ) + ) + return parsed + parts = [part.strip() for part in text.split(",")] + if any(part == "" for part in parts): + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must be a JSON array or a comma-separated list.", + key=key, + type_name=type(obj).__name__, + ) + ) + element_kind = _list_element_kind_for_spec(obj, spec) + return [ + _coerce_list_item(part, element_kind=element_kind, key=key, obj=obj) + for part in parts + ] + + +def _coerce_triplet(value: Any, *, key: str, obj: Any) -> tuple[float, float, float]: + if isinstance(value, str): + parts = [part.strip() for part in value.split(",")] + if len(parts) != 3 or any(part == "" for part in parts): + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must be a comma-separated numeric triplet like '0,0,1'.", + key=key, + type_name=type(obj).__name__, + ) + ) + try: + numbers = [float(part) for part in parts] + except ValueError as exc: + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must be a comma-separated numeric triplet like '0,0,1'.", + key=key, + type_name=type(obj).__name__, + ) + ) from exc + return numbers[0], numbers[1], numbers[2] + + if isinstance(value, (list, tuple)) and len(value) == 3 and all(_is_number(v) for v in value): + return float(value[0]), float(value[1]), float(value[2]) + + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must be a 3-item numeric sequence like [0, 0, 1].", + key=key, + type_name=type(obj).__name__, + ) + ) + + +def _vector_array_triplets(value: Any, *, key: str, obj: Any) -> list[tuple[float, float, float]]: + if isinstance(value, str): + text = value.strip() + if not text: + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must be a JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'.", + key=key, + type_name=type(obj).__name__, + ) + ) + if text.startswith("["): + try: + parsed = _json.loads(text) + except _json.JSONDecodeError as exc: + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must be a valid JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'.", + key=key, + type_name=type(obj).__name__, + ) + ) from exc + value = parsed + else: + parts = [part.strip() for part in text.split(";")] + if any(part == "" for part in parts): + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must be a JSON array of triplets or a semicolon-separated list like '0,0,0;1,0,0'.", + key=key, + type_name=type(obj).__name__, + ) + ) + return [_coerce_triplet(part, key=f"{key}[{index}]", obj=obj) for index, part in enumerate(parts)] + if not isinstance(value, (list, tuple)): + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must be a list of numeric triplets.", + key=key, + type_name=type(obj).__name__, + ) + ) + return [_coerce_triplet(item, key=f"{key}[{index}]", obj=obj) for index, item in enumerate(value)] + + +def _example_item_for_list_spec(target: Any, spec: _AdapterSpec) -> Any: + params = _callable_params(target, spec.method_name) + if params is None or not params: + return "" + annotation = params[0].annotation + args = getattr(annotation, "__args__", ()) + if args: + elem_type = args[0] + if elem_type is int: + return 1 + if elem_type is float: + return 1.0 + if elem_type is str: + return "alpha" + return "" + + +def _call_adapter_method(obj: Any, spec: _AdapterSpec, *args: Any) -> Any: + method = getattr(obj, spec.method_name, None) + if not callable(method): + raise ValueError( + _tr( + "'{type_name}' no longer exposes adapter method '{method_name}'.", + type_name=type(obj).__name__, + method_name=spec.method_name, + ) + ) + return method(*args) + + +def _apply_selection_text_adapter( + spec: _AdapterSpec, + obj: Any, + key: str, + value: Any, + *, + applied_adapters: dict[str, str], +) -> bool: + _ensure_adapter_unused( + spec, + key, + applied_adapters=applied_adapters, + obj=obj, + ) + + if not isinstance(value, str): + raise ValueError( + _tr( + "Field '{key}' for '{type_name}' must be a string selection expression. Expected field: {preferred_field}.", + key=key, + type_name=type(obj).__name__, + preferred_field=spec.preferred_field, + ) + ) + + _call_adapter_method(obj, spec, value) + applied_adapters[spec.name] = key + return True + + +def _apply_vector_triplet_adapter( + spec: _AdapterSpec, + obj: Any, + key: str, + value: Any, + *, + applied_adapters: dict[str, str], +) -> bool: + _ensure_adapter_unused( + spec, + key, + applied_adapters=applied_adapters, + obj=obj, + ) + x, y, z = _coerce_triplet(value, key=key, obj=obj) + _call_adapter_method(obj, spec, x, y, z) + applied_adapters[spec.name] = key + return True + + +def _apply_list_values_adapter( + spec: _AdapterSpec, + obj: Any, + key: str, + value: Any, + *, + applied_adapters: dict[str, str], +) -> bool: + _ensure_adapter_unused( + spec, + key, + applied_adapters=applied_adapters, + obj=obj, + ) + value_list = _coerce_list_values(value, spec=spec, key=key, obj=obj) + _call_adapter_method(obj, spec, value_list) + applied_adapters[spec.name] = key + return True + + +def _apply_vector_array_values_adapter( + spec: _AdapterSpec, + obj: Any, + key: str, + value: Any, + *, + applied_adapters: dict[str, str], +) -> bool: + _ensure_adapter_unused( + spec, + key, + applied_adapters=applied_adapters, + obj=obj, + ) + triplets = _vector_array_triplets(value, key=key, obj=obj) + clear = getattr(obj, "clear", None) + if callable(clear): + clear() + for x, y, z in triplets: + _call_adapter_method(obj, spec, x, y, z) + applied_adapters[spec.name] = key + return True + + +def apply_wrapper_input_adapter( + obj: Any, + key: str, + value: Any, + *, + applied_adapters: dict[str, str], +) -> bool: + """Apply user-friendly CLI input shapes for supported wrapper objects.""" + spec = _adapter_spec_for_field(obj, key) + if spec is None: + return False + if spec.value_kind == CLI_VALUE_KIND_SELECTION_TEXT: + return _apply_selection_text_adapter(spec, obj, key, value, applied_adapters=applied_adapters) + if spec.value_kind == CLI_VALUE_KIND_VECTOR_TRIPLET: + return _apply_vector_triplet_adapter(spec, obj, key, value, applied_adapters=applied_adapters) + if spec.value_kind == CLI_VALUE_KIND_VECTOR_ARRAY_VALUES: + return _apply_vector_array_values_adapter(spec, obj, key, value, applied_adapters=applied_adapters) + if spec.value_kind == CLI_VALUE_KIND_LIST_VALUES: + return _apply_list_values_adapter(spec, obj, key, value, applied_adapters=applied_adapters) + return False + + +def apply_wrapper_shorthand_adapter( + obj: Any, + value: Any, + *, + applied_adapters: dict[str, str], +) -> bool: + """Apply non-JSON key=value shorthand inputs for supported wrapper objects.""" + spec = _shorthand_adapter_spec(obj) + if spec is None: + return False + if spec.value_kind == CLI_VALUE_KIND_SELECTION_TEXT: + return _apply_selection_text_adapter( + spec, + obj, + _DIRECT_PARAM_SHORTHAND_KEY, + value, + applied_adapters=applied_adapters, + ) + if spec.value_kind == CLI_VALUE_KIND_VECTOR_TRIPLET: + return _apply_vector_triplet_adapter( + spec, + obj, + _DIRECT_PARAM_SHORTHAND_KEY, + value, + applied_adapters=applied_adapters, + ) + if spec.value_kind == CLI_VALUE_KIND_LIST_VALUES: + return _apply_list_values_adapter( + spec, + obj, + _DIRECT_PARAM_SHORTHAND_KEY, + value, + applied_adapters=applied_adapters, + ) + if spec.value_kind == CLI_VALUE_KIND_VECTOR_ARRAY_VALUES: + return _apply_vector_array_values_adapter( + spec, + obj, + _DIRECT_PARAM_SHORTHAND_KEY, + value, + applied_adapters=applied_adapters, + ) + return False + + +def _selection_text_adapter_hints(wrapper_type: str, spec: _AdapterSpec) -> dict[str, Any]: + return { + "typed_json_shape": { + "__type__": wrapper_type, + spec.preferred_field: "", + }, + "friendly_json_input": { + "preferred_field": spec.preferred_field, + "field_value_type": "string", + "note": _tr( + "Canonical JSON field is derived from the reflected wrapper method signature for {method_name}().", + method_name=spec.method_name, + ), + }, + "non_json_input": { + "preferred_syntax": "=", + "explicit_field_syntax": f".{spec.preferred_field}=", + "note": _T("Direct parameter assignment remains the preferred non-JSON form."), + }, + "examples": { + "preferred_param_value": { + spec.preferred_field: "N1,N2", + }, + "preferred_params_json": { + "": { + spec.preferred_field: "N1,N2", + } + }, + "preferred_non_json": "=N1,N2", + "explicit_field_non_json": f".{spec.preferred_field}=N1,N2", + }, + } + + +def _vector_triplet_adapter_hints(wrapper_type: str, spec: _AdapterSpec) -> dict[str, Any]: + return { + "typed_json_shape": { + "__type__": wrapper_type, + spec.preferred_field: [0.0, 0.0, 1.0], + }, + "friendly_json_input": { + "preferred_field": spec.preferred_field, + "alternate_fields": ["x", "y", "z"], + "field_value_type": "number[3]", + "note": _tr( + "Canonical triplet field is derived from the reflected wrapper method {method_name}().", + method_name=spec.method_name, + ), + }, + "non_json_input": { + "preferred_syntax": "=0,0,1", + "explicit_field_syntax": f".{spec.preferred_field}=0,0,1", + "note": _T("Use a comma-separated triplet for vector shorthand."), + }, + "examples": { + "preferred_param_value": { + spec.preferred_field: [0.0, 0.0, 1.0], + }, + "preferred_params_json": { + "": { + spec.preferred_field: [0.0, 0.0, 1.0], + } + }, + "preferred_non_json": "=0,0,1", + "explicit_field_non_json": f".{spec.preferred_field}=0,0,1", + }, + } + + +def _list_values_adapter_hints( + wrapper_type: str, + wrapper_cls: type, + spec: _AdapterSpec, +) -> dict[str, Any]: + example_item = _example_item_for_list_spec(wrapper_cls, spec) + second_item = _second_list_example_item(example_item) + shorthand_example = _list_values_shorthand_example(wrapper_cls, spec) + return { + "typed_json_shape": { + "__type__": wrapper_type, + spec.preferred_field: [example_item, second_item], + }, + "friendly_json_input": { + "preferred_field": spec.preferred_field, + "field_value_type": "list", + "note": _tr( + "Canonical list field is derived from the reflected wrapper method {method_name}().", + method_name=spec.method_name, + ), + }, + "non_json_input": { + "preferred_syntax": f"={shorthand_example}", + "explicit_field_syntax": f".{spec.preferred_field}={shorthand_example}", + "note": _T( + "Use a comma-separated list for quick CLI input, or a JSON array string when values contain commas." + ), + }, + "examples": { + "preferred_param_value": { + spec.preferred_field: [example_item, second_item], + }, + "preferred_params_json": { + "": { + spec.preferred_field: [example_item, second_item], + } + }, + "preferred_non_json": f"={shorthand_example}", + "explicit_field_non_json": f".{spec.preferred_field}={shorthand_example}", + }, + } + + +def _vector_array_values_adapter_hints(wrapper_type: str, spec: _AdapterSpec) -> dict[str, Any]: + return { + "typed_json_shape": { + "__type__": wrapper_type, + spec.preferred_field: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + }, + "friendly_json_input": { + "preferred_field": spec.preferred_field, + "field_value_type": "list[number[3]]", + "note": _tr( + "Canonical vector-array field is derived from the reflected wrapper method {method_name}().", + method_name=spec.method_name, + ), + }, + "non_json_input": { + "preferred_syntax": "=0,0,0;1,0,0", + "explicit_field_syntax": f".{spec.preferred_field}=0,0,0;1,0,0", + "note": _T( + "Use semicolon-separated triplets for quick CLI input. Quote the value in shells that treat semicolons specially." + ), + }, + "examples": { + "preferred_param_value": { + spec.preferred_field: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + }, + "preferred_params_json": { + "": { + spec.preferred_field: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + } + }, + "preferred_non_json": "=0,0,0;1,0,0", + "explicit_field_non_json": f".{spec.preferred_field}=0,0,0;1,0,0", + }, + } + + + +def _build_input_adapter_hints_for_class(wrapper_cls: type, wrapper_type: str) -> dict[str, Any] | None: + hints: dict[str, Any] = {} + for spec in _adapter_specs_for_target(wrapper_cls): + if spec.value_kind == CLI_VALUE_KIND_SELECTION_TEXT: + hints.update(_selection_text_adapter_hints(wrapper_type, spec)) + elif spec.value_kind == CLI_VALUE_KIND_VECTOR_TRIPLET: + hints.update(_vector_triplet_adapter_hints(wrapper_type, spec)) + elif spec.value_kind == CLI_VALUE_KIND_VECTOR_ARRAY_VALUES: + hints.update(_vector_array_values_adapter_hints(wrapper_type, spec)) + elif spec.value_kind == CLI_VALUE_KIND_LIST_VALUES: + hints.update(_list_values_adapter_hints(wrapper_type, wrapper_cls, spec)) + return hints or None + + +def build_target_input_adapter_hints(target: Any) -> dict[str, Any] | None: + """Return user-facing hints for a specific wrapper instance or wrapper class.""" + wrapper_cls = target if inspect.isclass(target) else type(target) + return _build_input_adapter_hints_for_class(wrapper_cls, wrapper_cls.__name__) + + +def build_wrapper_input_adapter_hints(wrapper_type: str) -> dict[str, Any] | None: + """Return user-facing template hints for wrapper-specific CLI adapters.""" + canonical_wrapper_type = resolve_wrapper_name(wrapper_type) + wrapper_cls = resolve_wrapper_class(wrapper_type) + if wrapper_cls is None or canonical_wrapper_type is None: + return None + return _build_input_adapter_hints_for_class(wrapper_cls, canonical_wrapper_type) diff --git a/src/moldflow_cli/wrapper_registry.py b/src/moldflow_cli/wrapper_registry.py new file mode 100644 index 0000000..9a59401 --- /dev/null +++ b/src/moldflow_cli/wrapper_registry.py @@ -0,0 +1,127 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import importlib +from functools import lru_cache +from typing import Any, Iterable + + +def _wrapper_snapshot() -> tuple[tuple[str, int], ...]: + """Return a stable snapshot of currently exported public wrapper classes.""" + from .introspection import iter_public_classes + + return tuple(sorted((name, id(cls)) for name, cls in iter_public_classes())) + + +@lru_cache(maxsize=8) +def _public_wrapper_classes_by_name_cached( + snapshot: tuple[tuple[str, int], ...], +) -> dict[str, type]: + """Return public wrapper classes keyed by case-insensitive class name.""" + mf = importlib.import_module("moldflow") + + return {name.lower(): getattr(mf, name) for name, _ in snapshot} + + +def public_wrapper_classes_by_name() -> dict[str, type]: + return _public_wrapper_classes_by_name_cached(_wrapper_snapshot()) + + +@lru_cache(maxsize=8) +def _public_wrapper_alias_lookup_cached( + snapshot: tuple[tuple[str, int], ...], +) -> dict[str, type]: + """Return public wrapper classes keyed by class name and snake_case CLI alias.""" + from .factories import camel_to_snake + mf = importlib.import_module("moldflow") + + lookup = {name.lower(): getattr(mf, name) for name, _ in snapshot} + for name, _ in snapshot: + cls = getattr(mf, name) + name_lower = name.lower() + lookup.setdefault(name_lower, cls) + lookup.setdefault(camel_to_snake(name).lower(), cls) + lookup.setdefault(cls.__name__.lower(), cls) + lookup.setdefault(camel_to_snake(cls.__name__).lower(), cls) + return lookup + + +def public_wrapper_alias_lookup() -> dict[str, type]: + return _public_wrapper_alias_lookup_cached(_wrapper_snapshot()) + + +@lru_cache(maxsize=8) +def _public_wrapper_name_lookup_cached( + snapshot: tuple[tuple[str, int], ...], +) -> dict[str, str]: + """Return canonical exported wrapper names keyed by supported case-insensitive aliases.""" + from .factories import camel_to_snake + mf = importlib.import_module("moldflow") + + lookup: dict[str, str] = {} + for name, _ in snapshot: + cls = getattr(mf, name) + lookup.setdefault(name.lower(), name) + lookup.setdefault(camel_to_snake(name).lower(), name) + lookup.setdefault(cls.__name__.lower(), name) + lookup.setdefault(camel_to_snake(cls.__name__).lower(), name) + return lookup + + +def public_wrapper_name_lookup() -> dict[str, str]: + return _public_wrapper_name_lookup_cached(_wrapper_snapshot()) + + +@lru_cache(maxsize=8) +def _public_wrapper_cli_map_cached(snapshot: tuple[tuple[str, int], ...]) -> dict[str, type]: + """Return public wrapper classes keyed by snake_case CLI alias.""" + from .factories import camel_to_snake + mf = importlib.import_module("moldflow") + + return {camel_to_snake(name): getattr(mf, name) for name, _ in snapshot} + + +def public_wrapper_cli_map() -> dict[str, type]: + return _public_wrapper_cli_map_cached(_wrapper_snapshot()) + + +def resolve_wrapper_class(wrapper_type: str) -> type | None: + """Resolve a wrapper class by case-insensitive class name or CLI alias.""" + if not isinstance(wrapper_type, str) or not wrapper_type.strip(): + return None + return public_wrapper_alias_lookup().get(wrapper_type.strip().lower()) + + +def resolve_wrapper_name(wrapper_type: str) -> str | None: + """Resolve the canonical exported wrapper name for a supported alias.""" + if not isinstance(wrapper_type, str) or not wrapper_type.strip(): + return None + return public_wrapper_name_lookup().get(wrapper_type.strip().lower()) + + +def resolve_first_wrapper_name(type_names: Iterable[str]) -> str | None: + """Resolve the first canonical exported wrapper name from annotation-derived type names.""" + lookup = public_wrapper_name_lookup() + for type_name in type_names: + if not isinstance(type_name, str): + continue + resolved = lookup.get(type_name.strip().lower()) + if resolved is not None: + return resolved + return None + + +def build_wrapper_input_hints(wrapper_type: str) -> dict[str, Any] | None: + """Return wrapper input hints through the shared facade.""" + from .wrapper_input_adapters import build_wrapper_input_adapter_hints + + return build_wrapper_input_adapter_hints(wrapper_type) + + +def build_target_input_hints(target: Any) -> dict[str, Any] | None: + """Return target input hints through the shared facade.""" + from .wrapper_input_adapters import build_target_input_adapter_hints + + return build_target_input_adapter_hints(target) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..eee830b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..eee830b --- /dev/null +++ b/tests/api/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/api/integration_tests/test_message_box_permutations.py b/tests/api/integration_tests/test_message_box_permutations.py index a29bddc..9f294d0 100644 --- a/tests/api/integration_tests/test_message_box_permutations.py +++ b/tests/api/integration_tests/test_message_box_permutations.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + """Integration tests for message box permutations (Windows only).""" # These tests perform UI automation and include short-lived closures and diff --git a/tests/api/unit_tests/__init__.py b/tests/api/unit_tests/__init__.py new file mode 100644 index 0000000..eee830b --- /dev/null +++ b/tests/api/unit_tests/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/api/unit_tests/conftest.py b/tests/api/unit_tests/conftest.py index a82deba..a8e6a1f 100644 --- a/tests/api/unit_tests/conftest.py +++ b/tests/api/unit_tests/conftest.py @@ -5,11 +5,21 @@ Fixtures: mock_object: A pytest fixture that provides a mock object for the class instantiation.""" +import re from unittest.mock import Mock + import pytest from moldflow.constants import COLOR_BAND_RANGE from tests.api.unit_tests.mock_container import MockContainer +_ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + + +def strip_ansi(text: str) -> str: + """Remove ANSI escape sequences from CLI output.""" + return _ANSI_ESCAPE.sub("", text) + + VALID_COLOR_BAND_VALUES = COLOR_BAND_RANGE VALID_MOCK = MockContainer() diff --git a/tests/api/unit_tests/test_cli_basics.py b/tests/api/unit_tests/test_cli_basics.py new file mode 100644 index 0000000..5540a6b --- /dev/null +++ b/tests/api/unit_tests/test_cli_basics.py @@ -0,0 +1,236 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Focused basic command behavior tests for moldflow CLI.""" + +from __future__ import annotations + +from unittest.mock import call, patch + +import pytest +from typer.testing import CliRunner +import moldflow + +from moldflow_cli.commands import build_cli_app +from tests.api.unit_tests.conftest import strip_ansi + +runner = CliRunner() + + +@pytest.mark.cli +@pytest.mark.unit +class TestUnitCLI: + """Unit tests for the moldflow CLI entrypoints.""" + + def test_help_runs_without_synergy(self): + """Ensure global help runs without instantiating Synergy/COM.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke(app, ["--no-color", "--help"]) + assert result.exit_code == 0 + # Help should not need to touch Synergy/COM at all. + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + output = strip_ansi(result.stdout) + assert "Moldflow command-line interface" in output + assert "Start with 'list' to discover targets" in output + assert "describe " in output + assert "invoke " in output + + def test_help_shows_global_no_color_option(self): + """Global help should advertise the output-styling toggle.""" + app = build_cli_app() + result = runner.invoke(app, ["--no-color", "--help"]) + assert result.exit_code == 0 + assert "--no-color" in strip_ansi(result.stdout) + + def test_subcommand_help_runs_without_synergy(self): + """Subcommand help should be available without creating Synergy/COM objects.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + list_help = runner.invoke(app, ["--no-color", "list", "--help"]) + describe_help = runner.invoke(app, ["--no-color", "describe", "--help"]) + invoke_help = runner.invoke(app, ["--no-color", "invoke", "--help"]) + + assert list_help.exit_code == 0 + assert describe_help.exit_code == 0 + assert invoke_help.exit_code == 0 + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + list_output = strip_ansi(list_help.stdout) + describe_output = strip_ansi(describe_help.stdout) + invoke_output = strip_ansi(invoke_help.stdout) + assert "Discover invokable targets and the next command to run for each one" in list_output + assert "--json" in list_output + assert "--with-describe" in list_output + assert ( + "Inspect a target's signature, docs, examples, and structured invoke template" + in describe_output + ) + assert "Run a Moldflow target with named parameters or JSON input" in invoke_output + + def test_global_no_color_option_configures_cli_output(self): + """Global --no-color should reconfigure shared console output before dispatch.""" + with patch("moldflow_cli.commands.configure_console") as mock_configure_console: + app = build_cli_app() + result = runner.invoke(app, ["--no-color", "version"]) + + assert result.exit_code == 0 + assert mock_configure_console.call_args_list == [call(no_color=False), call(no_color=True)] + + def test_version_command_is_registered(self): + """Ensure explicit 'version' subcommand is available.""" + app = build_cli_app() + result = runner.invoke(app, ["version"]) + assert result.exit_code == 0 + assert moldflow.__version__ in result.stdout + + def test_list_does_not_instantiate_synergy(self): + """Ensure 'list' reflects targets without touching Synergy/COM.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke(app, ["list", "--filter", "synergy"]) + assert result.exit_code == 0 + # Listing invokable targets is based on reflection only. + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + # Should at least mention a Synergy-related target. + assert "synergy" in result.stdout.lower() + + def test_describe_uses_signature_without_synergy(self): + """Ensure 'describe' uses introspection only and does not touch Synergy/COM.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + # Synergy.open_project is a simple, well-known method to describe. + result = runner.invoke(app, ["describe", "synergy.open_project"]) + assert result.exit_code == 0 + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + # Signature line should include the parameter name. + assert "open_project" in result.stdout + assert "path" in result.stdout + + def test_invoke_missing_required_param_fails_before_synergy(self): + """Ensure invoke validates required parameters before creating Synergy/COM.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke(app, ["invoke", "synergy.open_project"]) + # Should fail due to missing arguments. + assert result.exit_code != 0 + # Should not instantiate Synergy when arguments are invalid. + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + + def test_invoke_simple_scalar_method_calls_mock(self): + """Invoke a simple scalar-argument method and ensure the mock is called correctly.""" + app = build_cli_app() + + class FakeSynergyWindow: + """Fake Synergy exposing set_application_window_pos.""" + + def __init__(self) -> None: + self.calls: list[tuple[int, int, int, int]] = [] + + def set_application_window_pos( + self, x: int, y: int, size_x: int, size_y: int + ) -> bool: # noqa: D401 + """Mock implementation that records call arguments.""" + self.calls.append((x, y, size_x, size_y)) + return True + + fake = FakeSynergyWindow() + with patch("moldflow_cli.context.get_synergy", return_value=fake), patch( + "moldflow_cli.factories.get_synergy", return_value=fake + ): + result = runner.invoke( + app, + [ + "invoke", + "synergy.set_application_window_pos", + "x=10", + "y=20", + "size_x=800", + "size_y=600", + ], + ) + + assert result.exit_code == 0 + assert fake.calls == [(10, 20, 800, 600)] + + def test_invoke_unknown_target_does_not_instantiate_synergy(self): + """Unknown target should fail fast without creating Synergy/COM.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke(app, ["invoke", "synergy.no_such_method"]) + assert result.exit_code != 0 + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + + def test_describe_unknown_target_shows_actionable_error(self): + """Unknown describe target should fail with a clear message and no Synergy usage.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke(app, ["describe", "synergy.no_such_method"]) + + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "no attribute" in combined.lower() or "cannot resolve" in combined.lower() + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + + def test_invoke_unknown_parameter_does_not_instantiate_synergy(self): + """Unknown parameter name should be reported without creating Synergy/COM.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke(app, ["invoke", "synergy.open_project", "bad_param=foo"]) + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "Run 'describe" in combined + assert "inspect accepted parameters and JSON examples" in combined + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + + def test_invoke_nested_args_missing_required_param_does_not_instantiate_synergy(self): + """Nested args still fail required-arg checks before Synergy/COM.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_test_nested_required", None) + + def cli_test_nested_required(self, required: str, options: "Vector | None" = None) -> str: + del self, required, options + return "ok" + + setattr(moldflow.Synergy, "cli_test_nested_required", cli_test_nested_required) + try: + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke( + app, ["invoke", "synergy.cli_test_nested_required", "options.x=1"] + ) + + assert result.exit_code != 0 + out = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "missing required parameter" in out.lower() + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_test_nested_required") + else: + setattr(moldflow.Synergy, "cli_test_nested_required", orig) diff --git a/tests/api/unit_tests/test_cli_describe.py b/tests/api/unit_tests/test_cli_describe.py new file mode 100644 index 0000000..b3549a6 --- /dev/null +++ b/tests/api/unit_tests/test_cli_describe.py @@ -0,0 +1,1264 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Focused describe command tests for moldflow CLI.""" + +from __future__ import annotations + +# Test module uses small inline doubles for targeted behavior checks. +# pylint: disable=too-few-public-methods,too-many-lines + +from unittest.mock import patch +import importlib.util +import json + +import pytest +from typer.testing import CliRunner +import moldflow + +from moldflow_cli.commands import build_cli_app +from moldflow_cli import target_resolution + +runner = CliRunner() + + +def _has_yaml() -> bool: + return importlib.util.find_spec("yaml") is not None + + +@pytest.mark.cli +@pytest.mark.unit +def test_iter_static_members_falls_back_without_getmembers_static(): + """Static member lookup should stay descriptor-safe on Python 3.10.""" + + class ExplosiveDescriptor: + """Descriptor used to verify the fallback does not execute descriptors.""" + + def __get__(self, obj, owner=None): + raise RuntimeError("descriptor should not execute during static lookup") + + class CliExplosive: + """Class containing a descriptor and regular method for static lookup tests.""" + + danger = ExplosiveDescriptor() + + def safe_method(self) -> str: + """Simple method used to confirm regular members are still returned.""" + return "ok" + + with patch.object( + target_resolution.inspect, + "getmembers_static", + create=True, + side_effect=AttributeError("getmembers_static unavailable"), + ): + members = dict(target_resolution.iter_static_members(CliExplosive)) + + assert "danger" in members + assert "safe_method" in members + assert isinstance(members["danger"], ExplosiveDescriptor) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_case_insensitive_and_prefixed(): + """Ensure describe is case-insensitive and accepts 'moldflow.' prefixes.""" + app = build_cli_app() + + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result_mixed = runner.invoke(app, ["describe", "SyNeRgY.NeW_PrOjEcT"]) + result_prefixed = runner.invoke(app, ["describe", "moldflow.Synergy.new_project"]) + + # Neither call should touch Synergy/COM. + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + assert result_mixed.exit_code == 0 + assert result_prefixed.exit_code == 0 + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_multi_step_signature_rendering(): + """Describe on a chained target should show signatures for each step (introspection only).""" + app = build_cli_app() + res = runner.invoke(app, ["describe", "plot_manager.find_plot_by_name"]) + assert res.exit_code == 0 + assert "find_plot_by_name" in res.stdout or "plot_manager" in res.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_help_and_describe_show_snake_case_targets(): + """Ensure help for `describe` shows snake_case example targets.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "--help"]) + assert result.exit_code == 0 + assert "synergy.new_project" in result.stdout + lowered = result.stdout.lower() + assert "structured invoke template" in lowered or "examples" in lowered + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_help_mentions_type_and_describe_first_guidance(): + """List help should advertise discovery-first guidance for first-time users.""" + app = build_cli_app() + result = runner.invoke(app, ["list", "--help"]) + assert result.exit_code == 0 + lowered = result.stdout.lower() + assert "discover invokable targets" in lowered + assert "next command" in lowered + assert "wildcard" in lowered + + +@pytest.mark.cli +@pytest.mark.unit +@pytest.mark.skipif(not _has_yaml(), reason="PyYAML not installed") +def test_describe_yaml_output(): + """Describe should emit YAML when requested.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "synergy.open_project", "--yaml"]) + assert result.exit_code == 0 + assert "signature" in result.stdout or "params" in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_multiple_targets_human_output(): + """Describe should accept multiple targets and render each target block in order.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "synergy.new_project", "synergy.open_project"]) + assert result.exit_code == 0 + assert "synergy.new_project" in result.stdout + assert "synergy.open_project(path: str)" in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_multiple_targets_json_output_emits_list_payload(): + """Structured describe should emit a list payload when multiple targets are requested.""" + app = build_cli_app() + result = runner.invoke( + app, ["describe", "synergy.new_project", "synergy.open_project", "--json"] + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert isinstance(payload, list) + assert [item["target"] for item in payload] == ["synergy.new_project", "synergy.open_project"] + assert payload[0]["schema_version"] == payload[1]["schema_version"] + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_rejects_unresolvable_chained_target_suffix(): + """Describe should fail when extra chained segments cannot be resolved.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "synergy.open_project.nonexistent_tail"]) + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + lowered = combined.lower() + assert "has no attribute" in lowered or all( + token in lowered for token in ("returns", "non-wrapper", "value") + ) + assert all(token in lowered for token in ("cannot", "continue", "nonexistent_tail")) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_rejects_empty_target_segment(): + """Describe should reject malformed dotted targets with empty segments.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "synergy..open_project"]) + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "empty path segment" in combined.lower() + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_rejects_invalid_target_identifier_segment(): + """Describe should reject target segments that are not valid identifiers.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "synergy.open-project"]) + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "must be a valid identifier" in combined.lower() + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_json_handles_uninspectable_signature(): + """Describe --json should not crash when inspect.signature fails.""" + app = build_cli_app() + + class BadSigCallable: + """Callable object whose signature introspection fails.""" + + @property + def __signature__(self): + raise ValueError("bad signature") + + def __call__(self, *args, **kwargs): # pragma: no cover - not invoked here + return None + + orig = getattr(moldflow.Synergy, "cli_bad_sig_describe", None) + setattr(moldflow.Synergy, "cli_bad_sig_describe", BadSigCallable()) + try: + result = runner.invoke(app, ["describe", "synergy.cli_bad_sig_describe", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["target"] == "synergy.cli_bad_sig_describe" + assert payload["params"] == [] + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_bad_sig_describe") + else: + setattr(moldflow.Synergy, "cli_bad_sig_describe", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_json_on_property_returns_structured_payload(): + """Structured describe should not fail on non-callable properties.""" + + class CLITestDummy: + """Test dummy class for describe command.""" + + @property + def foobar(self) -> str: + """Example property.""" + return "bar" + + orig_cls = getattr(moldflow, "CLITestDummy", None) + setattr(moldflow, "CLITestDummy", CLITestDummy) + app = build_cli_app() + try: + result = runner.invoke(app, ["describe", "CLITestDummy.foobar", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["signature"] is None + assert payload["type"] == "property" + assert payload["summary"] == "Example property." + assert "details" not in payload + finally: + if orig_cls is None: + delattr(moldflow, "CLITestDummy") + else: + setattr(moldflow, "CLITestDummy", orig_cls) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_settable_property_json_exposes_synthetic_value_param(): + """Settable property JSON should include synthetic 'value' param metadata for clarity.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "synergy.study_doc.mesh_type", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + params = payload.get("params", []) + assert isinstance(params, list) + assert any( + isinstance(param, dict) and param.get("name") == "value" and param.get("synthetic") is True + for param in params + ) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_json_preserves_docstring_markup_like_text(): + """Structured describe output must not interpret rich-like markup tokens.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_markup_doc", None) + + def cli_markup_doc(self): + """Literal doc with [bold]tokens[/bold] should round-trip unchanged.""" + del self + + setattr(moldflow.Synergy, "cli_markup_doc", cli_markup_doc) + try: + result = runner.invoke(app, ["describe", "synergy.cli_markup_doc", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert ( + payload["summary"] + == "Literal doc with [bold]tokens[/bold] should round-trip unchanged." + ) + assert "details" not in payload + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_markup_doc") + else: + setattr(moldflow.Synergy, "cli_markup_doc", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_json_omits_empty_paren_signature_for_zero_arg_methods(): + """Structured describe should not emit a bare empty-paren signature for zero-arg callables.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_zero_arg_doc", None) + + def cli_zero_arg_doc(self) -> str: + """Create a sentinel value.""" + del self + return "ok" + + setattr(moldflow.Synergy, "cli_zero_arg_doc", cli_zero_arg_doc) + try: + result = runner.invoke(app, ["describe", "synergy.cli_zero_arg_doc", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["signature"] is None + assert payload["returns"] == "str" + assert payload["summary"] == "Create a sentinel value." + assert payload["params"] == [] + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_zero_arg_doc") + else: + setattr(moldflow.Synergy, "cli_zero_arg_doc", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_json_flattens_multiline_docstrings(): + """Structured describe output should not contain embedded newline escapes in doc text.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_multiline_doc", None) + + def cli_multiline_doc(self): + """Show the given plot. + + Args: + plot (Plot | None): The plot to show. + """ + del self + + setattr(moldflow.Synergy, "cli_multiline_doc", cli_multiline_doc) + try: + result = runner.invoke(app, ["describe", "synergy.cli_multiline_doc", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert "\n" not in payload["summary"] + assert payload["summary"] == "Show the given plot." + assert payload["details"] == "Args: plot (Plot | None): The plot to show." + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_multiline_doc") + else: + setattr(moldflow.Synergy, "cli_multiline_doc", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_schema_emits_json_schema_like_contract(): + """describe --schema should emit required/properties payload.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "synergy.open_project", "--schema"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["type"] == "object" + assert "properties" in payload and "required" in payload + assert "path" in payload["properties"] + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_schema_is_mutually_exclusive_with_json_and_yaml(): + """describe --schema should be exclusive with --json/--yaml.""" + app = build_cli_app() + r1 = runner.invoke(app, ["describe", "synergy.open_project", "--schema", "--json"]) + r2 = runner.invoke(app, ["describe", "synergy.open_project", "--schema", "--yaml"]) + assert r1.exit_code != 0 + assert r2.exit_code != 0 + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_trims_target_whitespace_before_resolution(): + """Describe should normalize leading/trailing spaces in target input.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", " synergy.open_project "]) + assert result.exit_code == 0 + assert "synergy.open_project" in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_json_includes_invoke_examples_and_template(): + """Structured describe should show the preferred invoke form and params-json shape.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "synergy.open_project", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + invoke_examples = payload.get("invoke_examples") + assert isinstance(invoke_examples, dict) + assert payload.get("signature") == "(path: str)" + assert payload.get("returns") == "bool" + assert payload.get("summary") + assert invoke_examples.get("cli_command") == "invoke synergy.open_project path=" + assert payload.get("next_command") == "invoke synergy.open_project path=" + assert payload.get("params_json_template", {}).get("path") is None + assert invoke_examples.get("params_json", {}).get("path") == "" + assert "kind" not in payload["params"][0] + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_yaml_avoids_anchor_noise_in_invoke_examples(): + """YAML describe output should not emit anchors for duplicated invoke example payloads.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "synergy.open_project", "--yaml"]) + assert result.exit_code == 0 + assert "&id" not in result.stdout + assert "*id" not in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_omits_receiver_params_from_human_and_json_output(): + """Describe output should hide Python receiver parameters such as self/cls.""" + app = build_cli_app() + + human = runner.invoke(app, ["describe", "synergy.new_project"]) + structured = runner.invoke(app, ["describe", "synergy.new_project", "--json"]) + + assert human.exit_code == 0 + assert structured.exit_code == 0 + assert "(self" not in human.stdout + payload = json.loads(structured.stdout) + assert "self" not in payload["signature"] + assert payload.get("returns") == "bool" + assert all(param["name"] != "self" for param in payload["params"]) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_human_output_includes_examples(): + """Human describe output should show the preferred invoke command and JSON shape.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "synergy.open_project"]) + assert result.exit_code == 0 + assert "Try this:" in result.stdout + assert "JSON example:" in result.stdout + assert "synergy.open_project(path: str)" in result.stdout + assert "-> None" not in result.stdout + assert "invoke synergy.open_project path=" in result.stdout + assert '"path": ""' in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_human_output_includes_wrapper_input_hints(): + """Describe should surface wrapper-specific JSON and non-JSON input guidance directly.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_accept_levels", None) + + def cli_accept_levels(self, levels: "DoubleArray | None") -> str: + """Return a simple sentinel for wrapper-hint coverage.""" + del self, levels + return "ok" + + setattr(moldflow.Synergy, "cli_accept_levels", cli_accept_levels) + try: + result = runner.invoke(app, ["describe", "synergy.cli_accept_levels"]) + assert result.exit_code == 0 + assert "Input hints:" in result.stdout + assert "levels (DoubleArray):" in result.stdout + assert "JSON value:" in result.stdout + assert "CLI argument: levels=1.0,2.5" in result.stdout + assert '"levels": {' in result.stdout + assert '"values": [' in result.stdout + assert "levels=1.0,2.5" in result.stdout + assert "levels.values=1.0,2.5" in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_accept_levels") + else: + setattr(moldflow.Synergy, "cli_accept_levels", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_wrapper_input_hints_normalize_alias_annotations_to_canonical_name(): + """Describe should normalize alias annotations to canonical wrapper names.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_accept_levels_alias", None) + + def cli_accept_levels_alias(self, levels: "double_array | None") -> str: + """Return a simple sentinel for alias-normalization coverage.""" + del self, levels + return "ok" + + setattr(moldflow.Synergy, "cli_accept_levels_alias", cli_accept_levels_alias) + try: + result = runner.invoke(app, ["describe", "synergy.cli_accept_levels_alias"]) + assert result.exit_code == 0 + assert "levels (DoubleArray):" in result.stdout + assert "levels (double_array):" not in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_accept_levels_alias") + else: + setattr(moldflow.Synergy, "cli_accept_levels_alias", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_human_output_includes_minimal_examples_when_they_differ(): + """Describe should surface both preferred and minimal examples when both are useful.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_optional_describe", None) + + def cli_optional_describe(self, required_name: str, dataset_name: str | None = None) -> str: + """Return a simple sentinel for describe example coverage.""" + del self, required_name, dataset_name + return "ok" + + setattr(moldflow.Synergy, "cli_optional_describe", cli_optional_describe) + try: + result = runner.invoke(app, ["describe", "synergy.cli_optional_describe"]) + assert result.exit_code == 0 + assert "Try this:" in result.stdout + assert "Shorter form:" in result.stdout + assert "dataset_name=" in result.stdout + assert '"required_name": ""' in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_optional_describe") + else: + setattr(moldflow.Synergy, "cli_optional_describe", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_human_output_uses_concise_wrapper_type_names_and_omits_bare_minimal_form(): + """Describe should keep wrapper names concise and hide bare minimal examples.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "boundary_conditions.create_edge_loads"]) + assert result.exit_code == 0 + normalized = " ".join(result.stdout.split()) + assert ( + "synergy.boundary_conditions.create_edge_loads(nodes: EntList | None, force: Vector | None)" + in normalized + ) + assert "moldflow.ent_list.EntList" not in result.stdout + assert "moldflow.vector.Vector" not in result.stdout + assert "Shorter form:" not in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_rejects_hidden_transient_wrapper_factory_targets(): + """Describe should reject wrapper-factory helpers hidden by library CLI metadata.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "boundary_conditions.create_entity_list"]) + + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + lowered = combined.lower() + assert "hidden from the cli" in lowered + assert "boundary_conditions.create_entity_list" in combined + assert "entlist wrapper" in lowered + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_resolves_synergy_property_reachable_methods(): + """Describe should follow Synergy properties to their wrapper methods.""" + app = build_cli_app() + + class CLITestManager: + """Wrapper reachable from a Synergy property for describe resolution.""" + + def run(self, label: str) -> str: + """Run the test manager action.""" + del label + return "ok" + + orig_cls = getattr(moldflow, "CLITestManager", None) + orig_attr = getattr(moldflow.Synergy, "cli_test_manager", None) + + def _get_cli_test_manager(self) -> CLITestManager: + del self + return CLITestManager() + + setattr(moldflow, "CLITestManager", CLITestManager) + setattr(moldflow.Synergy, "cli_test_manager", property(_get_cli_test_manager)) + try: + result = runner.invoke(app, ["describe", "synergy.cli_test_manager.run"]) + assert result.exit_code == 0 + assert "synergy.cli_test_manager.run(" in result.stdout + assert "label" in result.stdout + assert "Try this:" in result.stdout + finally: + if orig_cls is None: + delattr(moldflow, "CLITestManager") + else: + setattr(moldflow, "CLITestManager", orig_cls) + if orig_attr is None: + delattr(moldflow.Synergy, "cli_test_manager") + else: + setattr(moldflow.Synergy, "cli_test_manager", orig_attr) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_bare_synergy_property_prefers_property_over_wrapper_class_alias(): + """Bare describe should prefer the matching Synergy property when one exists.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "cad_diagnostic"]) + assert result.exit_code == 0 + normalized = "".join(result.stdout.split()) + assert "synergy.cad_diagnostic" in result.stdout + assert "_cad_diagnostic" not in result.stdout + assert "Wrapper for CADDiagnostic class of Moldflow Synergy." not in result.stdout + assert "Continuewithdescribesynergy.cad_diagnostic." in normalized + assert "Try this:" not in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_property_human_output_strips_rst_field_list_lines(): + """Human describe output should not expose raw reStructuredText property fields.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_doc_property", None) + + def _get_cli_doc_property(self) -> str: + """Show a clean description. + + :getter: Get the current value. + :type: str + """ + del self + return "ok" + + setattr(moldflow.Synergy, "cli_doc_property", property(_get_cli_doc_property)) + try: + result = runner.invoke(app, ["describe", "synergy.cli_doc_property"]) + assert result.exit_code == 0 + assert "Show a clean description." in result.stdout + assert ":getter:" not in result.stdout + assert ":type:" not in result.stdout + assert "JSON example:" not in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_doc_property") + else: + setattr(moldflow.Synergy, "cli_doc_property", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_property_json_output_strips_rst_field_list_lines(): + """Structured describe output should not expose raw property RST field-list lines.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_doc_property_json", None) + + def _get_cli_doc_property_json(self) -> str: + """Show a clean structured description. + + :getter: Get the current value. + :type: str + """ + del self + return "ok" + + setattr(moldflow.Synergy, "cli_doc_property_json", property(_get_cli_doc_property_json)) + try: + result = runner.invoke(app, ["describe", "synergy.cli_doc_property_json", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert ":getter:" not in payload["summary"] + assert ":type:" not in payload["summary"] + assert "Show a clean structured description." in payload["summary"] + assert "details" not in payload + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_doc_property_json") + else: + setattr(moldflow.Synergy, "cli_doc_property_json", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_readwrite_property_human_output_shows_read_and_assignment_guidance(): + """Describe should show both read and assignment guidance for read/write properties.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_rw_property_doc", None) + + def _get_cli_rw_property_doc(self) -> str: + del self + return "Fusion" + + def _set_cli_rw_property_doc(self, value: str) -> None: + del self, value + + setattr( + moldflow.Synergy, + "cli_rw_property_doc", + property(_get_cli_rw_property_doc, _set_cli_rw_property_doc), + ) + try: + result = runner.invoke(app, ["describe", "synergy.cli_rw_property_doc"]) + assert result.exit_code == 0 + assert "Read current value:" in result.stdout + assert "invoke synergy.cli_rw_property_doc" in result.stdout + assert "Set it with:" in result.stdout + assert '"value": ""' in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_rw_property_doc") + else: + setattr(moldflow.Synergy, "cli_rw_property_doc", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_human_output_wraps_long_commands_with_indentation(): + """Wrapped describe command examples should keep continuation lines indented.""" + app = build_cli_app() + result = runner.invoke( + app, ["describe", "synergy.plot_manager.find_plot_by_name"], terminal_width=78 + ) + assert result.exit_code == 0 + assert any( + line.startswith(" dataset_name=") for line in result.stdout.splitlines() + ) + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_does_not_execute_class_descriptors_during_introspection(): + """list should be descriptor-safe and not trigger class-level side effects.""" + app = build_cli_app() + + class ExplosiveDescriptor: + """Descriptor used to ensure list introspection stays side-effect free.""" + + def __get__(self, obj, owner=None): + raise RuntimeError("descriptor should not execute during list") + + class CliExplosive: + """Class containing a descriptor that must not be executed by list.""" + + danger = ExplosiveDescriptor() + + def safe_method(self) -> str: + """Simple callable used to keep class publicly invokable.""" + return "ok" + + orig_cls = getattr(moldflow, "CliExplosive", None) + setattr(moldflow, "CliExplosive", CliExplosive) + try: + result = runner.invoke(app, ["list", "--json"]) + assert result.exit_code == 0 + finally: + if orig_cls is None: + delattr(moldflow, "CliExplosive") + else: + setattr(moldflow, "CliExplosive", orig_cls) + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_filter_regex_and_prefix(): + """list --filter supports partial and case-insensitive matches.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy"), patch("moldflow_cli.factories.get_synergy"): + r = runner.invoke(app, ["list", "--filter", "NEW_PROJ"]) + normalized = "".join(r.stdout.lower().split()) + assert r.exit_code == 0 + assert "new_project" in r.stdout.lower() + assert "synergy.new_project" not in r.stdout.lower() + assert "describeandinvokeaccepteitherform" in normalized + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_filter_supports_wildcard_patterns(): + """list --filter should treat * as a wildcard pattern, not as a literal character.""" + app = build_cli_app() + result = runner.invoke(app, ["list", "--filter", "*_diag"]) + assert result.exit_code == 0 + lowered = result.stdout.lower() + assert "cad_diagnostic" in lowered + assert "diagnosis_manager" in lowered + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_multiple_filters_are_additive(): + """Repeated list filters should match targets satisfying any provided filter.""" + app = build_cli_app() + result = runner.invoke(app, ["list", "--json", "--filter", "NEW_PROJ", "--filter", "*_diag"]) + assert result.exit_code == 0 + targets = {row["target"] for row in json.loads(result.stdout)} + assert "synergy.new_project" in targets + assert any(target.startswith("synergy.cad_diagnostic.") for target in targets) + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_human_output_shows_explicit_message_for_empty_filtered_results(): + """Filtered human list output should explain when no invokable targets match.""" + app = build_cli_app() + result = runner.invoke(app, ["list", "--filter", "animation_export_options.size_x"]) + assert result.exit_code == 0 + assert "No invokable targets matched this filter." in result.stdout + assert "rerun list --json for canonical target strings" in result.stdout + assert "Use the Target value with the action shown above" not in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_with_describe_requires_structured_output(): + """Expanded list metadata should stay opt-in for JSON/YAML flows only.""" + app = build_cli_app() + result = runner.invoke(app, ["--no-color", "list", "--with-describe"]) + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "--with-describe requires --json or --yaml" in combined + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_json_honors_max_results(): + """Structured list output should honor --max-results to cap discovery volume.""" + app = build_cli_app() + result = runner.invoke(app, ["list", "--json", "--max-results", "2"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert len(payload) == 2 + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_json_allows_zero_max_results(): + """Structured list output should allow a zero cap for callers that want no rows.""" + app = build_cli_app() + result = runner.invoke(app, ["list", "--json", "--max-results", "0"]) + assert result.exit_code == 0 + assert json.loads(result.stdout) == [] + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_rejects_negative_max_results(): + """List should reject negative result caps explicitly.""" + app = build_cli_app() + result = runner.invoke(app, ["--no-color", "list", "--json", "--max-results", "-1"]) + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "--max-results must be greater than or equal to 0" in combined + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_json_includes_invokable_synergy_property_targets(): + """List output should include rooted property targets that invoke supports.""" + app = build_cli_app() + orig_readonly = getattr(moldflow.Synergy, "cli_readonly_property", None) + orig_settable = getattr(moldflow.Synergy, "cli_settable_property", None) + + def _get_cli_readonly_property(self) -> str: + del self + return "2027" + + def _get_cli_settable_property(self) -> str: + del self + return "Fusion" + + def _set_cli_settable_property(self, value: str) -> None: + del self, value + + setattr(moldflow.Synergy, "cli_readonly_property", property(_get_cli_readonly_property)) + setattr( + moldflow.Synergy, + "cli_settable_property", + property(_get_cli_settable_property, _set_cli_settable_property), + ) + try: + result = runner.invoke(app, ["list", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + rows_by_target = {row["target"]: row for row in payload} + readonly = rows_by_target["synergy.cli_readonly_property"] + readwrite = rows_by_target["synergy.cli_settable_property"] + assert readonly["owner_class"] == "Synergy" + assert readonly["kind"] == "property" + assert readonly["suggested_command"] == "describe synergy.cli_readonly_property" + assert readonly["commands"] == { + "describe": "describe synergy.cli_readonly_property", + "invoke": "invoke synergy.cli_readonly_property", + } + assert readwrite["kind"] == "settable_property" + assert readwrite["suggested_command"] == "describe synergy.cli_settable_property" + assert readwrite["commands"] == { + "describe": "describe synergy.cli_settable_property", + "invoke": "invoke synergy.cli_settable_property", + } + finally: + if orig_readonly is None: + delattr(moldflow.Synergy, "cli_readonly_property") + else: + setattr(moldflow.Synergy, "cli_readonly_property", orig_readonly) + if orig_settable is None: + delattr(moldflow.Synergy, "cli_settable_property") + else: + setattr(moldflow.Synergy, "cli_settable_property", orig_settable) + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_json_roots_synergy_reachable_wrapper_targets(): + """List should emit Synergy-rooted targets when a wrapper is reachable from Synergy.""" + app = build_cli_app() + + class CLITestManager: + """Fixture wrapper reachable from Synergy through a property.""" + + def run(self) -> str: + """Return a sentinel value.""" + return "ok" + + orig_cls = getattr(moldflow, "CLITestManager", None) + orig_attr = getattr(moldflow.Synergy, "cli_test_manager", None) + setattr(moldflow, "CLITestManager", CLITestManager) + + def _get_cli_test_manager(self) -> CLITestManager: + del self + return CLITestManager() + + setattr(moldflow.Synergy, "cli_test_manager", property(_get_cli_test_manager)) + try: + result = runner.invoke(app, ["list", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + rows_by_target = {row["target"]: row for row in payload} + assert "synergy.cli_test_manager" not in rows_by_target + row = rows_by_target["synergy.cli_test_manager.run"] + assert row["owner_class"] == "CLITestManager" + assert row["suggested_command"] == "describe synergy.cli_test_manager.run" + assert row["commands"] == { + "describe": "describe synergy.cli_test_manager.run", + "invoke": "invoke synergy.cli_test_manager.run", + } + finally: + if orig_cls is None: + delattr(moldflow, "CLITestManager") + else: + setattr(moldflow, "CLITestManager", orig_cls) + if orig_attr is None: + delattr(moldflow.Synergy, "cli_test_manager") + else: + setattr(moldflow.Synergy, "cli_test_manager", orig_attr) + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_json_with_describe_embeds_structured_target_metadata(): + """List should optionally include describe-like structured payloads for each target.""" + app = build_cli_app() + result = runner.invoke(app, ["list", "--json", "--with-describe", "--filter", "open_project"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + rows_by_target = {row["target"]: row for row in payload} + row = rows_by_target["synergy.open_project"] + assert row["next_command"] == "describe synergy.open_project" + assert isinstance(row.get("describe"), dict) + assert row["describe"]["target"] == "synergy.open_project" + assert row["describe"]["next_command"] == "invoke synergy.open_project path=" + assert ( + row["describe"]["invoke_examples"]["cli_command"] + == "invoke synergy.open_project path=" + ) + assert row["describe"]["params_json_template"]["path"] is None + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_json_hides_builder_style_settable_properties_but_keeps_methods(): + """Builder-style wrappers should list their action methods, not each configuration knob.""" + app = build_cli_app() + + class CLITestGenerator: + """Builder-style wrapper with one action method and several settable properties.""" + + def generate(self) -> bool: + """Run the generator.""" + return True + + @property + def width(self) -> float: + """Get the configured width.""" + return 1.0 + + @width.setter + def width(self, value: float) -> None: + del value + + @property + def height(self) -> float: + """Get the configured height.""" + return 1.0 + + @height.setter + def height(self, value: float) -> None: + del value + + @property + def depth(self) -> float: + """Get the configured depth.""" + return 1.0 + + @depth.setter + def depth(self, value: float) -> None: + del value + + orig_cls = getattr(moldflow, "CLITestGenerator", None) + orig_attr = getattr(moldflow.Synergy, "cli_test_generator", None) + setattr(moldflow, "CLITestGenerator", CLITestGenerator) + + def _get_cli_test_generator(self) -> CLITestGenerator: + del self + return CLITestGenerator() + + setattr(moldflow.Synergy, "cli_test_generator", property(_get_cli_test_generator)) + try: + result = runner.invoke(app, ["list", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + rows_by_target = {row["target"]: row for row in payload} + assert "synergy.cli_test_generator.width" not in rows_by_target + assert "synergy.cli_test_generator.height" not in rows_by_target + assert "synergy.cli_test_generator.depth" not in rows_by_target + row = rows_by_target["synergy.cli_test_generator.generate"] + assert row["owner_class"] == "CLITestGenerator" + assert row["commands"] == { + "describe": "describe synergy.cli_test_generator.generate", + "invoke": "invoke synergy.cli_test_generator.generate", + } + finally: + if orig_cls is None: + delattr(moldflow, "CLITestGenerator") + else: + setattr(moldflow, "CLITestGenerator", orig_cls) + if orig_attr is None: + delattr(moldflow.Synergy, "cli_test_generator") + else: + setattr(moldflow.Synergy, "cli_test_generator", orig_attr) + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_json_hides_config_only_rooted_option_surfaces(): + """Config-only rooted wrappers should not flood list output with option properties.""" + app = build_cli_app() + + class CLITestOptions: + """Option-bag wrapper with only settable properties.""" + + @property + def width(self) -> float: + """Get the configured width.""" + return 1.0 + + @width.setter + def width(self, value: float) -> None: + del value + + @property + def height(self) -> float: + """Get the configured height.""" + return 1.0 + + @height.setter + def height(self, value: float) -> None: + del value + + @property + def depth(self) -> float: + """Get the configured depth.""" + return 1.0 + + @depth.setter + def depth(self, value: float) -> None: + del value + + orig_cls = getattr(moldflow, "CLITestOptions", None) + orig_attr = getattr(moldflow.Synergy, "cli_test_options", None) + setattr(moldflow, "CLITestOptions", CLITestOptions) + + def _get_cli_test_options(self) -> CLITestOptions: + del self + return CLITestOptions() + + setattr(moldflow.Synergy, "cli_test_options", property(_get_cli_test_options)) + try: + result = runner.invoke(app, ["list", "--json"]) + assert result.exit_code == 0 + rows_by_target = {row["target"]: row for row in json.loads(result.stdout)} + assert "synergy.cli_test_options.width" not in rows_by_target + assert "synergy.cli_test_options.height" not in rows_by_target + assert "synergy.cli_test_options.depth" not in rows_by_target + finally: + if orig_cls is None: + delattr(moldflow, "CLITestOptions") + else: + setattr(moldflow, "CLITestOptions", orig_cls) + if orig_attr is None: + delattr(moldflow.Synergy, "cli_test_options") + else: + setattr(moldflow.Synergy, "cli_test_options", orig_attr) + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_json_hides_synergy_create_factory_helper_surfaces(): + """Synergy create_* helper wrappers should not appear as top-level list targets.""" + app = build_cli_app() + result = runner.invoke(app, ["list", "--json"]) + assert result.exit_code == 0 + rows_by_target = {row["target"]: row for row in json.loads(result.stdout)} + assert "synergy.boundary_conditions.create_entity_list" not in rows_by_target + assert "synergy.create_double_array.add_double" not in rows_by_target + assert "synergy.create_double_array.from_list" not in rows_by_target + assert "synergy.create_double_array.size" not in rows_by_target + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_human_output_includes_type_and_describe_guidance(): + """Human list output should stay discovery-first and point people to describe.""" + app = build_cli_app() + orig_prop = getattr(moldflow.Synergy, "cli_settable_property", None) + + def _get_cli_settable_property(self) -> str: + del self + return "Fusion" + + def _set_cli_settable_property(self, value: str) -> None: + del self, value + + setattr( + moldflow.Synergy, + "cli_settable_property", + property(_get_cli_settable_property, _set_cli_settable_property), + ) + try: + result = runner.invoke( + app, ["list", "--filter", "cli_settable_property"], terminal_width=120 + ) + normalized = "".join(result.stdout.split()) + assert result.exit_code == 0 + assert "Type" in result.stdout + assert "Start" not in result.stdout + assert "cli_settable_property" in result.stdout + assert "Filtered matches:" in result.stdout + assert "Use describe to inspect parameters" in result.stdout + assert "cli_settable_property" in normalized + finally: + if orig_prop is None: + delattr(moldflow.Synergy, "cli_settable_property") + else: + setattr(moldflow.Synergy, "cli_settable_property", orig_prop) + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_human_output_preserves_readwrite_kind_under_narrow_widths(): + """Human list output should keep the read/write distinction visible on narrow tables.""" + app = build_cli_app() + orig_prop = getattr(moldflow.Synergy, "cli_settable_property_narrow", None) + + def _get_cli_settable_property_narrow(self) -> str: + del self + return "Fusion" + + def _set_cli_settable_property_narrow(self, value: str) -> None: + del self, value + + setattr( + moldflow.Synergy, + "cli_settable_property_narrow", + property(_get_cli_settable_property_narrow, _set_cli_settable_property_narrow), + ) + try: + result = runner.invoke( + app, ["list", "--filter", "cli_settable_property_narrow"], terminal_width=72 + ) + assert result.exit_code == 0 + assert "settable" in result.stdout + assert "rw-prop" not in result.stdout + assert "..." not in result.stdout + finally: + if orig_prop is None: + delattr(moldflow.Synergy, "cli_settable_property_narrow") + else: + setattr(moldflow.Synergy, "cli_settable_property_narrow", orig_prop) + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_human_output_avoids_large_filtered_match_duplication(): + """Broad filtered lists should avoid duplicating exact-match summaries for every row.""" + app = build_cli_app() + result = runner.invoke(app, ["list", "--filter", "plot_manager"]) + assert result.exit_code == 0 + assert "Filtered matches:" not in result.stdout + assert "Start" not in result.stdout + assert "Class" not in result.stdout + assert "Showing the compact table for" in result.stdout + assert "canonical target strings" in result.stdout + assert "describe" in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_list_human_output_wraps_long_targets_without_exact_target_loss(): + """Human list tables should keep long target text visible instead of ellipsizing it away.""" + app = build_cli_app() + orig_prop = getattr(moldflow.Synergy, "cli_extremely_descriptive_mesh_type_property", None) + + def _get_cli_extremely_descriptive_mesh_type_property(self) -> str: + del self + return "Fusion" + + setattr( + moldflow.Synergy, + "cli_extremely_descriptive_mesh_type_property", + property(_get_cli_extremely_descriptive_mesh_type_property), + ) + try: + result = runner.invoke( + app, ["list", "--filter", "extremely_descriptive_mesh_type_property"], terminal_width=72 + ) + normalized = "".join(result.stdout.split()) + assert result.exit_code == 0 + assert "cli_extremely_descriptive_mesh_type_property" in normalized + assert "..." not in result.stdout + finally: + if orig_prop is None: + delattr(moldflow.Synergy, "cli_extremely_descriptive_mesh_type_property") + else: + setattr(moldflow.Synergy, "cli_extremely_descriptive_mesh_type_property", orig_prop) + + +@pytest.mark.cli +@pytest.mark.unit +@pytest.mark.skipif(not _has_yaml(), reason="PyYAML not installed") +def test_list_yaml_output(): + """List should emit YAML when requested.""" + app = build_cli_app() + result = runner.invoke(app, ["list", "--yaml"]) + assert result.exit_code == 0 + assert "target" in result.stdout diff --git a/tests/api/unit_tests/test_cli_entrypoint.py b/tests/api/unit_tests/test_cli_entrypoint.py new file mode 100644 index 0000000..e7f1f75 --- /dev/null +++ b/tests/api/unit_tests/test_cli_entrypoint.py @@ -0,0 +1,80 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Focused tests for moldflow CLI entrypoint behavior.""" + +from __future__ import annotations + +from unittest.mock import patch +from unittest.mock import Mock +import importlib +import runpy +import sys + +import pytest + + +@pytest.mark.cli +@pytest.mark.unit +def test_cli_entrypoint_installed_and_callable(): + """Smoke test that the CLI builder is importable via the package entrypoint.""" + mod = importlib.import_module("moldflow_cli.__main__") + assert hasattr(mod, "build_cli_app") or hasattr(mod, "main") + + +@pytest.mark.cli +@pytest.mark.unit +def test_cli_main_only_converts_missing_cli_deps_to_user_message(): + """Missing typer/rich should print guidance; unrelated import errors should surface.""" + mod = importlib.import_module("moldflow_cli.__main__") + + def _import_missing_typer(name: str): + if name == "typer": + raise ModuleNotFoundError("No module named 'typer'") + return object() + + with patch("importlib.import_module", side_effect=_import_missing_typer): + with pytest.raises(SystemExit) as exc_info: + mod.main() + assert exc_info.value.code == 1 + + with patch( + "importlib.import_module", side_effect=ModuleNotFoundError("No module named 'otherpkg'") + ): + with pytest.raises(ModuleNotFoundError): + mod.main() + + +@pytest.mark.cli +@pytest.mark.unit +def test_cli_main_reconfigures_stdio_to_utf8_when_supported(): + """CLI entrypoint should best-effort configure stdout/stderr to UTF-8.""" + mod = importlib.import_module("moldflow_cli.__main__") + fake_stdout = Mock() + fake_stderr = Mock() + with patch("sys.stdout", fake_stdout), patch("sys.stderr", fake_stderr): + getattr(mod, "_ensure_utf8_stdio")() + + fake_stdout.reconfigure.assert_called_once_with(encoding="utf-8", errors="replace") + fake_stderr.reconfigure.assert_called_once_with(encoding="utf-8", errors="replace") + + +@pytest.mark.cli +@pytest.mark.unit +def test_cli_module_execution_invokes_main(): + """Executing python -m moldflow_cli should invoke main() and run the app.""" + app_called = {"count": 0} + + class _FakeApp: + def __call__(self): + app_called["count"] += 1 + + cached_main = sys.modules.pop("moldflow_cli.__main__", None) + try: + with patch("moldflow_cli.commands.build_cli_app", return_value=_FakeApp()): + runpy.run_module("moldflow_cli.__main__", run_name="__main__") + finally: + if cached_main is not None: + sys.modules["moldflow_cli.__main__"] = cached_main + + assert app_called["count"] == 1 diff --git a/tests/api/unit_tests/test_cli_factories.py b/tests/api/unit_tests/test_cli_factories.py new file mode 100644 index 0000000..102dc12 --- /dev/null +++ b/tests/api/unit_tests/test_cli_factories.py @@ -0,0 +1,574 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Focused regression tests for moldflow_cli.factories helpers.""" + +from __future__ import annotations + +from unittest.mock import patch +import typing + +import pytest +from moldflow.cli_input_metadata import cli_input_adapter + +from moldflow_cli.invoke_resolution import _resolve_return_class +from moldflow_cli.factories import configure_object_from_dict +from moldflow_cli.factories import _is_none_annotation +from moldflow_cli.factories import build_wrapper_instance +from moldflow_cli.factories import camel_to_snake +from moldflow_cli.factories import convert_value +from moldflow_cli.type_annotations import extract_non_none_annotation_type_names +from moldflow_cli.type_annotations import extract_non_none_type_names +from moldflow_cli.wrapper_input_adapters import build_wrapper_input_adapter_hints + + +@pytest.mark.cli +@pytest.mark.unit +def test_is_none_annotation_only_matches_nonetype(): + """Regression: broad types like object must not be treated as NoneType.""" + assert _is_none_annotation(type(None)) is True + assert _is_none_annotation(object) is False + assert _is_none_annotation(int) is False + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_nested_object_dict_conversion(): + """Test convert_value/configure_object_from_dict builds typed wrapper from dict shape.""" + + class FakeObj: + """Simple object used for typed conversion checks.""" + + def __init__(self) -> None: + self.example_field = None + + class Sy: + """Fake Synergy host exposing import_options property.""" + + import_options = FakeObj() + + with patch("moldflow_cli.factories.get_synergy", return_value=Sy()): + typed = {"__type__": "ImportOptions", "example_field": 123} + obj = convert_value(typed) + # Should be a wrapper instance with attribute set. + assert hasattr(obj, "example_field") + assert obj.example_field == 123 + + +@pytest.mark.cli +@pytest.mark.unit +def test_convert_value_rejects_non_public_typed_object_fields(): + """Typed-object conversion must reject non-public fields from JSON payloads.""" + + class FakeObj: + """Simple object used for typed conversion checks.""" + + class Sy: + """Fake Synergy host exposing import_options property.""" + + import_options = FakeObj() + + with patch("moldflow_cli.factories.get_synergy", return_value=Sy()): + with pytest.raises(ValueError, match="Non-public field '_private' is not allowed"): + convert_value({"__type__": "ImportOptions", "_private": 1}) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_build_wrapper_instance_failure_message(): + """build_wrapper_instance should raise a ValueError mentioning the unknown type.""" + + with patch("moldflow_cli.factories.get_synergy", return_value=object()): + with pytest.raises(ValueError) as exc: + build_wrapper_instance("NoSuchType") + assert "NoSuchType" in str(exc.value) + + +@pytest.mark.cli +@pytest.mark.unit +def test_camel_to_snake_handles_acronym_boundaries(): + """Acronym-heavy class names should map to stable snake_case CLI names.""" + + assert camel_to_snake("CADManager") == "cad_manager" + assert camel_to_snake("XMLParser") == "xml_parser" + assert camel_to_snake("ImportOptions") == "import_options" + + +@pytest.mark.cli +@pytest.mark.unit +def test_build_wrapper_instance_handles_union_forward_ref(): + """build_wrapper_instance handles union-like forward-ref annotation strings.""" + + class FakeImportOptions: + """Dummy ImportOptions-like object for wrapper construction tests.""" + + def __init__(self) -> None: + self.example = "ok" + + class Sy: + """Fake Synergy host exposing import_options property.""" + + import_options = FakeImportOptions() + + with patch("moldflow_cli.factories.get_synergy", return_value=Sy()): + inst = build_wrapper_instance("ImportOptions | None") + assert isinstance(inst, FakeImportOptions) + inst2 = build_wrapper_instance("'ImportOptions | None'") + assert isinstance(inst2, FakeImportOptions) + inst3 = build_wrapper_instance("ImportOptions|None") + assert isinstance(inst3, FakeImportOptions) + inst4 = build_wrapper_instance("Optional[ImportOptions]") + assert isinstance(inst4, FakeImportOptions) + inst5 = build_wrapper_instance("typing.Optional[ImportOptions]") + assert isinstance(inst5, FakeImportOptions) + inst6 = build_wrapper_instance(typing.Optional["ImportOptions"]) + assert isinstance(inst6, FakeImportOptions) + inst7 = build_wrapper_instance(typing.Annotated[typing.Optional["ImportOptions"], "meta"]) + assert isinstance(inst7, FakeImportOptions) + + +@pytest.mark.cli +@pytest.mark.unit +def test_build_wrapper_instance_entlist_scans_synergy_related_providers(): + """EntList resolution should discover any related provider exposing create_entity_list.""" + + class FakeEntList: + """Dummy EntList-like object for provider discovery tests.""" + + class PropertyEditorProvider: + """Fake provider reachable through a Synergy property.""" + + def create_entity_list(self) -> FakeEntList: + """Return an entity-list instance through the discovered provider API.""" + + return FakeEntList() + + class Sy: + """Fake Synergy host exposing only property_editor as an EntList-capable provider.""" + + @property + def property_editor(self) -> PropertyEditorProvider: + """Expose a provider that can create entity lists.""" + + return PropertyEditorProvider() + + with patch("moldflow_cli.factories.get_synergy", return_value=Sy()): + assert isinstance(build_wrapper_instance("EntList"), FakeEntList) + + +@pytest.mark.cli +@pytest.mark.unit +def test_build_wrapper_instance_derives_synergy_factory_names(): + """Known array/vector wrappers should resolve through derived Synergy factory names.""" + + class FakeVector: + """Dummy vector wrapper for factory-resolution tests.""" + + class FakeVectorArray: + """Dummy vector-array wrapper for factory-resolution tests.""" + + class Sy: + """Fake Synergy host exposing factory methods that follow the naming convention.""" + + def create_vector(self) -> FakeVector: + """Return a vector wrapper instance using the conventional factory name.""" + + return FakeVector() + + def create_vector_array(self) -> FakeVectorArray: + """Return a vector-array wrapper instance using the conventional factory name.""" + + return FakeVectorArray() + + with patch("moldflow_cli.factories.get_synergy", return_value=Sy()): + assert isinstance(build_wrapper_instance("Vector"), FakeVector) + assert isinstance(build_wrapper_instance("VectorArray"), FakeVectorArray) + + +@pytest.mark.cli +@pytest.mark.unit +def test_configure_object_from_dict_selection_adapter_uses_string_input(): + """Selection-like wrapper fields should route through select_from_string without setattr.""" + + class FakeSelectionWrapper: + """Dummy wrapper exposing select_from_string for adapter tests.""" + + def __init__(self) -> None: + """Initialize selection state for the test wrapper.""" + + self.selected = None + + @cli_input_adapter(value_kind="selection_text", shorthand_supported=True) + def select_from_string(self, entity_string: str) -> None: + """Capture the reflected selection string value.""" + + self.selected = entity_string + + obj = FakeSelectionWrapper() + configure_object_from_dict(obj, {"entity_string": "N1,N2"}) + assert obj.selected == "N1,N2" + + +@pytest.mark.cli +@pytest.mark.unit +def test_configure_object_from_dict_selection_adapter_requires_string(): + """Selection-like JSON fields must remain strict strings.""" + + class FakeSelectionWrapper: + """Dummy wrapper exposing select_from_string for adapter tests.""" + + @cli_input_adapter(value_kind="selection_text", shorthand_supported=True) + def select_from_string(self, entity_string: str) -> None: # pragma: no cover + """Fail fast if the adapter accepts a non-string selection payload.""" + + raise AssertionError("select_from_string should not be called") + + with pytest.raises(ValueError, match="must be a string selection expression"): + configure_object_from_dict(FakeSelectionWrapper(), {"entity_string": 42}) + + +@pytest.mark.cli +@pytest.mark.unit +def test_configure_object_from_dict_selection_adapter_uses_reflected_param_name(): + """Selection-like wrappers should use the reflected parameter name as the JSON field.""" + + class FakeSelectionWrapper: + """Dummy wrapper exposing select_from_string for adapter tests.""" + + def __init__(self) -> None: + """Initialize selection state for the test wrapper.""" + + self.selected = None + + @cli_input_adapter(value_kind="selection_text", shorthand_supported=True) + def select_from_string(self, value: str) -> None: + """Capture the reflected parameter name chosen by adapter discovery.""" + + self.selected = value + + obj = FakeSelectionWrapper() + configure_object_from_dict(obj, {"value": "N1"}) + assert obj.selected == "N1" + + +@pytest.mark.cli +@pytest.mark.unit +def test_configure_object_from_dict_vector_adapter_uses_xyz_triplet(): + """Vector-like wrappers should accept the canonical xyz adapter field.""" + + class FakeVector: + """Dummy vector wrapper exposing a vector-triplet adapter method.""" + + def __init__(self) -> None: + """Initialize vector state for the test wrapper.""" + + self.value = None + + @cli_input_adapter( + value_kind="vector_triplet", preferred_field="xyz", shorthand_supported=True + ) + def set_xyz(self, x: float, y: float, z: float) -> None: + """Store the adapted xyz triplet as floats.""" + + self.value = (x, y, z) + + obj = FakeVector() + configure_object_from_dict(obj, {"xyz": [1, 2, 3]}) + assert obj.value == (1.0, 2.0, 3.0) + + +@pytest.mark.cli +@pytest.mark.unit +def test_configure_object_from_dict_list_values_adapter_uses_from_list(): + """List-backed wrappers should accept the canonical values adapter field.""" + + class FakeDoubleArray: + """Dummy list-backed wrapper exposing a values adapter method.""" + + def __init__(self) -> None: + """Initialize list state for the test wrapper.""" + + self.values = None + + @cli_input_adapter(value_kind="list_values") + def from_list(self, values: list[float]) -> None: + """Store the adapted list payload.""" + + self.values = list(values) + + obj = FakeDoubleArray() + configure_object_from_dict(obj, {"values": [1.0, 2.5]}) + assert obj.values == [1.0, 2.5] + + +@pytest.mark.cli +@pytest.mark.unit +def test_configure_object_from_dict_accepts_matching_type_alias_metadata(): + """Wrapper JSON may include a matching plain type field without changing the inferred target.""" + + class FakeDoubleArray: + """Dummy list-backed wrapper exposing a values adapter method.""" + + def __init__(self) -> None: + """Initialize list state for the test wrapper.""" + + self.values = None + + @cli_input_adapter(value_kind="list_values") + def from_list(self, values: list[float]) -> None: + """Store the adapted list payload.""" + + self.values = list(values) + + obj = FakeDoubleArray() + configure_object_from_dict(obj, {"type": "FakeDoubleArray", "values": [1.0, 2.5]}) + assert obj.values == [1.0, 2.5] + + +@pytest.mark.cli +@pytest.mark.unit +def test_configure_object_from_dict_rejects_mismatched_type_alias_metadata(): + """Wrapper JSON should fail fast on mismatched explicit type aliases.""" + + class FakeDoubleArray: + """Dummy list-backed wrapper exposing a values adapter method.""" + + @cli_input_adapter(value_kind="list_values") + def from_list(self, values: list[float]) -> None: + """Accept list values for the wrapper.""" + + with pytest.raises(ValueError, match="does not match expected wrapper 'FakeDoubleArray'"): + configure_object_from_dict(FakeDoubleArray(), {"type": "Vector", "values": [1.0, 2.5]}) + + +@pytest.mark.cli +@pytest.mark.unit +def test_configure_object_from_dict_reuses_existing_nested_wrapper_context(): + """Nested wrapper-valued attributes should accept untagged JSON. + + An existing wrapper instance provides enough context to avoid a nested ``__type__`` tag. + """ + + class FakeVector: + """Dummy vector wrapper exposing a vector-triplet adapter method.""" + + def __init__(self) -> None: + """Initialize vector state for the nested wrapper.""" + + self.xyz = None + + @cli_input_adapter( + value_kind="vector_triplet", preferred_field="xyz", shorthand_supported=True + ) + def set_xyz(self, x: float, y: float, z: float) -> None: + """Store the adapted xyz triplet.""" + + self.xyz = (x, y, z) + + class FakeOuter: + """Dummy wrapper exposing an already-instantiated nested vector object.""" + + def __init__(self) -> None: + """Initialize nested wrapper state.""" + + self.direction = FakeVector() + + obj = FakeOuter() + configure_object_from_dict(obj, {"direction": {"xyz": [1, 2, 3]}}) + assert obj.direction.xyz == (1.0, 2.0, 3.0) + + +@pytest.mark.cli +@pytest.mark.unit +def test_configure_object_from_dict_invalid_adapter_field_reports_guidance(): + """Unknown wrapper JSON fields should point users to the canonical adapter field.""" + + class FakeSelectionWrapper: + """Dummy selection wrapper exposing a canonical selection field.""" + + @cli_input_adapter(value_kind="selection_text", shorthand_supported=True) + def select_from_string(self, entity_string: str) -> None: + """Accept a canonical selection string.""" + + with pytest.raises(ValueError, match="entity_string") as exc_info: + configure_object_from_dict(FakeSelectionWrapper(), {"value": "N1,N2"}) + + assert "N1,N2" in str(exc_info.value) + + +@pytest.mark.cli +@pytest.mark.unit +def test_build_wrapper_input_adapter_hints_for_vector_prefers_xyz_triplet(): + """Template hints for vectors should expose canonical xyz input and shorthand.""" + + hints = build_wrapper_input_adapter_hints("Vector") + assert isinstance(hints, dict) + friendly_json_input = hints.get("friendly_json_input") + assert isinstance(friendly_json_input, dict) + non_json_input = hints.get("non_json_input") + assert isinstance(non_json_input, dict) + examples = hints.get("examples") + assert isinstance(examples, dict) + assert friendly_json_input.get("preferred_field") == "xyz" + assert non_json_input.get("preferred_syntax") == "=0,0,1" + assert examples.get("preferred_non_json") == "=0,0,1" + + +@pytest.mark.cli +@pytest.mark.unit +def test_build_wrapper_input_adapter_hints_for_double_array_prefers_values(): + """Template hints for list-backed wrappers should expose canonical values input.""" + + hints = build_wrapper_input_adapter_hints("DoubleArray") + assert isinstance(hints, dict) + friendly_json_input = hints.get("friendly_json_input") + assert isinstance(friendly_json_input, dict) + typed_json_shape = hints.get("typed_json_shape") + assert isinstance(typed_json_shape, dict) + examples = hints.get("examples") + assert isinstance(examples, dict) + preferred_param_value = examples.get("preferred_param_value") + assert isinstance(preferred_param_value, dict) + non_json_input = hints.get("non_json_input") + assert isinstance(non_json_input, dict) + assert friendly_json_input.get("preferred_field") == "values" + assert typed_json_shape.get("__type__") == "DoubleArray" + assert preferred_param_value.get("values") + assert non_json_input.get("preferred_syntax") == "=1.0,2.5" + assert examples.get("preferred_non_json") == "=1.0,2.5" + + +@pytest.mark.cli +@pytest.mark.unit +def test_build_wrapper_input_adapter_hints_normalizes_alias_to_canonical_type_name(): + """Alias-based hint lookups should still emit the canonical exported wrapper type.""" + + hints = build_wrapper_input_adapter_hints("double_array") + assert isinstance(hints, dict) + typed_json_shape = hints.get("typed_json_shape") + assert isinstance(typed_json_shape, dict) + assert typed_json_shape.get("__type__") == "DoubleArray" + + +@pytest.mark.cli +@pytest.mark.unit +def test_build_wrapper_input_adapter_hints_for_vector_array_prefers_triplet_series(): + """Template hints for vector arrays should expose canonical series input and shorthand.""" + + hints = build_wrapper_input_adapter_hints("VectorArray") + assert isinstance(hints, dict) + friendly_json_input = hints.get("friendly_json_input") + assert isinstance(friendly_json_input, dict) + non_json_input = hints.get("non_json_input") + assert isinstance(non_json_input, dict) + examples = hints.get("examples") + assert isinstance(examples, dict) + assert friendly_json_input.get("preferred_field") == "xyz" + assert non_json_input.get("preferred_syntax") == "=0,0,0;1,0,0" + assert examples.get("preferred_non_json") == "=0,0,0;1,0,0" + + +@pytest.mark.cli +@pytest.mark.unit +def test_resolve_return_class_string_union_is_ignored_when_ambiguous(): + """String Union with multiple concrete wrappers should not infer a next class.""" + + class Plot: + """Dummy wrapper class for resolve_return_class tests.""" + + class StudyDoc: + """Dummy wrapper class for resolve_return_class tests.""" + + cli_to_class = {"plot": Plot, "study_doc": StudyDoc} + assert _resolve_return_class("Union[Plot, StudyDoc, None]", cli_to_class) is None + + +@pytest.mark.cli +@pytest.mark.unit +def test_resolve_return_class_nested_generic_optional_does_not_infer_wrapper(): + """Nested generic return refs should not infer a direct chained wrapper class.""" + + class Vector: + """Dummy wrapper class for resolve_return_class tests.""" + + cli_to_class = {"vector": Vector} + assert _resolve_return_class("Optional[list[Vector]]", cli_to_class) is None + + +@pytest.mark.cli +@pytest.mark.unit +def test_resolve_return_class_string_fully_qualified_name_pipe_optional(): + """Fully qualified wrapper names should resolve by leaf class name.""" + + class Plot: + """Dummy wrapper class for resolve_return_class tests.""" + + cli_to_class = {"plot": Plot} + assert _resolve_return_class("moldflow.plot.Plot | None", cli_to_class) is Plot + + +@pytest.mark.cli +@pytest.mark.unit +def test_resolve_return_class_deduplicates_aliases_for_same_class(): + """Multiple CLI aliases for one class should still resolve unambiguously.""" + + class Plot: + """Dummy wrapper class for resolve_return_class alias tests.""" + + cli_to_class = {"plot": Plot, "plot_alias": Plot} + assert _resolve_return_class("Optional[Plot]", cli_to_class) is Plot + + +@pytest.mark.cli +@pytest.mark.unit +def test_resolve_return_class_accepts_snake_case_alias_annotation(): + """Snake_case alias annotations should resolve to the underlying wrapper class.""" + + class DoubleArray: + """Dummy wrapper class for resolve_return_class snake_case alias tests.""" + + cli_to_class = {"doublearray": DoubleArray} + assert _resolve_return_class("double_array | None", cli_to_class) is DoubleArray + + +@pytest.mark.cli +@pytest.mark.unit +def test_resolve_return_class_annotated_string_type(): + """Annotated return type strings should resolve to the wrapped class.""" + + class Plot: + """Dummy wrapper class for annotated return resolution tests.""" + + cli_to_class = {"plot": Plot} + annotation = typing.Annotated["Plot", "meta"] + assert _resolve_return_class(annotation, cli_to_class) is Plot + + +@pytest.mark.cli +@pytest.mark.unit +def test_extract_non_none_annotation_type_names_handles_annotated_string(): + """String Annotated[T, ...] should resolve to T for chain inference.""" + assert extract_non_none_annotation_type_names('Annotated["Plot", "meta"]') == ["Plot"] + + +@pytest.mark.cli +@pytest.mark.unit +def test_extract_non_none_type_names_handles_runtime_annotations(): + """Runtime annotation objects should resolve through the shared helper.""" + assert extract_non_none_type_names(typing.Optional["Plot"]) == ["Plot"] + annotation = typing.Annotated[typing.Optional["Plot"], "meta"] + assert extract_non_none_type_names(annotation) == ["Plot"] + assert extract_non_none_type_names(typing.Optional[list["Plot"]]) == [] + + +@pytest.mark.cli +@pytest.mark.unit +def test_annotation_string_malformed_input_gracefully_falls_back(): + """Malformed annotation strings should not raise and should resolve to no class.""" + + class Plot: + """Dummy wrapper class for malformed annotation tests.""" + + names = extract_non_none_annotation_type_names("Optional[") + assert isinstance(names, list) + assert _resolve_return_class("Optional[", {"plot": Plot}) is None diff --git a/tests/api/unit_tests/test_cli_invoke_execute.py b/tests/api/unit_tests/test_cli_invoke_execute.py new file mode 100644 index 0000000..65798a1 --- /dev/null +++ b/tests/api/unit_tests/test_cli_invoke_execute.py @@ -0,0 +1,2847 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Focused invoke execution-path tests for moldflow CLI.""" + +# Test modules intentionally use many tiny inline doubles to mirror CLI call patterns. +# pylint: disable=missing-function-docstring,missing-class-docstring,unused-argument,too-many-lines,too-few-public-methods + +from __future__ import annotations + +from unittest.mock import patch +import typing +import json + +import pytest +from typer.testing import CliRunner +import moldflow + +from moldflow_cli.commands import build_cli_app + +runner = CliRunner() + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_property_targets_return_values(): + """Property-only targets should be readable via invoke.""" + app = build_cli_app() + + class Sy: + @property + def version(self) -> str: + return "2027" + + @property + def edition(self) -> str: + return "Insight" + + fake = Sy() + with patch("moldflow_cli.context.get_synergy", return_value=fake), patch( + "moldflow_cli.factories.get_synergy", return_value=fake + ): + version_result = runner.invoke(app, ["invoke", "synergy.version"]) + edition_result = runner.invoke(app, ["invoke", "synergy.edition"]) + + assert version_result.exit_code == 0 + assert edition_result.exit_code == 0 + assert "2027" in version_result.stdout + assert "Insight" in edition_result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_property_target_rejects_arguments(): + """Read-only property targets should reject parameter input.""" + app = build_cli_app() + + class Sy: + @property + def version(self) -> str: + return "2027" + + fake = Sy() + with patch("moldflow_cli.context.get_synergy", return_value=fake), patch( + "moldflow_cli.factories.get_synergy", return_value=fake + ): + result = runner.invoke(app, ["invoke", "synergy.version", "x=1"]) + + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "does not accept arguments" in combined.lower() + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_wrapper_property_target_is_rejected_as_terminal_read(): + """Wrapper-handle properties should require a chained member target instead of direct invoke.""" + app = build_cli_app() + + class CLITestManager: + """Wrapper reachable from a Synergy property.""" + + orig_cls = getattr(moldflow, "CLITestManager", None) + orig_attr = getattr(moldflow.Synergy, "cli_test_manager", None) + + def _get_cli_test_manager(self) -> CLITestManager: + del self + return CLITestManager() + + setattr(moldflow, "CLITestManager", CLITestManager) + setattr(moldflow.Synergy, "cli_test_manager", property(_get_cli_test_manager)) + try: + result = runner.invoke(app, ["--no-color", "invoke", "cli_test_manager"]) + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + lowered = combined.lower() + assert "wrapper property" in lowered + assert "describe synergy.cli_test_manager." in combined + finally: + if orig_cls is None: + delattr(moldflow, "CLITestManager") + else: + setattr(moldflow, "CLITestManager", orig_cls) + if orig_attr is None: + delattr(moldflow.Synergy, "cli_test_manager") + else: + setattr(moldflow.Synergy, "cli_test_manager", orig_attr) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_property_target_accepts_assignment_when_settable(): + """Settable properties should accept `value=...` assignment via invoke.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "mesh_type", None) + + setattr( + moldflow.Synergy, + "mesh_type", + property( + lambda self: getattr(self, "_mesh_type", "Fusion"), + lambda self, value: setattr(self, "_mesh_type", value), + ), + ) + + class Sy: + def __init__(self) -> None: + self._mesh_type = "Fusion" + + @property + def mesh_type(self) -> str: + return self._mesh_type + + @mesh_type.setter + def mesh_type(self, value: str) -> None: + self._mesh_type = value + + try: + fake = Sy() + with patch("moldflow_cli.context.get_synergy", return_value=fake), patch( + "moldflow_cli.factories.get_synergy", return_value=fake + ): + result = runner.invoke( + app, ["invoke", "synergy.mesh_type", "value=3D", "--json-output"] + ) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["ok"] is True + assert payload["result"] == "3D" + assert fake.mesh_type == "3D" + finally: + if orig is None: + delattr(moldflow.Synergy, "mesh_type") + else: + setattr(moldflow.Synergy, "mesh_type", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_write_only_property_target_is_rejected(): + """Setter-only properties should fail fast with a clear read-vs-write message.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "secret", None) + + class Sy: + def __init__(self) -> None: + self._secret: str | None = None + + def _set_secret(self, value: str) -> None: + self._secret = value + + secret = property(fset=_set_secret) + + setattr(moldflow.Synergy, "secret", property(fset=lambda self, value: None)) + + fake = Sy() + try: + with patch("moldflow_cli.context.get_synergy", return_value=fake), patch( + "moldflow_cli.factories.get_synergy", return_value=fake + ): + result = runner.invoke(app, ["invoke", "synergy.secret"]) + + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "write-only property" in combined.lower() + finally: + if orig is None: + delattr(moldflow.Synergy, "secret") + else: + setattr(moldflow.Synergy, "secret", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_property_assignment_dry_run_does_not_execute_setter(): + """Dry-run on a property assignment target should emit a plan without mutating the object.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "mesh_type", None) + + class Sy: + def __init__(self) -> None: + self._mesh_type = "Fusion" + self.setter_calls = 0 + + @property + def mesh_type(self) -> str: + return self._mesh_type + + @mesh_type.setter + def mesh_type(self, value: str) -> None: + self.setter_calls += 1 + self._mesh_type = value + + setattr(moldflow.Synergy, "mesh_type", Sy.mesh_type) + fake = Sy() + try: + with patch("moldflow_cli.context.get_synergy", return_value=fake), patch( + "moldflow_cli.factories.get_synergy", return_value=fake + ): + result = runner.invoke( + app, ["invoke", "synergy.mesh_type", "value=3D", "--dry-run", "--json-output"] + ) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["mode"] == "dry_run" + assert payload["terminal_target"]["assignment"] is True + assert payload["assignment"]["value"] == "3D" + assert fake.mesh_type == "Fusion" + assert fake.setter_calls == 0 + finally: + if orig is None: + delattr(moldflow.Synergy, "mesh_type") + else: + setattr(moldflow.Synergy, "mesh_type", orig) + + +class FakeVector: + """Lightweight stand-in for the Vector wrapper used in CLI tests.""" + + def __init__(self) -> None: + self.x: float | int | None = None + self.y: float | int | None = None + self.z: float | int | None = None + + def __repr__(self) -> str: + return f"FakeVector(x={self.x}, y={self.y}, z={self.z})" + + +class FakePlot: + """Fake plot object exposing the probe-line API used by the CLI tests.""" + + def get_probe_plot_probe_line( + self, index: int, start_pt: "Vector | None", end_pt: "Vector | None" + ) -> str: + """Return a simple sentinel that encodes arguments for assertion.""" + return f"probe_line(index={index}, start={start_pt}, end={end_pt})" + + +class FakePlotManager: + """Fake plot manager that returns a FakePlot regardless of input.""" + + def find_plot_by_name( + self, plot_name: str, dataset_name: str | None = None # pylint: disable=unused-argument + ) -> FakePlot: + """Return a FakePlot regardless of input.""" + return FakePlot() + + +class FakeSynergy: + """Fake Synergy root exposing a plot_manager and create_vector factory.""" + + @property + def plot_manager(self) -> FakePlotManager: + """Return a FakePlotManager.""" + return FakePlotManager() + + # Factory methods used by build_wrapper_instance for Vector | None. + def create_vector(self) -> FakeVector: + """Return a FakeVector.""" + return FakeVector() + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_chained_call_with_vectors_uses_factories_and_synergy_chain(): + """Verify chained invoke uses Synergy factories and object graph as expected.""" + app = build_cli_app() + + fake = FakeSynergy() + with patch("moldflow_cli.context.get_synergy", return_value=fake) as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy", return_value=fake + ) as mock_fact_synergy: + result = runner.invoke( + app, + [ + "invoke", + "synergy.plot_manager.find_plot_by_name.get_probe_plot_probe_line", + "find_plot_by_name.plot_name=My Plot", + "get_probe_plot_probe_line.index=0", + "get_probe_plot_probe_line.start_pt.x=1.0", + "get_probe_plot_probe_line.start_pt.y=2.0", + "get_probe_plot_probe_line.start_pt.z=3.0", + "get_probe_plot_probe_line.end_pt.x=4.0", + "get_probe_plot_probe_line.end_pt.y=5.0", + "get_probe_plot_probe_line.end_pt.z=6.0", + ], + ) + + assert result.exit_code == 0 + # Synergy should be instantiated via the mocked factories/context only. + assert mock_ctx_synergy.call_count >= 1 or mock_fact_synergy.call_count >= 1 + # Our fake implementation returns a simple string sentinel; ensure it appears. + assert "probe_line(index=0" in result.stdout + # And that our fake vectors have the expected coordinates in their repr. + assert "FakeVector(x=1.0" in result.stdout + assert "FakeVector(x=4.0" in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_chained_call_accepts_grouped_params_json(): + """Chained invoke should accept grouped --params-json payloads keyed by step name.""" + app = build_cli_app() + + fake = FakeSynergy() + with patch("moldflow_cli.context.get_synergy", return_value=fake) as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy", return_value=fake + ) as mock_fact_synergy: + result = runner.invoke( + app, + [ + "invoke", + "synergy.plot_manager.find_plot_by_name.get_probe_plot_probe_line", + "--params-json", + '{"find_plot_by_name":{"plot_name":"My Plot"},' + '"get_probe_plot_probe_line":{"index":0,"start_pt":{"x":1.0,"y":2.0,"z":3.0},' + '"end_pt":{"x":4.0,"y":5.0,"z":6.0}}}', + ], + ) + + assert result.exit_code == 0 + assert mock_ctx_synergy.call_count >= 1 or mock_fact_synergy.call_count >= 1 + assert "probe_line(index=0" in result.stdout + assert "FakeVector(x=1.0" in result.stdout + assert "FakeVector(x=4.0" in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_single_step_param_routing_equivalence(): + """Single-step invoke accepts both param=value and method.param=value forms.""" + app = build_cli_app() + + class FakeWin: + def __init__(self) -> None: + self.calls = [] + + def set_application_window_pos(self, x: int, y: int, size_x: int, size_y: int) -> bool: + self.calls.append((x, y, size_x, size_y)) + return True + + fake = FakeWin() + with patch("moldflow_cli.context.get_synergy", return_value=fake), patch( + "moldflow_cli.factories.get_synergy", return_value=fake + ): + result_plain = runner.invoke( + app, + [ + "invoke", + "synergy.set_application_window_pos", + "x=7", + "y=8", + "size_x=70", + "size_y=80", + ], + ) + result_prefixed = runner.invoke( + app, + [ + "invoke", + "synergy.set_application_window_pos", + "set_application_window_pos.x=7", + "set_application_window_pos.y=8", + "set_application_window_pos.size_x=70", + "set_application_window_pos.size_y=80", + ], + ) + + assert result_plain.exit_code == 0 + assert result_prefixed.exit_code == 0 + assert fake.calls == [(7, 8, 70, 80), (7, 8, 70, 80)] + + +@pytest.mark.cli +@pytest.mark.unit +def test_concurrent_invokes_do_not_share_state(): + """Two separate invoke calls that construct transient objects should not share state.""" + app = build_cli_app() + + class Maker: + def make_obj(self): + class O: + pass + + return O() + + class Sy: + @property + def maker(self): + return Maker() + + # Define the method on the Sy object returned by get_synergy so resolution matches. + class SyWithFactory(Sy): + def cli_make_obj(self): + return object() + + # Point moldflow.Synergy to our test class so introspection and runtime agree. + orig_mf_synergy = getattr(moldflow, "Synergy", None) + setattr(moldflow, "Synergy", SyWithFactory) + try: + with patch("moldflow_cli.context.get_synergy", return_value=SyWithFactory()), patch( + "moldflow_cli.factories.get_synergy", return_value=SyWithFactory() + ): + r1 = runner.invoke(app, ["invoke", "synergy.cli_make_obj"]) + r2 = runner.invoke(app, ["invoke", "synergy.cli_make_obj"]) + + assert r1.exit_code == 0 + assert r2.exit_code == 0 + # The reprs should not be identical (fresh instances). + assert r1.stdout != r2.stdout + finally: + if orig_mf_synergy is None: + delattr(moldflow, "Synergy") + else: + setattr(moldflow, "Synergy", orig_mf_synergy) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_chained_call_with_optional_return_forward_ref(): + """Chained invoke should resolve next-step class from Optional[Class] return refs.""" + app = build_cli_app() + + class Plot: + def then_method(self) -> str: + return "optional-chain-ok" + + class Sy: + def get_plot_optional(self): + return Plot() + + orig_plot = getattr(moldflow, "Plot", None) + orig_get_plot_optional = getattr(moldflow.Synergy, "get_plot_optional", None) + + def _get_plot_optional(self): + return Plot() + + _get_plot_optional.__annotations__ = {"return": "Optional[Plot]"} + + setattr(moldflow, "Plot", Plot) + setattr(moldflow.Synergy, "get_plot_optional", _get_plot_optional) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.get_plot_optional.then_method"]) + assert result.exit_code == 0 + assert "optional-chain-ok" in result.stdout + finally: + if orig_plot is None: + delattr(moldflow, "Plot") + else: + setattr(moldflow, "Plot", orig_plot) + if orig_get_plot_optional is None: + delattr(moldflow.Synergy, "get_plot_optional") + else: + setattr(moldflow.Synergy, "get_plot_optional", orig_get_plot_optional) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_chained_call_with_optional_forwardref_object_return_annotation(): + """Chained invoke should resolve Optional[ForwardRef[Class]] return annotations.""" + app = build_cli_app() + + class PlotForward: + def then_method(self) -> str: + return "forwardref-chain-ok" + + class Sy: + def get_plot_forward_optional(self): + return PlotForward() + + orig_plot = getattr(moldflow, "PlotForward", None) + orig_getter = getattr(moldflow.Synergy, "get_plot_forward_optional", None) + + def _get_plot_forward_optional(self): + return PlotForward() + + _get_plot_forward_optional.__annotations__ = { + "return": typing.Optional[typing.ForwardRef("PlotForward")] + } + + setattr(moldflow, "PlotForward", PlotForward) + setattr(moldflow.Synergy, "get_plot_forward_optional", _get_plot_forward_optional) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.get_plot_forward_optional.then_method"]) + assert result.exit_code == 0 + assert "forwardref-chain-ok" in result.stdout + finally: + if orig_plot is None: + delattr(moldflow, "PlotForward") + else: + setattr(moldflow, "PlotForward", orig_plot) + if orig_getter is None: + delattr(moldflow.Synergy, "get_plot_forward_optional") + else: + setattr(moldflow.Synergy, "get_plot_forward_optional", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_chained_call_with_annotated_return_annotation(): + """Chained invoke should resolve next-step class from Annotated return refs.""" + app = build_cli_app() + + class PlotAnnotated: + def then_method(self) -> str: + return "annotated-chain-ok" + + class Sy: + def get_plot_annotated(self): + return PlotAnnotated() + + orig_plot = getattr(moldflow, "PlotAnnotated", None) + orig_getter = getattr(moldflow.Synergy, "get_plot_annotated", None) + + def _get_plot_annotated(self): + return PlotAnnotated() + + _get_plot_annotated.__annotations__ = {"return": typing.Annotated["PlotAnnotated", "meta"]} + + setattr(moldflow, "PlotAnnotated", PlotAnnotated) + setattr(moldflow.Synergy, "get_plot_annotated", _get_plot_annotated) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.get_plot_annotated.then_method"]) + assert result.exit_code == 0 + assert "annotated-chain-ok" in result.stdout + finally: + if orig_plot is None: + delattr(moldflow, "PlotAnnotated") + else: + setattr(moldflow, "PlotAnnotated", orig_plot) + if orig_getter is None: + delattr(moldflow.Synergy, "get_plot_annotated") + else: + setattr(moldflow.Synergy, "get_plot_annotated", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_chained_call_with_annotated_string_return_annotation(): + """Chained invoke should resolve string Annotated return refs.""" + app = build_cli_app() + + class PlotAnnotatedStr: + def then_method(self) -> str: + return "annotated-string-chain-ok" + + class Sy: + def get_plot_annotated_str(self): + return PlotAnnotatedStr() + + orig_plot = getattr(moldflow, "PlotAnnotatedStr", None) + orig_getter = getattr(moldflow.Synergy, "get_plot_annotated_str", None) + + def _get_plot_annotated_str(self): + return PlotAnnotatedStr() + + _get_plot_annotated_str.__annotations__ = {"return": 'Annotated["PlotAnnotatedStr", "meta"]'} + + setattr(moldflow, "PlotAnnotatedStr", PlotAnnotatedStr) + setattr(moldflow.Synergy, "get_plot_annotated_str", _get_plot_annotated_str) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.get_plot_annotated_str.then_method"]) + assert result.exit_code == 0 + assert "annotated-string-chain-ok" in result.stdout + finally: + if orig_plot is None: + delattr(moldflow, "PlotAnnotatedStr") + else: + setattr(moldflow, "PlotAnnotatedStr", orig_plot) + if orig_getter is None: + delattr(moldflow.Synergy, "get_plot_annotated_str") + else: + setattr(moldflow.Synergy, "get_plot_annotated_str", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_chained_call_with_optional_annotated_string_return_annotation(): + """Chained invoke should resolve Optional[Annotated[T, ...]] string return refs.""" + app = build_cli_app() + + class PlotAnnotatedOpt: + def then_method(self) -> str: + return "annotated-optional-string-chain-ok" + + class Sy: + def get_plot_annotated_opt(self): + return PlotAnnotatedOpt() + + orig_plot = getattr(moldflow, "PlotAnnotatedOpt", None) + orig_getter = getattr(moldflow.Synergy, "get_plot_annotated_opt", None) + + def _get_plot_annotated_opt(self): + return PlotAnnotatedOpt() + + _get_plot_annotated_opt.__annotations__ = { + "return": 'Optional[Annotated["PlotAnnotatedOpt", "meta"]]' + } + + setattr(moldflow, "PlotAnnotatedOpt", PlotAnnotatedOpt) + setattr(moldflow.Synergy, "get_plot_annotated_opt", _get_plot_annotated_opt) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.get_plot_annotated_opt.then_method"]) + assert result.exit_code == 0 + assert "annotated-optional-string-chain-ok" in result.stdout + finally: + if orig_plot is None: + delattr(moldflow, "PlotAnnotatedOpt") + else: + setattr(moldflow, "PlotAnnotatedOpt", orig_plot) + if orig_getter is None: + delattr(moldflow.Synergy, "get_plot_annotated_opt") + else: + setattr(moldflow.Synergy, "get_plot_annotated_opt", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_chained_call_with_ambiguous_union_return_uses_runtime_fallback(): + """Ambiguous Union return annotations should still allow valid runtime chain calls.""" + app = build_cli_app() + + class PlotA: + def ping(self, value: int) -> str: + return f"A:{value}" + + class PlotB: + def ping(self, value: int) -> str: + return f"B:{value}" + + class Sy: + def get_plot_union(self): + return PlotA() + + orig_plot_a = getattr(moldflow, "PlotA", None) + orig_plot_b = getattr(moldflow, "PlotB", None) + orig_getter = getattr(moldflow.Synergy, "get_plot_union", None) + + def _get_plot_union(self): + return PlotA() + + _get_plot_union.__annotations__ = {"return": "Union[PlotA, PlotB, None]"} + + setattr(moldflow, "PlotA", PlotA) + setattr(moldflow, "PlotB", PlotB) + setattr(moldflow.Synergy, "get_plot_union", _get_plot_union) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.get_plot_union.ping", "ping.value=7"]) + assert result.exit_code == 0 + assert "A:7" in result.stdout + finally: + if orig_plot_a is None: + delattr(moldflow, "PlotA") + else: + setattr(moldflow, "PlotA", orig_plot_a) + if orig_plot_b is None: + delattr(moldflow, "PlotB") + else: + setattr(moldflow, "PlotB", orig_plot_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_plot_union") + else: + setattr(moldflow.Synergy, "get_plot_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_runtime_fallback_chained_step_is_case_insensitive(): + """Runtime-fallback chained steps should still resolve method names case-insensitively.""" + app = build_cli_app() + + class PlotCase: + def ping(self, value: int) -> str: + return f"case:{value}" + + class OtherCase: + def ping(self, value: int) -> str: # pragma: no cover - not used, only union member + return f"other:{value}" + + class Sy: + def get_plot_union_case(self): + return PlotCase() + + orig_plot = getattr(moldflow, "PlotCase", None) + orig_other = getattr(moldflow, "OtherCase", None) + orig_getter = getattr(moldflow.Synergy, "get_plot_union_case", None) + + def _get_plot_union_case(self): + return PlotCase() + + _get_plot_union_case.__annotations__ = {"return": "Union[PlotCase, OtherCase, None]"} + + setattr(moldflow, "PlotCase", PlotCase) + setattr(moldflow, "OtherCase", OtherCase) + setattr(moldflow.Synergy, "get_plot_union_case", _get_plot_union_case) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, ["invoke", "synergy.get_plot_union_case.PING", "PING.value=9"] + ) + assert result.exit_code == 0 + assert "case:9" in result.stdout + finally: + if orig_plot is None: + delattr(moldflow, "PlotCase") + else: + setattr(moldflow, "PlotCase", orig_plot) + if orig_other is None: + delattr(moldflow, "OtherCase") + else: + setattr(moldflow, "OtherCase", orig_other) + if orig_getter is None: + delattr(moldflow.Synergy, "get_plot_union_case") + else: + setattr(moldflow.Synergy, "get_plot_union_case", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_deep_runtime_fallback_chain_supports_multiple_deferred_steps(): + """Multiple deferred chain steps should execute correctly at runtime.""" + app = build_cli_app() + + class Level2A: + def finish(self, value: int) -> str: + return f"done:{value}" + + class Level2B: + def finish(self, value: int) -> str: # pragma: no cover - union member only + return f"alt:{value}" + + class Level1A: + def get_level2(self): + return Level2A() + + class Level1B: + def get_level2(self): # pragma: no cover - union member only + return Level2B() + + class Sy: + def get_level1(self): + return Level1A() + + orig_l1a = getattr(moldflow, "Level1A", None) + orig_l1b = getattr(moldflow, "Level1B", None) + orig_l2a = getattr(moldflow, "Level2A", None) + orig_l2b = getattr(moldflow, "Level2B", None) + orig_getter = getattr(moldflow.Synergy, "get_level1", None) + + def _get_level1(self): + return Level1A() + + _get_level1.__annotations__ = {"return": "Union[Level1A, Level1B, None]"} + + setattr(moldflow, "Level1A", Level1A) + setattr(moldflow, "Level1B", Level1B) + setattr(moldflow, "Level2A", Level2A) + setattr(moldflow, "Level2B", Level2B) + setattr(moldflow.Synergy, "get_level1", _get_level1) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, ["invoke", "synergy.get_level1.get_level2.finish", "finish.value=5"] + ) + assert result.exit_code == 0 + assert "done:5" in result.stdout + finally: + if orig_l1a is None: + delattr(moldflow, "Level1A") + else: + setattr(moldflow, "Level1A", orig_l1a) + if orig_l1b is None: + delattr(moldflow, "Level1B") + else: + setattr(moldflow, "Level1B", orig_l1b) + if orig_l2a is None: + delattr(moldflow, "Level2A") + else: + setattr(moldflow, "Level2A", orig_l2a) + if orig_l2b is None: + delattr(moldflow, "Level2B") + else: + setattr(moldflow, "Level2B", orig_l2b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_level1") + else: + setattr(moldflow.Synergy, "get_level1", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_runtime_fallback_supports_nested_typed_arg_assignment(): + """Deferred steps should support nested typed args after runtime signature binding.""" + app = build_cli_app() + + class FakeImportOptions: + def __init__(self) -> None: + self.use_mdl = None + + class PlotTypedA: + def apply(self, import_options: "ImportOptions | None") -> str: + return f"use_mdl={import_options.use_mdl}" + + class PlotTypedB: + def apply(self, import_options: "ImportOptions | None") -> str: # pragma: no cover + return f"use_mdl={import_options.use_mdl}" + + class Sy: + @property + def import_options(self) -> FakeImportOptions: + return FakeImportOptions() + + def get_plot_typed_union(self): + return PlotTypedA() + + orig_plot_a = getattr(moldflow, "PlotTypedA", None) + orig_plot_b = getattr(moldflow, "PlotTypedB", None) + orig_getter = getattr(moldflow.Synergy, "get_plot_typed_union", None) + + def _get_plot_typed_union(self): + return PlotTypedA() + + _get_plot_typed_union.__annotations__ = {"return": "Union[PlotTypedA, PlotTypedB, None]"} + + setattr(moldflow, "PlotTypedA", PlotTypedA) + setattr(moldflow, "PlotTypedB", PlotTypedB) + setattr(moldflow.Synergy, "get_plot_typed_union", _get_plot_typed_union) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, + [ + "invoke", + "synergy.get_plot_typed_union.apply", + "apply.import_options.use_mdl=true", + ], + ) + assert result.exit_code == 0 + assert "use_mdl=True" in result.stdout + finally: + if orig_plot_a is None: + delattr(moldflow, "PlotTypedA") + else: + setattr(moldflow, "PlotTypedA", orig_plot_a) + if orig_plot_b is None: + delattr(moldflow, "PlotTypedB") + else: + setattr(moldflow, "PlotTypedB", orig_plot_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_plot_typed_union") + else: + setattr(moldflow.Synergy, "get_plot_typed_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_deferred_chain_kwargs_accepts_flat_keys_and_rejects_nested_keys(): + """Deferred steps should preserve **kwargs routing and nested-key validation.""" + app = build_cli_app() + + class KwA: + def sink(self, **kwargs) -> str: + return f"kw={sorted(kwargs.items())}" + + class KwB: + def sink(self, **kwargs) -> str: # pragma: no cover - union member only + return f"kw={sorted(kwargs.items())}" + + class Sy: + def get_kwargs_union(self): + return KwA() + + orig_a = getattr(moldflow, "KwA", None) + orig_b = getattr(moldflow, "KwB", None) + orig_getter = getattr(moldflow.Synergy, "get_kwargs_union", None) + + def _get_kwargs_union(self): + return KwA() + + _get_kwargs_union.__annotations__ = {"return": "Union[KwA, KwB, None]"} + + setattr(moldflow, "KwA", KwA) + setattr(moldflow, "KwB", KwB) + setattr(moldflow.Synergy, "get_kwargs_union", _get_kwargs_union) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + ok = runner.invoke( + app, ["invoke", "synergy.get_kwargs_union.sink", "sink.alpha=1", "sink.beta=two"] + ) + bad = runner.invoke( + app, ["invoke", "synergy.get_kwargs_union.sink", "sink.alpha.beta=1"] + ) + + assert ok.exit_code == 0 + assert "alpha" in ok.stdout and "beta" in ok.stdout + assert bad.exit_code != 0 + bad_text = (bad.stdout or "") + (getattr(bad, "stderr", "") or "") + assert "nested argument" in bad_text.lower() + assert "**kwargs" in bad_text + finally: + if orig_a is None: + delattr(moldflow, "KwA") + else: + setattr(moldflow, "KwA", orig_a) + if orig_b is None: + delattr(moldflow, "KwB") + else: + setattr(moldflow, "KwB", orig_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_kwargs_union") + else: + setattr(moldflow.Synergy, "get_kwargs_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_deferred_chain_positional_only_error_remains_actionable(): + """Deferred steps with positional-only params should fail with clear guidance.""" + app = build_cli_app() + + class PosA: + def sink(self, value, /): + return value + + class PosB: + def sink(self, value, /): # pragma: no cover - union member only + return value + + class Sy: + def get_pos_union(self): + return PosA() + + orig_a = getattr(moldflow, "PosA", None) + orig_b = getattr(moldflow, "PosB", None) + orig_getter = getattr(moldflow.Synergy, "get_pos_union", None) + + def _get_pos_union(self): + return PosA() + + _get_pos_union.__annotations__ = {"return": "Union[PosA, PosB, None]"} + + setattr(moldflow, "PosA", PosA) + setattr(moldflow, "PosB", PosB) + setattr(moldflow.Synergy, "get_pos_union", _get_pos_union) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.get_pos_union.sink", "sink.value=5"]) + assert result.exit_code != 0 + text = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "positional-only parameters" in text.lower() + assert "not supported" in text.lower() + finally: + if orig_a is None: + delattr(moldflow, "PosA") + else: + setattr(moldflow, "PosA", orig_a) + if orig_b is None: + delattr(moldflow, "PosB") + else: + setattr(moldflow, "PosB", orig_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_pos_union") + else: + setattr(moldflow.Synergy, "get_pos_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_deferred_chain_conflicting_paths_still_fail_early(): + """Deferred steps should enforce duplicate/conflicting path validation consistently.""" + app = build_cli_app() + + class ConfA: + def do(self, vec: "Vector") -> str: + return "ok" + + class ConfB: + def do(self, vec: "Vector") -> str: # pragma: no cover - union member only + return "ok" + + class Sy: + def get_conf_union(self): + return ConfA() + + def create_vector(self): + return FakeVector() + + orig_a = getattr(moldflow, "ConfA", None) + orig_b = getattr(moldflow, "ConfB", None) + orig_getter = getattr(moldflow.Synergy, "get_conf_union", None) + + def _get_conf_union(self): + return ConfA() + + _get_conf_union.__annotations__ = {"return": "Union[ConfA, ConfB, None]"} + + setattr(moldflow, "ConfA", ConfA) + setattr(moldflow, "ConfB", ConfB) + setattr(moldflow.Synergy, "get_conf_union", _get_conf_union) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, ["invoke", "synergy.get_conf_union.do", "do.vec=1", "do.vec.x=2"] + ) + assert result.exit_code != 0 + text = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "conflicting argument paths" in text.lower() + finally: + if orig_a is None: + delattr(moldflow, "ConfA") + else: + setattr(moldflow, "ConfA", orig_a) + if orig_b is None: + delattr(moldflow, "ConfB") + else: + setattr(moldflow, "ConfB", orig_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_conf_union") + else: + setattr(moldflow.Synergy, "get_conf_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_deferred_chain_json_file_supports_mixed_case_step_keys(tmp_path): + """Deferred chains should accept mixed-case step keys from --params-json-file.""" + app = build_cli_app() + + class PlotFromUnionA: + def apply(self, import_options: "ImportOptions | None", retries: int = 0) -> str: + return f"use_mdl={import_options.use_mdl};retries={retries}" + + class PlotFromUnionB: + def apply( + self, import_options: "ImportOptions | None", retries: int = 0 # pragma: no cover + ) -> str: + return f"use_mdl={import_options.use_mdl};retries={retries}" + + class FakeImportOptions: + def __init__(self) -> None: + self.use_mdl = None + + class Sy: + @property + def import_options(self) -> FakeImportOptions: + return FakeImportOptions() + + def get_plot_union(self, plot_name: str): + _ = plot_name + return PlotFromUnionA() + + orig_a = getattr(moldflow, "PlotFromUnionA", None) + orig_b = getattr(moldflow, "PlotFromUnionB", None) + orig_getter = getattr(moldflow.Synergy, "get_plot_union", None) + + def _get_plot_union(self, plot_name: str): + _ = plot_name + return PlotFromUnionA() + + _get_plot_union.__annotations__ = { + "plot_name": "str", + "return": "Union[PlotFromUnionA, PlotFromUnionB, None]", + } + + setattr(moldflow, "PlotFromUnionA", PlotFromUnionA) + setattr(moldflow, "PlotFromUnionB", PlotFromUnionB) + setattr(moldflow.Synergy, "get_plot_union", _get_plot_union) + + payload_file = tmp_path / "params.json" + payload_file.write_text( + ( + "{" + '"GET_PLOT_UNION":{"plot_name":"Main Plot"},' + '"aPpLy":{"import_options":{"use_mdl":true},"retries":2}' + "}" + ), + encoding="utf-8", + ) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, + ["invoke", "synergy.get_plot_union.apply", "--params-json-file", str(payload_file)], + ) + assert result.exit_code == 0 + assert "use_mdl=True" in result.stdout + assert "retries=2" in result.stdout + finally: + if orig_a is None: + delattr(moldflow, "PlotFromUnionA") + else: + setattr(moldflow, "PlotFromUnionA", orig_a) + if orig_b is None: + delattr(moldflow, "PlotFromUnionB") + else: + setattr(moldflow, "PlotFromUnionB", orig_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_plot_union") + else: + setattr(moldflow.Synergy, "get_plot_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_deferred_chain_json_file_honors_mixed_case_target_and_step_names(tmp_path): + """Deferred chains should route params correctly with mixed-case target + JSON step keys.""" + app = build_cli_app() + + class NodeA: + def final_action(self, value: int) -> str: + return f"value={value}" + + class NodeB: + def final_action(self, value: int) -> str: # pragma: no cover - union member only + return f"value={value}" + + class Sy: + def get_node_union(self): + return NodeA() + + orig_a = getattr(moldflow, "NodeA", None) + orig_b = getattr(moldflow, "NodeB", None) + orig_getter = getattr(moldflow.Synergy, "get_node_union", None) + + def _get_node_union(self): + return NodeA() + + _get_node_union.__annotations__ = {"return": "Union[NodeA, NodeB, None]"} + + setattr(moldflow, "NodeA", NodeA) + setattr(moldflow, "NodeB", NodeB) + setattr(moldflow.Synergy, "get_node_union", _get_node_union) + + payload_file = tmp_path / "params_case.json" + payload_file.write_text('{"fInAl_AcTiOn":{"value":9}}', encoding="utf-8") + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, + [ + "invoke", + "SyNeRgY.get_node_union.FINAL_ACTION", + "--params-json-file", + str(payload_file), + ], + ) + assert result.exit_code == 0 + assert "value=9" in result.stdout + finally: + if orig_a is None: + delattr(moldflow, "NodeA") + else: + setattr(moldflow, "NodeA", orig_a) + if orig_b is None: + delattr(moldflow, "NodeB") + else: + setattr(moldflow, "NodeB", orig_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_node_union") + else: + setattr(moldflow.Synergy, "get_node_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_deferred_chain_json_file_rejects_case_variant_duplicate_step_paths(tmp_path): + """Case-variant duplicate step objects should fail with duplicate-path validation.""" + app = build_cli_app() + + class DupA: + def apply(self, value: int) -> str: + return f"value={value}" + + class DupB: + def apply(self, value: int) -> str: # pragma: no cover - union member only + return f"value={value}" + + class Sy: + def get_dup_union(self, plot_name: str): + _ = plot_name + return DupA() + + orig_a = getattr(moldflow, "DupA", None) + orig_b = getattr(moldflow, "DupB", None) + orig_getter = getattr(moldflow.Synergy, "get_dup_union", None) + + def _get_dup_union(self, plot_name: str): + _ = plot_name + return DupA() + + _get_dup_union.__annotations__ = {"return": "Union[DupA, DupB, None]"} + + setattr(moldflow, "DupA", DupA) + setattr(moldflow, "DupB", DupB) + setattr(moldflow.Synergy, "get_dup_union", _get_dup_union) + + payload_file = tmp_path / "dup_params.json" + payload_file.write_text( + ( + "{" + '"GET_DUP_UNION":{"plot_name":"A"},' + '"get_dup_union":{"plot_name":"B"},' + '"APPLY":{"value":1}' + "}" + ), + encoding="utf-8", + ) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, + ["invoke", "synergy.get_dup_union.apply", "--params-json-file", str(payload_file)], + ) + assert result.exit_code != 0 + text = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "duplicate argument path" in text.lower() + finally: + if orig_a is None: + delattr(moldflow, "DupA") + else: + setattr(moldflow, "DupA", orig_a) + if orig_b is None: + delattr(moldflow, "DupB") + else: + setattr(moldflow, "DupB", orig_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_dup_union") + else: + setattr(moldflow.Synergy, "get_dup_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_deferred_chain_json_file_rejects_mixed_object_scalar_shapes(tmp_path): + """Step payloads must remain objects; scalar step payloads should fail consistently.""" + app = build_cli_app() + + class ShapeA: + def apply(self, value: int) -> str: + return f"value={value}" + + class ShapeB: + def apply(self, value: int) -> str: # pragma: no cover - union member only + return f"value={value}" + + class Sy: + def get_shape_union(self): + return ShapeA() + + orig_a = getattr(moldflow, "ShapeA", None) + orig_b = getattr(moldflow, "ShapeB", None) + orig_getter = getattr(moldflow.Synergy, "get_shape_union", None) + + def _get_shape_union(self): + return ShapeA() + + _get_shape_union.__annotations__ = {"return": "Union[ShapeA, ShapeB, None]"} + + setattr(moldflow, "ShapeA", ShapeA) + setattr(moldflow, "ShapeB", ShapeB) + setattr(moldflow.Synergy, "get_shape_union", _get_shape_union) + + payload_file = tmp_path / "shape_params.json" + payload_file.write_text('{"GET_SHAPE_UNION":{},"APPLY":5}', encoding="utf-8") + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, + [ + "invoke", + "synergy.get_shape_union.apply", + "--params-json-file", + str(payload_file), + ], + ) + assert result.exit_code != 0 + text = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "error" in text.lower() or result.exception is not None + finally: + if orig_a is None: + delattr(moldflow, "ShapeA") + else: + setattr(moldflow, "ShapeA", orig_a) + if orig_b is None: + delattr(moldflow, "ShapeB") + else: + setattr(moldflow, "ShapeB", orig_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_shape_union") + else: + setattr(moldflow.Synergy, "get_shape_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_deferred_chain_json_file_with_bom_parses_successfully(tmp_path): + """UTF-8 BOM-prefixed JSON files should parse for deferred chains.""" + app = build_cli_app() + + class BomA: + def done(self, value: int) -> str: + return f"done:{value}" + + class BomB: + def done(self, value: int) -> str: # pragma: no cover - union member only + return f"done:{value}" + + class Sy: + def get_bom_union(self): + return BomA() + + orig_a = getattr(moldflow, "BomA", None) + orig_b = getattr(moldflow, "BomB", None) + orig_getter = getattr(moldflow.Synergy, "get_bom_union", None) + + def _get_bom_union(self): + return BomA() + + _get_bom_union.__annotations__ = {"return": "Union[BomA, BomB, None]"} + + setattr(moldflow, "BomA", BomA) + setattr(moldflow, "BomB", BomB) + setattr(moldflow.Synergy, "get_bom_union", _get_bom_union) + + payload_file = tmp_path / "bom_params.json" + payload_file.write_bytes(b"\xef\xbb\xbf" + b'{"DoNe":{"value":4}}') + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, + ["invoke", "synergy.get_bom_union.done", "--params-json-file", str(payload_file)], + ) + assert result.exit_code == 0 + assert "done:4" in result.stdout + finally: + if orig_a is None: + delattr(moldflow, "BomA") + else: + setattr(moldflow, "BomA", orig_a) + if orig_b is None: + delattr(moldflow, "BomB") + else: + setattr(moldflow, "BomB", orig_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_bom_union") + else: + setattr(moldflow.Synergy, "get_bom_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_deferred_chain_json_file_invalid_unicode_reports_badparameter(tmp_path): + """Invalid-unicode JSON file input should fail as a CLI validation error.""" + app = build_cli_app() + + class BadUCA: + def done(self, value: int) -> str: + return f"done:{value}" + + class BadUCB: + def done(self, value: int) -> str: # pragma: no cover - union member only + return f"done:{value}" + + class Sy: + def get_baduc_union(self): + return BadUCA() + + orig_a = getattr(moldflow, "BadUCA", None) + orig_b = getattr(moldflow, "BadUCB", None) + orig_getter = getattr(moldflow.Synergy, "get_baduc_union", None) + + def _get_baduc_union(self): + return BadUCA() + + _get_baduc_union.__annotations__ = {"return": "Union[BadUCA, BadUCB, None]"} + + setattr(moldflow, "BadUCA", BadUCA) + setattr(moldflow, "BadUCB", BadUCB) + setattr(moldflow.Synergy, "get_baduc_union", _get_baduc_union) + + payload_file = tmp_path / "bad_unicode.json" + payload_file.write_bytes(b"\xff\xfe\xfa") + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, + ["invoke", "synergy.get_baduc_union.done", "--params-json-file", str(payload_file)], + ) + assert result.exit_code == 2 + text = ( + (result.stdout or "") + + (getattr(result, "stderr", "") or "") + + (f"\n{result.exception}" if getattr(result, "exception", None) else "") + ) + assert "cannot read json file" in text.lower() + finally: + if orig_a is None: + delattr(moldflow, "BadUCA") + else: + setattr(moldflow, "BadUCA", orig_a) + if orig_b is None: + delattr(moldflow, "BadUCB") + else: + setattr(moldflow, "BadUCB", orig_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_baduc_union") + else: + setattr(moldflow.Synergy, "get_baduc_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_deferred_chain_json_file_excessive_nesting_reports_validation_error(tmp_path): + """Pathologically deep JSON should fail as deterministic CLI validation, not raw exceptions.""" + app = build_cli_app() + + class DeepA: + def done(self, value: int) -> str: + return f"done:{value}" + + class DeepB: + def done(self, value: int) -> str: # pragma: no cover - union member only + return f"done:{value}" + + class Sy: + def get_deep_union(self): + return DeepA() + + orig_a = getattr(moldflow, "DeepA", None) + orig_b = getattr(moldflow, "DeepB", None) + orig_getter = getattr(moldflow.Synergy, "get_deep_union", None) + + def _get_deep_union(self): + return DeepA() + + _get_deep_union.__annotations__ = {"return": "Union[DeepA, DeepB, None]"} + + setattr(moldflow, "DeepA", DeepA) + setattr(moldflow, "DeepB", DeepB) + setattr(moldflow.Synergy, "get_deep_union", _get_deep_union) + + deep_value = "[" * 1800 + "0" + "]" * 1800 + payload_file = tmp_path / "deep_payload.json" + payload_file.write_text('{"DONE":{"value":' + deep_value + "}}", encoding="utf-8") + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, + ["invoke", "synergy.get_deep_union.done", "--params-json-file", str(payload_file)], + ) + assert result.exit_code == 2 + text = ( + (result.stdout or "") + + (getattr(result, "stderr", "") or "") + + (f"\n{result.exception}" if getattr(result, "exception", None) else "") + ).lower() + assert "cannot read json file" in text or "invalid json value for parameter" in text + finally: + if orig_a is None: + delattr(moldflow, "DeepA") + else: + setattr(moldflow, "DeepA", orig_a) + if orig_b is None: + delattr(moldflow, "DeepB") + else: + setattr(moldflow, "DeepB", orig_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_deep_union") + else: + setattr(moldflow.Synergy, "get_deep_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_deferred_chain_json_file_large_kwargs_payload_is_deterministic(tmp_path): + """Large deferred JSON payloads should parse and route deterministically.""" + app = build_cli_app() + + class BigA: + def sink(self, **kwargs) -> str: + return f"count={len(kwargs)};k0={kwargs.get('k0')};k499={kwargs.get('k499')}" + + class BigB: + def sink(self, **kwargs) -> str: # pragma: no cover - union member only + return f"count={len(kwargs)}" + + class Sy: + def get_big_union(self): + return BigA() + + orig_a = getattr(moldflow, "BigA", None) + orig_b = getattr(moldflow, "BigB", None) + orig_getter = getattr(moldflow.Synergy, "get_big_union", None) + + def _get_big_union(self): + return BigA() + + _get_big_union.__annotations__ = {"return": "Union[BigA, BigB, None]"} + + setattr(moldflow, "BigA", BigA) + setattr(moldflow, "BigB", BigB) + setattr(moldflow.Synergy, "get_big_union", _get_big_union) + + sink_payload = {f"k{i}": i for i in range(500)} + payload_file = tmp_path / "big_payload.json" + payload_file.write_text(json.dumps({"SINK": sink_payload}), encoding="utf-8") + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, + ["invoke", "synergy.get_big_union.sink", "--params-json-file", str(payload_file)], + ) + assert result.exit_code == 0 + assert "count=500" in result.stdout + assert "k0=0" in result.stdout + assert "k499=499" in result.stdout + finally: + if orig_a is None: + delattr(moldflow, "BigA") + else: + setattr(moldflow, "BigA", orig_a) + if orig_b is None: + delattr(moldflow, "BigB") + else: + setattr(moldflow, "BigB", orig_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_big_union") + else: + setattr(moldflow.Synergy, "get_big_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_dry_run_emits_plan_without_touching_synergy(): + """--dry-run should produce plan output and avoid Synergy instantiation.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_plan_only", None) + + def _cli_plan_only(self, value: int) -> str: + return f"value={value}" + + setattr(moldflow.Synergy, "cli_plan_only", _cli_plan_only) + try: + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke( + app, ["invoke", "synergy.cli_plan_only", "value=3", "--dry-run", "--json-output"] + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["mode"] == "dry_run" + assert payload["target"] == "synergy.cli_plan_only" + assert "summary" not in payload + assert "workflow_examples" not in payload + assert "params_json_template" not in payload + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_plan_only") + else: + setattr(moldflow.Synergy, "cli_plan_only", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_dry_run_step_docs_split_into_summary_and_details(): + """Dry-run step metadata should reuse the structured summary/details from template metadata.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_docful_dry_run", None) + + def cli_docful_dry_run(self, name: str) -> str: + """Show the named dry-run thing. + + Args: + name (str): The dry-run thing to show. + """ + del self, name + return "ok" + + setattr(moldflow.Synergy, "cli_docful_dry_run", cli_docful_dry_run) + try: + result = runner.invoke( + app, ["invoke", "synergy.cli_docful_dry_run", "name=demo", "--dry-run", "--json-output"] + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["summary"] == "Show the named dry-run thing." + assert payload["details"] == "Args: name (str): The dry-run thing to show." + step = payload["steps"][0] + assert step["signature"] == "(name: str)" + assert step["summary"] == "Show the named dry-run thing." + assert step["details"] == "Args: name (str): The dry-run thing to show." + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_docful_dry_run") + else: + setattr(moldflow.Synergy, "cli_docful_dry_run", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_removed_template_flag(): + """Invoke should no longer expose the removed --template flag.""" + app = build_cli_app() + result = runner.invoke(app, ["invoke", "synergy.open_project", "--template"]) + assert result.exit_code != 0 + text = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "no such option" in text.lower() + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_batch_file_rejects_unknown_item_fields(tmp_path): + """Batch items with unknown fields should fail deterministically.""" + app = build_cli_app() + batch_file = tmp_path / "batch_unknown.json" + batch_file.write_text( + json.dumps([{"target": "synergy.open_project", "args": ["path=x"], "unexpected": 1}]), + encoding="utf-8", + ) + result = runner.invoke(app, ["invoke", "--batch-file", str(batch_file), "--json-output"]) + assert result.exit_code != 0 + payload = json.loads(result.stdout) + assert payload["summary"] == {"total": 1, "succeeded": 0, "failed": 1} + assert payload["batch_results"][0]["ok"] is False + assert payload["batch_results"][0]["error_type"] == "batch_item_validation" + assert "unknown batch item field" in payload["batch_results"][0]["error"].lower() + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_dry_run_supports_json_file_output(tmp_path): + """Dry-run plan JSON should be writable via --json-file-output.""" + app = build_cli_app() + out_file = tmp_path / "dryrun.json" + result = runner.invoke( + app, + [ + "invoke", + "synergy.open_project", + "path=C:/tmp/a.mfproj", + "--dry-run", + "--json-file-output", + str(out_file), + ], + ) + assert result.exit_code == 0 + assert "Wrote structured output to" in result.stdout + assert out_file.exists() + payload = json.loads(out_file.read_text(encoding="utf-8")) + assert payload["mode"] == "dry_run" + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_dry_run_human_output_omits_examples(): + """Dry-run human output should stay plan-focused instead of repeating describe examples.""" + app = build_cli_app() + result = runner.invoke( + app, + ["invoke", "synergy.plot_manager.find_plot_by_name", "plot_name=Main Plot", "--dry-run"], + ) + assert result.exit_code == 0 + assert "Try this:" not in result.stdout + assert "JSON example:" not in result.stdout + assert "Shorter JSON example:" not in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_dry_run_human_output_omits_wrapper_input_hints(): + """Dry-run human output should not repeat describe-only wrapper input guidance.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_accept_levels_dry_run", None) + + def cli_accept_levels_dry_run(self, levels: "DoubleArray | None" = None) -> str: + del self, levels + return "ok" + + setattr(moldflow.Synergy, "cli_accept_levels_dry_run", cli_accept_levels_dry_run) + try: + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke( + app, ["invoke", "synergy.cli_accept_levels_dry_run", "levels=1.0,2.5", "--dry-run"] + ) + assert result.exit_code == 0 + assert "Input hints:" not in result.stdout + assert "levels (DoubleArray):" not in result.stdout + assert "JSON value:" not in result.stdout + assert "CLI argument:" not in result.stdout + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_accept_levels_dry_run") + else: + setattr(moldflow.Synergy, "cli_accept_levels_dry_run", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_property_read_dry_run_human_output_omits_empty_sections(): + """Read-only property dry-run output should avoid empty params-json and kwargs noise.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_read_only_plan", None) + + def _get_cli_read_only_plan(self) -> str: + del self + return "ok" + + setattr(moldflow.Synergy, "cli_read_only_plan", property(_get_cli_read_only_plan)) + try: + result = runner.invoke(app, ["invoke", "synergy.cli_read_only_plan", "--dry-run"]) + assert result.exit_code == 0 + assert "Dry run for synergy.cli_read_only_plan" in result.stdout + assert "This property is read-only and takes no arguments." in result.stdout + assert "Try this:" not in result.stdout + assert "JSON example:" not in result.stdout + assert "Resolved kwargs:" not in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_read_only_plan") + else: + setattr(moldflow.Synergy, "cli_read_only_plan", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_property_assignment_dry_run_human_output_shows_resolved_assignment(): + """Property-assignment dry-run output should show the parsed assignment payload.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_rw_dry_run_property", None) + + def _get_cli_rw_dry_run_property(self) -> int: + del self + return 1 + + def _set_cli_rw_dry_run_property(self, value: int) -> None: + del self, value + + setattr( + moldflow.Synergy, + "cli_rw_dry_run_property", + property(_get_cli_rw_dry_run_property, _set_cli_rw_dry_run_property), + ) + try: + result = runner.invoke( + app, ["invoke", "synergy.cli_rw_dry_run_property", "value=7", "--dry-run"] + ) + assert result.exit_code == 0 + assert "This dry run validates a property assignment." in result.stdout + assert "Resolved assignment:" in result.stdout + assert '"value": 7' in result.stdout + assert "Resolved kwargs:" not in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_rw_dry_run_property") + else: + setattr(moldflow.Synergy, "cli_rw_dry_run_property", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_batch_file_supports_json_file_output(tmp_path): + """Batch result JSON should be writable via --json-file-output.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_echo", None) + + def _cli_echo(self, value: str) -> str: + return value + + setattr(moldflow.Synergy, "cli_echo", _cli_echo) + + class Sy: + def cli_echo(self, value: str) -> str: + return value + + batch_file = tmp_path / "batch_ok.json" + out_file = tmp_path / "batch_out.json" + batch_file.write_text( + json.dumps([{"target": "synergy.cli_echo", "args": ["value=ok"]}]), encoding="utf-8" + ) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, + ["invoke", "--batch-file", str(batch_file), "--json-file-output", str(out_file)], + ) + assert result.exit_code == 0 + assert "Wrote structured output to" in result.stdout + assert out_file.exists() + payload = json.loads(out_file.read_text(encoding="utf-8")) + assert payload["batch_results"][0]["ok"] is True + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_echo") + else: + setattr(moldflow.Synergy, "cli_echo", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_batch_file_executes_multiple_calls(tmp_path): + """--batch-file should execute call array and return structured batch results.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_echo", None) + + def _cli_echo(self, value: str) -> str: + return value + + setattr(moldflow.Synergy, "cli_echo", _cli_echo) + + class Sy: + def cli_echo(self, value: str) -> str: + return value + + batch_file = tmp_path / "batch.json" + batch_file.write_text( + json.dumps( + [ + {"target": "synergy.cli_echo", "args": ["value=one"]}, + {"target": "synergy.cli_echo", "params_json": {"value": "two"}}, + ] + ), + encoding="utf-8", + ) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, ["invoke", "--batch-file", str(batch_file), "--json-output"] + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["summary"] == {"total": 2, "succeeded": 2, "failed": 0} + assert len(payload["batch_results"]) == 2 + assert payload["batch_results"][0]["ok"] is True + assert payload["batch_results"][1]["ok"] is True + assert payload["batch_results"][0]["request"]["args"] == ["value=one"] + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_echo") + else: + setattr(moldflow.Synergy, "cli_echo", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_trace_emits_runtime_deferred_binding_events(): + """--trace should emit deferred runtime-bind events for ambiguous union chains.""" + app = build_cli_app() + + class TraceA: + def ping(self, value: int) -> str: + return f"A:{value}" + + class TraceB: + def ping(self, value: int) -> str: # pragma: no cover - union member only + return f"B:{value}" + + class Sy: + def get_trace_union(self): + return TraceA() + + orig_a = getattr(moldflow, "TraceA", None) + orig_b = getattr(moldflow, "TraceB", None) + orig_getter = getattr(moldflow.Synergy, "get_trace_union", None) + + def _get_trace_union(self): + return TraceA() + + _get_trace_union.__annotations__ = {"return": "Union[TraceA, TraceB, None]"} + setattr(moldflow, "TraceA", TraceA) + setattr(moldflow, "TraceB", TraceB) + setattr(moldflow.Synergy, "get_trace_union", _get_trace_union) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, ["invoke", "synergy.get_trace_union.ping", "ping.value=4", "--trace"] + ) + assert result.exit_code == 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert '"schema_version": "1.0"' in combined + assert '"target": "synergy.get_trace_union.ping"' in combined + assert '"event": "result"' in combined + assert "deferred_runtime_bind" in combined + assert "invoke_step" in combined + finally: + if orig_a is None: + delattr(moldflow, "TraceA") + else: + setattr(moldflow, "TraceA", orig_a) + if orig_b is None: + delattr(moldflow, "TraceB") + else: + setattr(moldflow, "TraceB", orig_b) + if orig_getter is None: + delattr(moldflow.Synergy, "get_trace_union") + else: + setattr(moldflow.Synergy, "get_trace_union", orig_getter) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_nullable_param_without_default_can_be_omitted(): + """Nullable annotations without defaults should be auto-populated as None when omitted.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_nullable_no_default", None) + + def _cli_nullable_no_default(self, maybe: "ImportOptions | None") -> str: + return f"maybe={maybe!r}" + + setattr(moldflow.Synergy, "cli_nullable_no_default", _cli_nullable_no_default) + + class Sy: + def cli_nullable_no_default(self, maybe) -> str: + return f"maybe={maybe!r}" + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.cli_nullable_no_default"]) + assert result.exit_code == 0 + assert "maybe=None" in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_nullable_no_default") + else: + setattr(moldflow.Synergy, "cli_nullable_no_default", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_fail_on_false_is_default_behavior(): + """False business results should fail command by default for CI-friendly signaling.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_returns_false", None) + + def _cli_returns_false(self) -> bool: + return False + + setattr(moldflow.Synergy, "cli_returns_false", _cli_returns_false) + + class Sy: + def cli_returns_false(self) -> bool: + return False + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.cli_returns_false", "--json-output"]) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["ok"] is False + assert payload["result"] is False + assert payload["result_type"] == "bool" + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_returns_false") + else: + setattr(moldflow.Synergy, "cli_returns_false", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_no_fail_on_false_allows_exit_zero(): + """--no-fail-on-false should preserve backward-compatible zero exit on False.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_returns_false", None) + + def _cli_returns_false(self) -> bool: + return False + + setattr(moldflow.Synergy, "cli_returns_false", _cli_returns_false) + + class Sy: + def cli_returns_false(self) -> bool: + return False + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, ["invoke", "synergy.cli_returns_false", "--json-output", "--no-fail-on-false"] + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["ok"] is False + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_returns_false") + else: + setattr(moldflow.Synergy, "cli_returns_false", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_batch_fail_on_false_default_sets_nonzero_exit(tmp_path): + """Batch mode should fail when a call returns False and fail-on-false is enabled.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_maybe", None) + + def _cli_maybe(self, value: str) -> bool: + return value == "ok" + + setattr(moldflow.Synergy, "cli_maybe", _cli_maybe) + + class Sy: + def cli_maybe(self, value: str) -> bool: + return value == "ok" + + batch_file = tmp_path / "batch_false.json" + batch_file.write_text( + json.dumps( + [ + {"target": "synergy.cli_maybe", "args": ["value=ok"]}, + {"target": "synergy.cli_maybe", "args": ["value=bad"]}, + ] + ), + encoding="utf-8", + ) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, ["invoke", "--batch-file", str(batch_file), "--json-output"] + ) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["batch_results"][0]["ok"] is True + assert payload["batch_results"][1]["ok"] is False + assert payload["batch_results"][1]["result"] is False + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_maybe") + else: + setattr(moldflow.Synergy, "cli_maybe", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_chained_call_intermediate_none(): + """If an intermediate call returns None, next-step invocation fails cleanly.""" + app = build_cli_app() + + class PM: + def get_none(self): + return None + + def then_method(self): + return "should not reach" + + class Sy: + @property + def plot_manager(self): + return PM() + + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.plot_manager.get_none.then_method"]) + + assert result.exit_code != 0 + err = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert ( + "cannot invoke method" in err.lower() + or "cannot resolve attribute" in err.lower() + or "is not a callable method" in err.lower() + ) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_boundary_conditions_null_owner_error_names_missing_manager(): + """Invoke should name the missing nullable Synergy manager owner.""" + app = build_cli_app() + + class Sy: + @property + def boundary_conditions(self): + return None + + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, ["invoke", "synergy.boundary_conditions.find_property", "prop_type=1", "prop_id=1"] + ) + + assert result.exit_code != 0 + err = (result.stdout or "") + (getattr(result, "stderr", "") or "") + lowered = err.lower() + assert "synergy.boundary_conditions" in err + assert "unavailable in the current session" in lowered + assert "find_property" in err + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_hidden_factory_wrapper_surfaces(): + """Invoke should reject explicit targets that pass through hidden factory wrapper helpers.""" + app = build_cli_app() + result = runner.invoke(app, ["invoke", "synergy.create_double_array.add_double", "value=1"]) + + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + lowered = combined.lower() + assert "synergy.create_double_array" in combined + assert "doublearray wrapper" in lowered + assert "direct cli targets" in lowered + + +@pytest.mark.cli +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_dry_run_human_output_is_plan_first(): + """Dry-run mode should default to a concise plan summary, not describe-style examples.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_plan_only_human", None) + + def _cli_plan_only_human(self, value: int) -> str: + return f"value={value}" + + setattr(moldflow.Synergy, "cli_plan_only_human", _cli_plan_only_human) + try: + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke( + app, ["invoke", "synergy.cli_plan_only_human", "value=3", "--dry-run"] + ) + assert result.exit_code == 0 + assert "Dry run for synergy.cli_plan_only_human" in result.stdout + assert "Resolved kwargs:" in result.stdout + assert "Try this:" not in result.stdout + assert "JSON example:" not in result.stdout + assert "self" not in result.stdout + assert "cli_plan_only_human(value:" not in result.stdout + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_plan_only_human") + else: + setattr(moldflow.Synergy, "cli_plan_only_human", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_batch_human_output_shows_summary_table(tmp_path): + """Batch mode should default to a concise summary table for terminal users.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_echo_human_batch", None) + + def _cli_echo_human_batch(self, value: str) -> str: + return value + + setattr(moldflow.Synergy, "cli_echo_human_batch", _cli_echo_human_batch) + + class Sy: + def cli_echo_human_batch(self, value: str) -> str: + return value + + batch_file = tmp_path / "batch_human.json" + batch_file.write_text( + json.dumps([{"target": "synergy.cli_echo_human_batch", "args": ["value=one"]}]), + encoding="utf-8", + ) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "--batch-file", str(batch_file)]) + assert result.exit_code == 0 + assert "Batch summary:" in result.stdout + assert "Batch results" in result.stdout + assert "synergy.cli_echo_human_batch" in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_echo_human_batch") + else: + setattr(moldflow.Synergy, "cli_echo_human_batch", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_trace_emits_error_events_for_validation_failures(): + """Trace mode should emit explicit error events before validation failures exit.""" + app = build_cli_app() + result = runner.invoke(app, ["invoke", "synergy.open_project", "--trace"]) + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert '"event": "error"' in combined + assert '"error_type": "invoke_validation"' in combined + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_batch_trace_includes_batch_index(tmp_path): + """Batch tracing should include a batch_index field for event correlation.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_trace_batch", None) + + def _cli_trace_batch(self, value: str) -> str: + return value + + setattr(moldflow.Synergy, "cli_trace_batch", _cli_trace_batch) + + class Sy: + def cli_trace_batch(self, value: str) -> str: + return value + + batch_file = tmp_path / "batch_trace.json" + batch_file.write_text( + json.dumps([{"target": "synergy.cli_trace_batch", "args": ["value=ok"]}]), encoding="utf-8" + ) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "--batch-file", str(batch_file), "--trace"]) + assert result.exit_code == 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert '"batch_index": 0' in combined + assert '"event": "result"' in combined + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_trace_batch") + else: + setattr(moldflow.Synergy, "cli_trace_batch", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_batch_runtime_error_is_reported_per_item_and_batch_continues(tmp_path): + """Non-validation runtime failures should stay structured and not abort the whole batch.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_batch_runtime", None) + + def _cli_batch_runtime(self, value: str) -> str: + if value == "boom": + raise RuntimeError("boom") + return value + + setattr(moldflow.Synergy, "cli_batch_runtime", _cli_batch_runtime) + + class Sy: + def cli_batch_runtime(self, value: str) -> str: + if value == "boom": + raise RuntimeError("boom") + return value + + batch_file = tmp_path / "batch_runtime.json" + batch_file.write_text( + json.dumps( + [ + {"target": "synergy.cli_batch_runtime", "args": ["value=boom"]}, + {"target": "synergy.cli_batch_runtime", "args": ["value=ok"]}, + ] + ), + encoding="utf-8", + ) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, ["invoke", "--batch-file", str(batch_file), "--json-output"] + ) + + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["summary"] == {"total": 2, "succeeded": 1, "failed": 1} + assert payload["batch_results"][0]["ok"] is False + assert payload["batch_results"][0]["error_type"] == "runtime_error" + assert "boom" in payload["batch_results"][0]["error"].lower() + assert payload["batch_results"][1]["ok"] is True + assert payload["batch_results"][1]["result"] == "ok" + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_batch_runtime") + else: + setattr(moldflow.Synergy, "cli_batch_runtime", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_case_insensitive_and_prefixed(): + """Invoke should accept mixed-case class/method names and moldflow. prefix.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_ping", None) + + def cli_ping(self) -> str: # type: ignore[name-defined] + return "pong" + + setattr(moldflow.Synergy, "cli_ping", cli_ping) + + class SynergyForTest: + def cli_ping(self) -> str: + return "pong" + + sy = SynergyForTest() + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy) as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ) as mock_fact_synergy: + r1 = runner.invoke(app, ["invoke", "SyNeRgY.cli_ping"]) + r2 = runner.invoke(app, ["invoke", "moldflow.Synergy.cli_ping"]) + + assert r1.exit_code == 0 + assert r2.exit_code == 0 + assert "pong" in r1.stdout + assert "pong" in r2.stdout + # Synergy should have been accessed when executing the invoke. + assert mock_ctx_synergy.call_count >= 1 or mock_fact_synergy.call_count >= 1 + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_ping") + else: + setattr(moldflow.Synergy, "cli_ping", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_multi_step_accepts_case_insensitive_step_prefixes(): + """Argument step prefixes should be case-insensitive for multi-step chains.""" + app = build_cli_app() + fake = FakeSynergy() + with patch("moldflow_cli.context.get_synergy", return_value=fake), patch( + "moldflow_cli.factories.get_synergy", return_value=fake + ): + result = runner.invoke( + app, + [ + "invoke", + "synergy.plot_manager.find_plot_by_name.get_probe_plot_probe_line", + "FIND_PLOT_BY_NAME.plot_name=My Plot", + "GET_PROBE_PLOT_PROBE_LINE.index=7", + "GET_PROBE_PLOT_PROBE_LINE.start_pt.x=1.0", + "GET_PROBE_PLOT_PROBE_LINE.start_pt.y=2.0", + "GET_PROBE_PLOT_PROBE_LINE.start_pt.z=3.0", + "GET_PROBE_PLOT_PROBE_LINE.end_pt.x=4.0", + "GET_PROBE_PLOT_PROBE_LINE.end_pt.y=5.0", + "GET_PROBE_PLOT_PROBE_LINE.end_pt.z=6.0", + ], + ) + + assert result.exit_code == 0 + assert "probe_line(index=7" in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_single_step_accepts_case_insensitive_step_prefixes(): + """Single-step routes should accept step-prefixed args case-insensitively.""" + app = build_cli_app() + fake = FakeSynergy() + with patch("moldflow_cli.context.get_synergy", return_value=fake), patch( + "moldflow_cli.factories.get_synergy", return_value=fake + ): + result = runner.invoke( + app, + [ + "invoke", + "synergy.plot_manager.find_plot_by_name", + "FIND_PLOT_BY_NAME.plot_name=My Plot", + ], + ) + + assert result.exit_code == 0 + assert "FakePlot" in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_single_step_json_accepts_nested_step_payload(): + """Single-step JSON payloads may optionally wrap args under the step name.""" + app = build_cli_app() + fake = FakeSynergy() + with patch("moldflow_cli.context.get_synergy", return_value=fake), patch( + "moldflow_cli.factories.get_synergy", return_value=fake + ): + result = runner.invoke( + app, + [ + "invoke", + "synergy.plot_manager.find_plot_by_name", + "--params-json", + '{"FIND_PLOT_BY_NAME": {"plot_name": "My Plot"}}', + ], + ) + + assert result.exit_code == 0 + assert "FakePlot" in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_multi_step_accepts_case_insensitive_target_method_segments(): + """Target method segments should resolve case-insensitively.""" + app = build_cli_app() + fake = FakeSynergy() + with patch("moldflow_cli.context.get_synergy", return_value=fake), patch( + "moldflow_cli.factories.get_synergy", return_value=fake + ): + result = runner.invoke( + app, + [ + "invoke", + "synergy.plot_manager.FIND_PLOT_BY_NAME.GET_PROBE_PLOT_PROBE_LINE", + "find_plot_by_name.plot_name=My Plot", + "get_probe_plot_probe_line.index=9", + "get_probe_plot_probe_line.start_pt.x=1.0", + "get_probe_plot_probe_line.start_pt.y=2.0", + "get_probe_plot_probe_line.start_pt.z=3.0", + "get_probe_plot_probe_line.end_pt.x=4.0", + "get_probe_plot_probe_line.end_pt.y=5.0", + "get_probe_plot_probe_line.end_pt.z=6.0", + ], + ) + + assert result.exit_code == 0 + assert "probe_line(index=9" in result.stdout + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_repeated_step_names_rejected_early(): + """Repeated step names in a chain should fail with an unambiguous message.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "loop", None) + + def _loop(self): + return self + + # Use a known wrapper return type so reflective chain parsing reaches duplicate detection. + _loop.__annotations__ = {"return": "Synergy"} + setattr(moldflow.Synergy, "loop", _loop) + try: + result = runner.invoke(app, ["invoke", "synergy.loop.loop"]) + assert result.exit_code != 0 + stderr_text = (getattr(result, "stderr_bytes", b"") or b"").decode("utf-8", "ignore") + assert "repeated method names" in stderr_text.lower() + finally: + if orig is None: + delattr(moldflow.Synergy, "loop") + else: + setattr(moldflow.Synergy, "loop", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_param_aliasing(): + """Accept hyphen/camel/snake variants of parameter names where sensible.""" + app = build_cli_app() + + class Fake: + def set_val(self, long_name: int) -> str: + return f"v={long_name}" + + sy = Fake() + # Ensure the class used for introspection exposes the callable. + orig = getattr(moldflow.Synergy, "set_val", None) + + def _set_val(self, long_name: int) -> str: + return f"v={long_name}" + + setattr(moldflow.Synergy, "set_val", _set_val) + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + # snake_case + r1 = runner.invoke(app, ["invoke", "synergy.set_val", "long_name=1"]) + # camelCase (not automatically supported but ensure it doesn't crash) + r2 = runner.invoke(app, ["invoke", "synergy.set_val", "longName=2"]) + # kebab-case (should be treated as literal param name and thus reject) + r3 = runner.invoke(app, ["invoke", "synergy.set_val", "long-name=3"]) + + assert r1.exit_code == 0 + assert "v=1" in r1.stdout + assert r2.exit_code != 0 or "v=2" in r2.stdout + assert r3.exit_code != 0 + finally: + if orig is None: + delattr(moldflow.Synergy, "set_val") + else: + setattr(moldflow.Synergy, "set_val", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_enum_parsing_and_invalid_enum(): + """Pass enum-like strings to methods expecting enums and invalid values raise BadParameter.""" + app = build_cli_app() + + class Fake: + def set_color(self, color) -> str: + return f"color={color}" + + sy = Fake() + orig = getattr(moldflow.Synergy, "set_color", None) + + def _set_color(self, color) -> str: + return f"color={color}" + + setattr(moldflow.Synergy, "set_color", _set_color) + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + ok = runner.invoke(app, ["invoke", "synergy.set_color", "color=Red"]) + bad = runner.invoke(app, ["invoke", "synergy.set_color", "color=NotAColor"]) + + # Current CLI does not validate enum membership; accept either behavior: + assert ok.exit_code == 0 + assert "color=Red" in (getattr(ok, "stdout", "") or getattr(ok, "output", "")) + # Either the CLI accepts the string or fails; assert one of those. + bad_output = (getattr(bad, "stdout", "") or getattr(bad, "output", "")) + ( + getattr(bad, "stderr", "") or "" + ) + assert bad.exit_code != 0 or "NotAColor" in bad_output or "color=NotAColor" in bad_output + finally: + if orig is None: + delattr(moldflow.Synergy, "set_color") + else: + setattr(moldflow.Synergy, "set_color", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_forward_ref_union_with_default_none(): + """Optional wrapper parameters with default None should not force wrapper creation.""" + app = build_cli_app() + + class Fake: + def do_optional(self, opt: "ImportOptions | None" = None) -> str: + return f"opt={opt}" + + sy = Fake() + orig = getattr(moldflow.Synergy, "do_optional", None) + + def _do_optional(self, opt=None) -> str: + return f"opt={opt}" + + setattr(moldflow.Synergy, "do_optional", _do_optional) + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + r = runner.invoke(app, ["invoke", "synergy.do_optional"]) + + assert r.exit_code == 0 + assert "opt=None" in r.stdout or "opt=None" in (r.stdout or "") + finally: + if orig is None: + delattr(moldflow.Synergy, "do_optional") + else: + setattr(moldflow.Synergy, "do_optional", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_varargs_and_kwargs(): + """Method accepting **kwargs should receive arbitrary named CLI params.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_varargs", None) + + def cli_varargs(self, **kwargs): + return f"kwcount={len(kwargs)};keys={sorted(kwargs.keys())}" + + setattr(moldflow.Synergy, "cli_varargs", cli_varargs) + + class Sy: + def cli_varargs(self, **kwargs): + return f"kwcount={len(kwargs)};keys={sorted(kwargs.keys())}" + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, ["invoke", "synergy.cli_varargs", "alpha=1", "beta=2", "gamma=three"] + ) + assert result.exit_code == 0 + assert "kwcount=3" in result.stdout + assert "alpha" in result.stdout and "beta" in result.stdout and "gamma" in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_varargs") + else: + setattr(moldflow.Synergy, "cli_varargs", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_prefers_callable_when_segment_name_collides_with_wrapper_class_name(): + """Callable method segments should win over class-name collisions during target parsing.""" + app = build_cli_app() + + class CliCollision: + """Dummy public class added to moldflow for collision testing.""" + + orig_cls = getattr(moldflow, "CliCollision", None) + orig_method = getattr(moldflow.Synergy, "cli_collision", None) + + def _cli_collision(self) -> str: + return "collision-ok" + + setattr(moldflow, "CliCollision", CliCollision) + setattr(moldflow.Synergy, "cli_collision", _cli_collision) + + class Sy: + def cli_collision(self) -> str: + return "collision-ok" + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.cli_collision"]) + assert result.exit_code == 0 + assert "collision-ok" in result.stdout + finally: + if orig_cls is None: + delattr(moldflow, "CliCollision") + else: + setattr(moldflow, "CliCollision", orig_cls) + if orig_method is None: + delattr(moldflow.Synergy, "cli_collision") + else: + setattr(moldflow.Synergy, "cli_collision", orig_method) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_supports_classmethod_targets(): + """Invoke should handle classmethod targets without requiring synthetic cls args.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_class_ping", None) + + class Sy: + @classmethod + def cli_class_ping(cls) -> str: + return "pong" + + setattr(moldflow.Synergy, "cli_class_ping", classmethod(lambda cls: "pong")) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.cli_class_ping"]) + assert result.exit_code == 0 + assert "pong" in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_class_ping") + else: + setattr(moldflow.Synergy, "cli_class_ping", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_import_options_nested_attributes_configured(): + """Ensure ImportOptions-like wrapper is allocated and nested attributes are set.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "import_file", None) + + class FakeImportOptions: + def __init__(self) -> None: + self.use_mdl = None + self.merge_parts = None + + def cli_import_file( # type: ignore[name-defined] + self, file: str, import_options: "ImportOptions | None" + ) -> str: + return ( + f"imported:{file};use_mdl={import_options.use_mdl};merge={import_options.merge_parts}" + ) + + setattr(moldflow.Synergy, "import_file", cli_import_file) + + class SynergyForTest: + @property + def import_options(self) -> FakeImportOptions: + return FakeImportOptions() + + def import_file(self, file: str, import_options: FakeImportOptions) -> str: + return ( + f"imported:{file};use_mdl={import_options.use_mdl};" + f"merge={import_options.merge_parts}" + ) + + sy = SynergyForTest() + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + result = runner.invoke( + app, + [ + "invoke", + "synergy.import_file", + "file=C:/tmp/part.iges", + "import_file.import_options.use_mdl=true", + "import_file.import_options.merge_parts=false", + ], + ) + + assert result.exit_code == 0 + assert "use_mdl=True" in result.stdout + assert "merge=False" in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "import_file") + else: + setattr(moldflow.Synergy, "import_file", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_signature_failure_does_not_block_call(): + """If signature inspection fails, invoke should still call the target.""" + app = build_cli_app() + + class BadSigCallable: + def __call__(self, **kwargs): + return f"ok:{kwargs}" + + @property + def __signature__(self): + raise ValueError("bad signature") + + orig = getattr(moldflow.Synergy, "cli_bad_sig", None) + setattr(moldflow.Synergy, "cli_bad_sig", BadSigCallable()) + + class Sy: + def __init__(self) -> None: + self.cli_bad_sig = BadSigCallable() + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_bad_sig", "foo=1"]) + + assert r.exit_code == 0 + assert "ok:" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_bad_sig") + else: + setattr(moldflow.Synergy, "cli_bad_sig", orig) diff --git a/tests/api/unit_tests/test_cli_invoke_parse.py b/tests/api/unit_tests/test_cli_invoke_parse.py new file mode 100644 index 0000000..0d3d01b --- /dev/null +++ b/tests/api/unit_tests/test_cli_invoke_parse.py @@ -0,0 +1,2053 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Focused invoke parsing/validation tests for moldflow CLI.""" + +# Test modules intentionally use many tiny inline doubles to mirror CLI call patterns. +# pylint: disable=missing-function-docstring,missing-class-docstring,unused-argument,too-many-lines + +from __future__ import annotations + +from unittest.mock import patch +import json +import inspect + +import pytest +from typer.testing import CliRunner +import moldflow +from moldflow.cli_input_metadata import cli_input_adapter + +from moldflow_cli.commands import build_cli_app +from moldflow_cli.invoke_binding import _bucket_items_by_step, _coerce_final_value +from tests.api.unit_tests.conftest import strip_ansi + +runner = CliRunner() + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_null_byte_in_value(): + """Parameters containing null bytes must be rejected before invocation.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_echo", None) + + def cli_echo(self, value: str) -> str: + return value + + setattr(moldflow.Synergy, "cli_echo", cli_echo) + + try: + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + r = runner.invoke(app, ["invoke", "synergy.cli_echo", "value=abc\x00def"]) + + # Should fail validation before Synergy instantiation. + assert r.exit_code != 0 + out = (getattr(r, "stdout", "") or getattr(r, "output", "")) + ( + getattr(r, "stderr", "") or "" + ) + assert "null byte" in out.lower() + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_echo") + else: + setattr(moldflow.Synergy, "cli_echo", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_control_characters_in_value(): + """Values containing newlines/tabs must be rejected.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_echo", None) + + def cli_echo(self, value: str) -> str: + return value + + setattr(moldflow.Synergy, "cli_echo", cli_echo) + + try: + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + r = runner.invoke(app, ["invoke", "synergy.cli_echo", "value=hello\nworld"]) + + assert r.exit_code != 0 + out = (getattr(r, "stdout", "") or getattr(r, "output", "")) + ( + getattr(r, "stderr", "") or "" + ) + assert ( + "control characters" in out.lower() or "newline" in out.lower() or "tab" in out.lower() + ) + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_echo") + else: + setattr(moldflow.Synergy, "cli_echo", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_control_characters_even_when_value_would_parse_to_int(): + """Control chars should be rejected before scalar coercion for non-string params too.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_int_echo", None) + + def cli_int_echo(self, value: int) -> int: + return value + + setattr(moldflow.Synergy, "cli_int_echo", cli_int_echo) + + try: + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + r = runner.invoke(app, ["invoke", "synergy.cli_int_echo", "value=\n1\n"]) + + assert r.exit_code != 0 + combined = (getattr(r, "stdout", "") or getattr(r, "output", "")) + ( + getattr(r, "stderr", "") or "" + ) + assert "control characters" in combined.lower() or "newline" in combined.lower() + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_int_echo") + else: + setattr(moldflow.Synergy, "cli_int_echo", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_allows_shell_metacharacters_in_value(): + """Shell metacharacters should be accepted as literal argument content.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_echo", None) + + def cli_echo(self, value: str) -> str: + return value + + setattr(moldflow.Synergy, "cli_echo", cli_echo) + + class Sy: + def cli_echo(self, value: str) -> str: + return value + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_echo", "value=a|b&ce$"]) + + assert r.exit_code == 0 + assert "a|b&ce$" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_echo") + else: + setattr(moldflow.Synergy, "cli_echo", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_allows_apostrophes_in_plain_values(): + """Ordinary punctuation like apostrophes should not be rejected as shell meta.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_echo", None) + + def cli_echo(self, value: str) -> str: + return value + + setattr(moldflow.Synergy, "cli_echo", cli_echo) + + class Sy: + def cli_echo(self, value: str) -> str: + return value + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_echo", "value=O'Neil"]) + + assert r.exit_code == 0 + assert "O'Neil" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_echo") + else: + setattr(moldflow.Synergy, "cli_echo", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_preserves_numeric_looking_string_when_param_is_str(): + """String-annotated params should not be auto-coerced to int/float.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_type_name", None) + + def cli_type_name(self, value: str) -> str: + return type(value).__name__ + + setattr(moldflow.Synergy, "cli_type_name", cli_type_name) + + class Sy: + def cli_type_name(self, value: str) -> str: + return type(value).__name__ + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_type_name", "value=00123"]) + + assert r.exit_code == 0 + assert "str" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_type_name") + else: + setattr(moldflow.Synergy, "cli_type_name", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_preserves_numeric_looking_string_for_string_forward_ref_forms(): + """Forward-ref optional-string forms should preserve literal string values.""" + app = build_cli_app() + + orig_optional = getattr(moldflow.Synergy, "cli_type_name_optional", None) + orig_pipe = getattr(moldflow.Synergy, "cli_type_name_pipe", None) + orig_typing = getattr(moldflow.Synergy, "cli_type_name_typing_optional", None) + + def cli_type_name_optional(self, value): + return type(value).__name__ + + def cli_type_name_pipe(self, value): + return type(value).__name__ + + def cli_type_name_typing_optional(self, value): + return type(value).__name__ + + cli_type_name_optional.__annotations__ = {"value": "Optional[str]", "return": "str"} + cli_type_name_pipe.__annotations__ = {"value": "str|None", "return": "str"} + cli_type_name_typing_optional.__annotations__ = { + "value": "typing.Optional[str]", + "return": "str", + } + + setattr(moldflow.Synergy, "cli_type_name_optional", cli_type_name_optional) + setattr(moldflow.Synergy, "cli_type_name_pipe", cli_type_name_pipe) + setattr(moldflow.Synergy, "cli_type_name_typing_optional", cli_type_name_typing_optional) + + class Sy: + def cli_type_name_optional(self, value): + return type(value).__name__ + + def cli_type_name_pipe(self, value): + return type(value).__name__ + + def cli_type_name_typing_optional(self, value): + return type(value).__name__ + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r1 = runner.invoke(app, ["invoke", "synergy.cli_type_name_optional", "value=00123"]) + r2 = runner.invoke(app, ["invoke", "synergy.cli_type_name_pipe", "value=00123"]) + r3 = runner.invoke( + app, ["invoke", "synergy.cli_type_name_typing_optional", "value=00123"] + ) + + assert r1.exit_code == 0 + assert r2.exit_code == 0 + assert r3.exit_code == 0 + assert "str" in r1.stdout + assert "str" in r2.stdout + assert "str" in r3.stdout + finally: + if orig_optional is None: + delattr(moldflow.Synergy, "cli_type_name_optional") + else: + setattr(moldflow.Synergy, "cli_type_name_optional", orig_optional) + if orig_pipe is None: + delattr(moldflow.Synergy, "cli_type_name_pipe") + else: + setattr(moldflow.Synergy, "cli_type_name_pipe", orig_pipe) + if orig_typing is None: + delattr(moldflow.Synergy, "cli_type_name_typing_optional") + else: + setattr(moldflow.Synergy, "cli_type_name_typing_optional", orig_typing) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_multi_step_requires_param_after_step(): + """Multi-step invoke must require a parameter name after the step.""" + app = build_cli_app() + + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke( + app, + [ + "invoke", + "synergy.plot_manager.find_plot_by_name.get_probe_plot_probe_line", + # Missing parameter name after the step (only 'find_plot_by_name=') + "find_plot_by_name=", + ], + ) + + # Should fail during argument routing before Synergy instantiation. + assert result.exit_code != 0 + err = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "must specify a parameter name" in err.lower() + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_multi_step_json_requires_step_grouping(): + """Multi-step JSON input should explain step grouping when users flatten parameters.""" + app = build_cli_app() + + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke( + app, + [ + "invoke", + "synergy.plot_manager.find_plot_by_name.get_probe_plot_probe_line", + "--params-json", + '{"plot_name":"My Plot"}', + ], + ) + + assert result.exit_code != 0 + err = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "find_plot_by_name" in err + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + + +@pytest.mark.cli +@pytest.mark.unit +def test_bucket_items_by_step_reports_json_grouping_guidance(): + """The underlying routing error should explicitly tell JSON callers to group by step name.""" + + method_steps = [{"name": "find_plot_by_name"}, {"name": "get_probe_plot_probe_line"}] + parsed_items = [(["plot_name"], "My Plot", "json")] + + with pytest.raises(Exception, match="group parameters by step name"): + _bucket_items_by_step(method_steps, parsed_items) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_json_input_literal(): + """Provide parameters via --json-input (literal) and ensure they are routed.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_params", None) + + def cli_params(self, name: str, path: str) -> str: + return f"name={name};path={path}" + + setattr(moldflow.Synergy, "cli_params", cli_params) + + class Sy: + def cli_params(self, name: str, path: str) -> str: + return f"name={name};path={path}" + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + payload = '{"name":"test","path":"C:\\\\Projects\\\\MyProject"}' + r = runner.invoke(app, ["invoke", "synergy.cli_params", "--params-json", payload]) + + assert r.exit_code == 0 + assert "name=test" in r.stdout + assert "C:\\Projects\\MyProject" in r.stdout or "MyProject" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_params") + else: + setattr(moldflow.Synergy, "cli_params", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_json_input_allows_multiline_text(): + """JSON-sourced strings may contain newlines/tabs and should be accepted.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_echo", None) + + def cli_echo(self, value: str) -> str: + return value + + setattr(moldflow.Synergy, "cli_echo", cli_echo) + + class Sy: + def cli_echo(self, value: str) -> str: + return value + + try: + payload = json.dumps({"value": "line1\nline2\tindented"}) + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_echo", "--params-json", payload]) + + assert r.exit_code == 0, (getattr(r, "stdout", "") or getattr(r, "output", "")) + ( + getattr(r, "stderr", "") or "" + ) + assert "line1" in r.stdout + assert "line2" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_echo") + else: + setattr(moldflow.Synergy, "cli_echo", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_missing_required_json_param_fails_before_synergy_instantiation(): + """Missing required params should error before any wrapper construction touches Synergy.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_needs_two", None) + + def cli_needs_two(self, import_options: "ImportOptions | None", name: str) -> str: + return f"{import_options}:{name}" + + setattr(moldflow.Synergy, "cli_needs_two", cli_needs_two) + + try: + payload = '{"import_options":{"use_mdl":true}}' + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + r = runner.invoke(app, ["invoke", "synergy.cli_needs_two", "--params-json", payload]) + + assert r.exit_code != 0 + out = (getattr(r, "stdout", "") or getattr(r, "output", "")) + ( + getattr(r, "stderr", "") or "" + ) + assert "missing required parameter" in out.lower() + mock_ctx_synergy.assert_not_called() + # Wrapper construction may still touch factories in edge introspection paths; + # what must never happen here is creating the live Synergy context. + assert mock_fact_synergy.call_count <= 1 + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_needs_two") + else: + setattr(moldflow.Synergy, "cli_needs_two", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_json_file_input(tmp_path): + """Provide parameters via --params-json-file and ensure they are routed.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_params", None) + + def cli_params(self, name: str, path: str) -> str: + return f"name={name};path={path}" + + setattr(moldflow.Synergy, "cli_params", cli_params) + + class Sy: + def cli_params(self, name: str, path: str) -> str: + return f"name={name};path={path}" + + try: + p = tmp_path / "params.json" + p.write_text('{"name":"filetest","path":"C:\\\\Tmp\\\\P"}', encoding="utf-8") + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_params", "--params-json-file", str(p)]) + + assert r.exit_code == 0 + assert "name=filetest" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_params") + else: + setattr(moldflow.Synergy, "cli_params", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_conflicting_json_inputs_error(): + """Specifying both --json-input and --json-file-input should error.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + r = runner.invoke( + app, + [ + "invoke", + "synergy.open_project", + "--params-json", + '{"name":"a"}', + "--params-json-file", + "params.json", + ], + ) + assert r.exit_code != 0 + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_json_list_and_dict_values(): + """JSON input should preserve list/dict types rather than stringifying them.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_complex", None) + + def cli_complex( # type: ignore[name-defined] + self, ids: list[int], meta: dict[str, str] + ) -> str: + return f"ids={ids};meta={meta}" + + setattr(moldflow.Synergy, "cli_complex", cli_complex) + + class Sy: + def cli_complex(self, ids: list[int], meta: dict[str, str]) -> str: + return f"ids={ids};meta={meta}" + + try: + payload = '{"ids":[1,2,3],"meta":{"a":"x","b":"y"}}' + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_complex", "--params-json", payload]) + + assert r.exit_code == 0 + assert "ids=[1, 2, 3]" in r.stdout + assert "meta={'a': 'x', 'b': 'y'}" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_complex") + else: + setattr(moldflow.Synergy, "cli_complex", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_json_typed_object(): + """JSON input should construct typed wrapper objects using __type__.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_typed", None) + + class FakeImportOptions: + def __init__(self) -> None: + self.use_mdl = None + + def cli_typed( # type: ignore[name-defined] + self, import_options: "ImportOptions | None" + ) -> str: + return f"use_mdl={import_options.use_mdl}" + + setattr(moldflow.Synergy, "cli_typed", cli_typed) + + class Sy: + @property + def import_options(self) -> FakeImportOptions: + return FakeImportOptions() + + def cli_typed(self, import_options: FakeImportOptions) -> str: + return f"use_mdl={import_options.use_mdl}" + + try: + payload = '{"import_options":{"__type__":"ImportOptions","use_mdl":true}}' + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_typed", "--params-json", payload]) + + assert r.exit_code == 0 + assert "use_mdl=True" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_typed") + else: + setattr(moldflow.Synergy, "cli_typed", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_coerce_final_value_json_nested_leaf_dict_uses_type_tag_for_wrappers(): + """Nested JSON leaves go through convert_value without a signature type. + + For a nested path, ``_coerce_final_value(..., origin='json')`` must still build + wrappers when the leaf dict includes ``__type__``. A wrapper-shaped dict without + ``__type__`` stays a plain dict (avoid assigning that shape expecting auto-wrap). + """ + + class FakeImportOptions: + def __init__(self) -> None: + self.use_mdl = None + + class Sy: + @property + def import_options(self) -> FakeImportOptions: + return FakeImportOptions() + + with patch("moldflow_cli.factories.get_synergy", return_value=Sy()): + wrapped = _coerce_final_value( + "container", "opts", {"__type__": "ImportOptions", "use_mdl": True}, "json" + ) + assert isinstance(wrapped, FakeImportOptions) + assert wrapped.use_mdl is True + + plain = _coerce_final_value("container", "opts", {"use_mdl": True}, "json") + assert plain == {"use_mdl": True} + assert not isinstance(plain, FakeImportOptions) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_non_public_field_in_typed_json_object(): + """Typed JSON payloads must reject non-public wrapper fields.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_typed", None) + + class FakeImportOptions: + pass + + def cli_typed( # type: ignore[name-defined] + self, import_options: "ImportOptions | None" + ) -> str: + return "ok" + + setattr(moldflow.Synergy, "cli_typed", cli_typed) + + class Sy: + @property + def import_options(self) -> FakeImportOptions: + return FakeImportOptions() + + def cli_typed(self, import_options: FakeImportOptions) -> str: + return "ok" + + try: + payload = '{"import_options":{"__type__":"ImportOptions","_private":1}}' + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_typed", "--params-json", payload]) + + assert r.exit_code != 0 + combined = ( + (getattr(r, "stdout", "") or getattr(r, "output", "")) + + (getattr(r, "stderr", "") or "") + + (f"\n{r.exception}" if getattr(r, "exception", None) else "") + ) + assert "non-public" in combined.lower() + assert "_private" in combined + mock_ctx_synergy.assert_not_called() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_typed") + else: + setattr(moldflow.Synergy, "cli_typed", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_invalid_single_positional_json_shorthand_has_actionable_error(): + """Malformed single-positional JSON shorthand should mention the shorthand rule.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_echo", None) + + def cli_echo(self, value: str) -> str: + return value + + setattr(moldflow.Synergy, "cli_echo", cli_echo) + + try: + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + r = runner.invoke(app, ["--no-color", "invoke", "synergy.cli_echo", '{"value":']) + + assert r.exit_code != 0 + combined = ( + (getattr(r, "stdout", "") or getattr(r, "output", "")) + + (getattr(r, "stderr", "") or "") + + (f"\n{r.exception}" if getattr(r, "exception", None) else "") + ) + lower = combined.lower() + assert "invalid json payload for parameters" in lower + assert "treated as json shorthand" in lower + assert "--params-json" in combined + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_echo") + else: + setattr(moldflow.Synergy, "cli_echo", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_does_not_silently_fallback_to_dict_when_typed_wrapper_assignment_fails(): + """Wrapper assignment failures should surface as validation errors, not dict fallback.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_typed_strict", None) + + class FakeImportOptions: + __slots__ = ("use_mdl",) + + def __init__(self) -> None: + self.use_mdl = False + + def cli_typed_strict( # type: ignore[name-defined] + self, import_options: "ImportOptions | None" + ) -> str: + return type(import_options).__name__ + + setattr(moldflow.Synergy, "cli_typed_strict", cli_typed_strict) + + class Sy: + @property + def import_options(self) -> FakeImportOptions: + return FakeImportOptions() + + def cli_typed_strict(self, import_options: FakeImportOptions) -> str: + return type(import_options).__name__ + + try: + payload = '{"import_options":{"unknown_field":1}}' + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_typed_strict", "--params-json", payload]) + + assert r.exit_code != 0 + combined = ( + (getattr(r, "stdout", "") or getattr(r, "output", "")) + + (getattr(r, "stderr", "") or "") + + (f"\n{r.exception}" if getattr(r, "exception", None) else "") + ) + assert "invalid json value for parameter" in combined.lower() + assert "unknown_field" in combined + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_typed_strict") + else: + setattr(moldflow.Synergy, "cli_typed_strict", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_json_entlist_selection_string(): + """Typed EntList JSON should accept a string selection field via the CLI adapter.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_select_nodes", None) + + class FakeEntList: + def __init__(self) -> None: + self.selected = None + + @cli_input_adapter(value_kind="selection_text", shorthand_supported=True) + def select_from_string(self, entity_string: str) -> None: + self.selected = entity_string + + def cli_select_nodes(self, nodes: "EntList | None") -> str: + return getattr(nodes, "selected", "missing") + + setattr(moldflow.Synergy, "cli_select_nodes", cli_select_nodes) + + class SelectionProvider: + def create_entity_list(self) -> FakeEntList: + return FakeEntList() + + class Sy: + @property + def property_editor(self) -> SelectionProvider: + return SelectionProvider() + + def cli_select_nodes(self, nodes: FakeEntList) -> str: + return getattr(nodes, "selected", "missing") + + try: + payload = '{"nodes":{"__type__":"EntList","entity_string":"N1,N2"}}' + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_select_nodes", "--params-json", payload]) + + assert r.exit_code == 0 + assert "N1,N2" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_select_nodes") + else: + setattr(moldflow.Synergy, "cli_select_nodes", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_non_string_entlist_selection_json(): + """Selection-like EntList JSON fields should reject non-string values clearly.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_select_nodes", None) + + class FakeEntList: + @cli_input_adapter(value_kind="selection_text", shorthand_supported=True) + def select_from_string(self, entity_string: str) -> None: + raise AssertionError("select_from_string should not be called") + + def cli_select_nodes(self, nodes: "EntList | None") -> str: + return "ok" + + setattr(moldflow.Synergy, "cli_select_nodes", cli_select_nodes) + + class SelectionProvider: + def create_entity_list(self) -> FakeEntList: + return FakeEntList() + + class Sy: + @property + def property_editor(self) -> SelectionProvider: + return SelectionProvider() + + def cli_select_nodes(self, nodes: FakeEntList) -> str: + return "ok" + + try: + payload = '{"nodes":{"__type__":"EntList","entity_string":5}}' + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_select_nodes", "--params-json", payload]) + + assert r.exit_code != 0 + combined = ( + (getattr(r, "stdout", "") or getattr(r, "output", "")) + + (getattr(r, "stderr", "") or "") + + (f"\n{r.exception}" if getattr(r, "exception", None) else "") + ) + assert "string selection expression" in combined.lower() + assert "entity_string" in combined + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_select_nodes") + else: + setattr(moldflow.Synergy, "cli_select_nodes", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_unknown_entlist_json_field_with_guidance(): + """Selection-like JSON should point users to the canonical field when they guess wrong.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_select_nodes", None) + + class FakeEntList: + @cli_input_adapter(value_kind="selection_text", shorthand_supported=True) + def select_from_string(self, entity_string: str) -> None: + pass + + def cli_select_nodes(self, nodes: "EntList | None") -> str: + return "ok" + + setattr(moldflow.Synergy, "cli_select_nodes", cli_select_nodes) + + class SelectionProvider: + def create_entity_list(self) -> FakeEntList: + return FakeEntList() + + class Sy: + @property + def property_editor(self) -> SelectionProvider: + return SelectionProvider() + + def cli_select_nodes(self, nodes: FakeEntList) -> str: + return "ok" + + try: + payload = '{"nodes":{"value":"N1,N2"}}' + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_select_nodes", "--params-json", payload]) + + assert r.exit_code != 0 + combined = ( + (getattr(r, "stdout", "") or getattr(r, "output", "")) + + (getattr(r, "stderr", "") or "") + + (f"\n{r.exception}" if getattr(r, "exception", None) else "") + ) + assert "entity_string" in combined + assert "template" in combined.lower() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_select_nodes") + else: + setattr(moldflow.Synergy, "cli_select_nodes", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_non_json_entlist_selection_shorthand(): + """Non-JSON mode should support direct selection-string shorthand for EntList params.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_select_nodes", None) + + class FakeEntList: + def __init__(self) -> None: + self.selected = None + + @cli_input_adapter(value_kind="selection_text", shorthand_supported=True) + def select_from_string(self, entity_string: str) -> None: + self.selected = entity_string + + def cli_select_nodes(self, nodes: "EntList | None") -> str: + return getattr(nodes, "selected", "missing") + + setattr(moldflow.Synergy, "cli_select_nodes", cli_select_nodes) + + class SelectionProvider: + def create_entity_list(self) -> FakeEntList: + return FakeEntList() + + class Sy: + @property + def property_editor(self) -> SelectionProvider: + return SelectionProvider() + + def cli_select_nodes(self, nodes: FakeEntList) -> str: + return getattr(nodes, "selected", "missing") + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_select_nodes", "nodes=N1,N2"]) + + assert r.exit_code == 0 + assert "N1,N2" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_select_nodes") + else: + setattr(moldflow.Synergy, "cli_select_nodes", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_non_json_entlist_selection_explicit_field(): + """Non-JSON dotted canonical field syntax should route through the wrapper adapter.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_select_nodes", None) + + class FakeEntList: + def __init__(self) -> None: + self.selected = None + + @cli_input_adapter(value_kind="selection_text", shorthand_supported=True) + def select_from_string(self, entity_string: str) -> None: + self.selected = entity_string + + def cli_select_nodes(self, nodes: "EntList | None") -> str: + return getattr(nodes, "selected", "missing") + + setattr(moldflow.Synergy, "cli_select_nodes", cli_select_nodes) + + class SelectionProvider: + def create_entity_list(self) -> FakeEntList: + return FakeEntList() + + class Sy: + @property + def property_editor(self) -> SelectionProvider: + return SelectionProvider() + + def cli_select_nodes(self, nodes: FakeEntList) -> str: + return getattr(nodes, "selected", "missing") + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke( + app, ["invoke", "synergy.cli_select_nodes", "nodes.entity_string=N1,N2"] + ) + + assert r.exit_code == 0 + assert "N1,N2" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_select_nodes") + else: + setattr(moldflow.Synergy, "cli_select_nodes", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_conflicting_non_json_entlist_selection_inputs(): + """Direct shorthand and explicit field syntax should be rejected as conflicting paths.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_select_nodes", None) + + class FakeEntList: + @cli_input_adapter(value_kind="selection_text", shorthand_supported=True) + def select_from_string(self, entity_string: str) -> None: + pass + + def cli_select_nodes(self, nodes: "EntList | None") -> str: + return "ok" + + setattr(moldflow.Synergy, "cli_select_nodes", cli_select_nodes) + + class SelectionProvider: + def create_entity_list(self) -> FakeEntList: + return FakeEntList() + + class Sy: + @property + def property_editor(self) -> SelectionProvider: + return SelectionProvider() + + def cli_select_nodes(self, nodes: FakeEntList) -> str: + return "ok" + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke( + app, ["invoke", "synergy.cli_select_nodes", "nodes=N1", "nodes.entity_string=N2"] + ) + + assert r.exit_code != 0 + combined = ( + (getattr(r, "stdout", "") or getattr(r, "output", "")) + + (getattr(r, "stderr", "") or "") + + (f"\n{r.exception}" if getattr(r, "exception", None) else "") + ) + assert "conflicting argument paths" in combined.lower() + assert "cli_select_nodes.nodes" in combined.lower() + assert "entity_string" in combined.lower() + assert "nodes" in combined.lower() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_select_nodes") + else: + setattr(moldflow.Synergy, "cli_select_nodes", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_json_vector_xyz_triplet(): + """Typed Vector JSON should accept the canonical xyz triplet field.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_accept_direction", None) + + class FakeVector: + def __init__(self) -> None: + self.xyz = None + + @cli_input_adapter( + value_kind="vector_triplet", preferred_field="xyz", shorthand_supported=True + ) + def set_xyz(self, x: float, y: float, z: float) -> None: + self.xyz = (x, y, z) + + def cli_accept_direction(self, direction: "Vector | None") -> str: + return "ok" + + setattr(moldflow.Synergy, "cli_accept_direction", cli_accept_direction) + + class Sy: + def create_vector(self) -> FakeVector: + return FakeVector() + + def cli_accept_direction(self, direction: FakeVector) -> str: + return str(direction.xyz) + + try: + payload = '{"direction":{"__type__":"Vector","xyz":[0,0,1]}}' + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke( + app, ["invoke", "synergy.cli_accept_direction", "--params-json", payload] + ) + + assert r.exit_code == 0 + assert "(0.0, 0.0, 1.0)" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_accept_direction") + else: + setattr(moldflow.Synergy, "cli_accept_direction", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_non_json_vector_shorthand(): + """Non-JSON mode should support direct triplet shorthand for Vector params.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_accept_direction", None) + + class FakeVector: + def __init__(self) -> None: + self.xyz = None + + @cli_input_adapter( + value_kind="vector_triplet", preferred_field="xyz", shorthand_supported=True + ) + def set_xyz(self, x: float, y: float, z: float) -> None: + self.xyz = (x, y, z) + + def cli_accept_direction(self, direction: "Vector | None") -> str: + return "ok" + + setattr(moldflow.Synergy, "cli_accept_direction", cli_accept_direction) + + class Sy: + def create_vector(self) -> FakeVector: + return FakeVector() + + def cli_accept_direction(self, direction: FakeVector) -> str: + return str(direction.xyz) + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_accept_direction", "direction=0,0,1"]) + + assert r.exit_code == 0 + assert "(0.0, 0.0, 1.0)" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_accept_direction") + else: + setattr(moldflow.Synergy, "cli_accept_direction", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_json_double_array_values(): + """Typed list-backed wrappers should accept the canonical values field in JSON mode.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_accept_levels", None) + + class FakeDoubleArray: + def __init__(self) -> None: + self.values = None + + @cli_input_adapter(value_kind="list_values", shorthand_supported=True) + def from_list(self, values: list[float]) -> None: + self.values = list(values) + + def cli_accept_levels(self, levels: "DoubleArray | None") -> str: + return "ok" + + setattr(moldflow.Synergy, "cli_accept_levels", cli_accept_levels) + + class Sy: + def create_double_array(self) -> FakeDoubleArray: + return FakeDoubleArray() + + def cli_accept_levels(self, levels: FakeDoubleArray) -> str: + return str(levels.values) + + try: + payload = '{"levels":{"__type__":"DoubleArray","values":[1.0,2.5]}}' + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke( + app, ["invoke", "synergy.cli_accept_levels", "--params-json", payload] + ) + + assert r.exit_code == 0 + assert "[1.0, 2.5]" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_accept_levels") + else: + setattr(moldflow.Synergy, "cli_accept_levels", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_json_double_array_values_without_type_tag(): + """JSON mode should infer DoubleArray when type tags are omitted.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_accept_levels", None) + + class FakeDoubleArray: + def __init__(self) -> None: + self.values = None + + @cli_input_adapter(value_kind="list_values", shorthand_supported=True) + def from_list(self, values: list[float]) -> None: + self.values = list(values) + + def cli_accept_levels(self, levels: "DoubleArray | None") -> str: + return "ok" + + setattr(moldflow.Synergy, "cli_accept_levels", cli_accept_levels) + + class Sy: + def create_double_array(self) -> FakeDoubleArray: + return FakeDoubleArray() + + def cli_accept_levels(self, levels: FakeDoubleArray) -> str: + return str(levels.values) + + try: + payload = '{"levels":{"values":[1.0,2.5]}}' + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke( + app, ["invoke", "synergy.cli_accept_levels", "--params-json", payload] + ) + + assert r.exit_code == 0 + assert "[1.0, 2.5]" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_accept_levels") + else: + setattr(moldflow.Synergy, "cli_accept_levels", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_json_double_array_values_accepts_plain_type_alias(): + """JSON mode should tolerate a plain matching type field in known context.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_accept_levels", None) + + class FakeDoubleArray: + def __init__(self) -> None: + self.values = None + + @cli_input_adapter(value_kind="list_values", shorthand_supported=True) + def from_list(self, values: list[float]) -> None: + self.values = list(values) + + def cli_accept_levels(self, levels: "DoubleArray | None") -> str: + return "ok" + + setattr(moldflow.Synergy, "cli_accept_levels", cli_accept_levels) + + class Sy: + def create_double_array(self) -> FakeDoubleArray: + return FakeDoubleArray() + + def cli_accept_levels(self, levels: FakeDoubleArray) -> str: + return str(levels.values) + + try: + payload = '{"levels":{"type":"DoubleArray","values":[1.0,2.5]}}' + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke( + app, ["invoke", "synergy.cli_accept_levels", "--params-json", payload] + ) + + assert r.exit_code == 0 + assert "[1.0, 2.5]" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_accept_levels") + else: + setattr(moldflow.Synergy, "cli_accept_levels", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_non_json_double_array_shorthand(): + """Non-JSON mode should support direct shorthand for list-backed wrappers.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_accept_levels", None) + + class FakeDoubleArray: + def __init__(self) -> None: + self.values = None + + @cli_input_adapter(value_kind="list_values", shorthand_supported=True) + def from_list(self, values: list[float]) -> None: + self.values = list(values) + + def cli_accept_levels(self, levels: "DoubleArray | None") -> str: + return "ok" + + setattr(moldflow.Synergy, "cli_accept_levels", cli_accept_levels) + + class Sy: + def create_double_array(self) -> FakeDoubleArray: + return FakeDoubleArray() + + def cli_accept_levels(self, levels: FakeDoubleArray) -> str: + return str(levels.values) + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_accept_levels", "levels=1.0,2.5"]) + + assert r.exit_code == 0 + assert "[1.0, 2.5]" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_accept_levels") + else: + setattr(moldflow.Synergy, "cli_accept_levels", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_with_non_json_vector_array_shorthand(): + """Non-JSON mode should support direct shorthand for vector-array wrappers.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_accept_points", None) + + class FakeVectorArray: + def __init__(self) -> None: + self.points: list[tuple[float, float, float]] = [] + + def clear(self) -> None: + self.points = [] + + @cli_input_adapter( + value_kind="vector_array_values", preferred_field="xyz", shorthand_supported=True + ) + def add_xyz(self, x: float, y: float, z: float) -> None: + self.points.append((x, y, z)) + + def cli_accept_points(self, points: "VectorArray | None") -> str: + return "ok" + + setattr(moldflow.Synergy, "cli_accept_points", cli_accept_points) + + class Sy: + def create_vector_array(self) -> FakeVectorArray: + return FakeVectorArray() + + def cli_accept_points(self, points: FakeVectorArray) -> str: + return str(points.points) + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_accept_points", "points=0,0,0;1,0,0"]) + + assert r.exit_code == 0 + assert "(0.0, 0.0, 0.0)" in r.stdout + assert "(1.0, 0.0, 0.0)" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_accept_points") + else: + setattr(moldflow.Synergy, "cli_accept_points", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_wraps_fallback_convert_value_errors_as_badparameter(): + """Fallback conversion errors should be surfaced as CLI validation failures.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_accept_dict", None) + + def cli_accept_dict(self, meta: dict) -> str: # type: ignore[name-defined] + return str(meta) + + setattr(moldflow.Synergy, "cli_accept_dict", cli_accept_dict) + + class Sy: + def cli_accept_dict(self, meta: dict) -> str: + return str(meta) + + try: + payload = '{"meta":{"inner":{"__type__":"NoSuchType","x":1}}}' + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke(app, ["invoke", "synergy.cli_accept_dict", "--params-json", payload]) + + assert r.exit_code == 2 + combined = ( + (getattr(r, "stdout", "") or getattr(r, "output", "")) + + (getattr(r, "stderr", "") or "") + + (f"\n{r.exception}" if getattr(r, "exception", None) else "") + ) + assert "invalid json value for parameter 'meta'" in combined.lower() + assert "nosuchtype" in combined.lower() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_accept_dict") + else: + setattr(moldflow.Synergy, "cli_accept_dict", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_accepts_bare_target_alias_and_normalizes_human_output(): + """Bare invoke targets should resolve through Synergy but render canonically.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_echo", None) + + def cli_echo(self, value: str) -> str: + return value + + setattr(moldflow.Synergy, "cli_echo", cli_echo) + + try: + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + r = runner.invoke(app, ["invoke", "cli_echo", "value=ok", "--dry-run"]) + + assert r.exit_code == 0 + out = (r.stdout or "") + (getattr(r, "stderr", "") or "") + assert "Dry run for synergy.cli_echo" in out + assert "invoke synergy.cli_echo value=" in out + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_echo") + else: + setattr(moldflow.Synergy, "cli_echo", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_class_like_segment_not_exposed_on_parent_preinstantiation(): + """Class-like path segments must exist on the parent object before invocation.""" + app = build_cli_app() + + class CliGhost: + def ping(self) -> str: + return "pong" + + orig_cls = getattr(moldflow, "CliGhost", None) + setattr(moldflow, "CliGhost", CliGhost) + try: + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + r = runner.invoke(app, ["invoke", "synergy.cli_ghost.ping"]) + + assert r.exit_code != 0 + out = (r.stdout or "") + (getattr(r, "stderr", "") or "") + assert "does not resolve as an attribute" in out.lower() + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + finally: + if orig_cls is None: + delattr(moldflow, "CliGhost") + else: + setattr(moldflow, "CliGhost", orig_cls) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_nested_kwargs_key_is_rejected_early(): + """Nested key syntax is invalid for **kwargs parameters and should fail pre-instantiation.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_kwargs_only", None) + + def cli_kwargs_only(self, **kwargs) -> str: + return str(kwargs) + + setattr(moldflow.Synergy, "cli_kwargs_only", cli_kwargs_only) + try: + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + r = runner.invoke(app, ["invoke", "synergy.cli_kwargs_only", "alpha.beta=1"]) + + assert r.exit_code != 0 + out = (r.stdout or "") + (getattr(r, "stderr", "") or "") + assert "nested argument" in out.lower() or "not supported" in out.lower() + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_kwargs_only") + else: + setattr(moldflow.Synergy, "cli_kwargs_only", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_non_public_target_segments_preinstantiation(): + """Invoke should reject private/dunder segments before touching Synergy.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke(app, ["invoke", "synergy.__class__"]) + + assert result.exit_code != 0 + assert ( + "non-public segment" + in ((result.stdout or "") + (getattr(result, "stderr", "") or "")).lower() + ) + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_empty_target_segment_preinstantiation(): + """Invoke should reject malformed dotted targets before touching Synergy.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke(app, ["invoke", "synergy..open_project"]) + + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "empty path segment" in combined.lower() + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_empty_argument_path_segment_preinstantiation(): + """Invoke should reject malformed argument paths before touching Synergy.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke(app, ["invoke", "synergy.open_project", "name..x=value"]) + + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "empty path segment" in combined.lower() + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_positional_only_params_fail_with_actionable_error(): + """Positional-only signatures should fail early with clear guidance.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_pos_only", None) + + def _cli_pos_only(self, value, /): + return value + + setattr(moldflow.Synergy, "cli_pos_only", _cli_pos_only) + + class Sy: + def cli_pos_only(self, value, /): + return value + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.cli_pos_only", "value=5"]) + assert result.exit_code != 0 + stderr_text = (getattr(result, "stderr_bytes", b"") or b"").decode("utf-8", "ignore") + combined = f"{result.stdout or ''}\n{stderr_text}\n{result.exception or ''}" + assert "positional-only parameters" in combined.lower() + assert "not supported" in combined.lower() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_pos_only") + else: + setattr(moldflow.Synergy, "cli_pos_only", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_conflicting_argument_paths(): + """A direct assignment and nested assignment for same param should be rejected.""" + app = build_cli_app() + + class Wrapper: + def __init__(self) -> None: + self.x = 0 + + class Sy: + def create_vector(self): + return Wrapper() + + def do(self, obj) -> str: + return "ok" + + orig = getattr(moldflow.Synergy, "do", None) + + def _do(self, obj: "Vector"): + return "ok" + + setattr(moldflow.Synergy, "do", _do) + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.do", "do.obj=1", "do.obj.x=2"]) + assert result.exit_code != 0 + stderr_text = (getattr(result, "stderr_bytes", b"") or b"").decode("utf-8", "ignore") + combined = f"{result.stdout or ''}\n{stderr_text}\n{result.exception or ''}" + assert "conflicting argument paths" in combined.lower() + finally: + if orig is None: + delattr(moldflow.Synergy, "do") + else: + setattr(moldflow.Synergy, "do", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_none_forward_ref_object_pass_through(): + """Passing 'null' for an optional scalar param yields None and is accepted.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_accept_optional", None) + + def cli_accept_optional(self, opt=None): + return f"opt={opt!r}" + + setattr(moldflow.Synergy, "cli_accept_optional", cli_accept_optional) + + class Sy: + def cli_accept_optional(self, opt=None): + return f"opt={opt!r}" + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.cli_accept_optional", "opt=null"]) + assert result.exit_code == 0 + assert "opt=None" in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_accept_optional") + else: + setattr(moldflow.Synergy, "cli_accept_optional", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_nested_attribute_invalid_path(): + """Setting a nested attribute path that doesn't exist should error without side-effects.""" + app = build_cli_app() + + class Wrapper: + def __init__(self) -> None: + self.exists = 1 + + class Fake: + def do(self, obj: Wrapper) -> str: + return "ok" + + sy = Fake() + orig = getattr(moldflow.Synergy, "do", None) + + def _do(self, obj): + return "ok" + + setattr(moldflow.Synergy, "do", _do) + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + res = runner.invoke(app, ["invoke", "synergy.do", "do.obj.nonexist=5"]) + + # Expect a non-zero exit; exact error text varies by Click/Typer versions. + assert res.exit_code != 0 + finally: + if orig is None: + delattr(moldflow.Synergy, "do") + else: + setattr(moldflow.Synergy, "do", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_readonly_attribute_assignment(): + """Attempting to set a read-only property should produce a helpful error.""" + app = build_cli_app() + + class Wrapper: + @property + def ro(self): + return 5 + + class Fake: + def do(self, obj: Wrapper) -> str: + return "ok" + + sy = Fake() + orig = getattr(moldflow.Synergy, "do", None) + + def _do(self, obj): + return "ok" + + setattr(moldflow.Synergy, "do", _do) + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + res = runner.invoke(app, ["invoke", "synergy.do", "do.obj.ro=10"]) + + # Expect a non-zero exit; exact error text varies by Click/Typer versions. + assert res.exit_code != 0 + finally: + if orig is None: + delattr(moldflow.Synergy, "do") + else: + setattr(moldflow.Synergy, "do", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_factory_failure_propagation(): + """If a Synergy factory raises while creating a wrapper, the CLI surfaces the error.""" + app = build_cli_app() + + class Sy: + @property + def import_options(self): + raise RuntimeError("factory failed") + + def import_file(self, file, import_options=None): + return "ok" + + with patch("moldflow_cli.factories.get_synergy", return_value=Sy()), patch( + "moldflow_cli.context.get_synergy", return_value=Sy() + ): + # Force wrapper/factory access by assigning nested attribute on import_options. + res = runner.invoke( + app, ["invoke", "synergy.import_file", "file=x", "import_file.import_options.foo=1"] + ) + + # Expect a non-zero exit; the exact message may vary across Click/Typer versions. + assert res.exit_code != 0 + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_unicode_and_path_escaping(): + """Ensure unicode and Windows paths round-trip through parsing.""" + app = build_cli_app() + + class Fake: + def open_path(self, path: str, name: str) -> str: + return f"path={path};name={name}" + + sy = Fake() + orig = getattr(moldflow.Synergy, "open_path", None) + + def _open_path(self, path: str, name: str) -> str: + return f"path={path};name={name}" + + setattr(moldflow.Synergy, "open_path", _open_path) + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + val = r"C:\path\to\file with spaces.txt" + r = runner.invoke(app, ["invoke", "synergy.open_path", f'path={val}', "name=ünïçødé"]) + assert r.exit_code == 0 + assert "C:\\path\\to\\file" in r.stdout or "ünïçødé" in r.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "open_path") + else: + setattr(moldflow.Synergy, "open_path", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_error_exit_codes(): + """Assert non-zero exit codes and helpful messages for common failure modes.""" + app = build_cli_app() + + # missing param / unknown param / parse error should not instantiate real Synergy. + class Sy: + def open_project(self, path: int): + # Enforce type at runtime so CLI surfaces a parse/type error. + if not isinstance(path, int): + raise TypeError("path must be int") + return f"opened:{path}" + + sy = Sy() + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + # missing param + r1 = runner.invoke(app, ["invoke", "synergy.open_project"]) + # unknown param + r2 = runner.invoke(app, ["invoke", "synergy.open_project", "bad=1"]) + # parse error (pass non-numeric to int) + r3 = runner.invoke(app, ["invoke", "synergy.open_project", "path=not-an-int"]) + + assert r1.exit_code != 0 + assert r2.exit_code != 0 + assert r3.exit_code != 0 + out1 = (getattr(r1, "stdout", "") or getattr(r1, "output", "")) + ( + getattr(r1, "stderr", "") or "" + ) + assert "missing required parameter" in out1.lower() + assert "missing required parameter 'path'" in out1.lower() + assert "open_project.path" not in out1 + assert "(self," not in out1 + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_help_shows_arg_syntax(): + """Invoke --help should demonstrate the step.param.attr=value routing syntax.""" + app = build_cli_app() + result = runner.invoke(app, ["--no-color", "invoke", "--help"]) + help_text = strip_ansi(result.stdout).lower() + assert result.exit_code == 0 + assert "find_plot_by_name.plot_name" in help_text or "step.param.attr" in help_text + assert "duplicate/conflicting paths" in help_text + assert "rejected" in help_text + assert "positional-only" in help_text + assert "arrays/scalars" in help_text + assert "template summary" in help_text + assert "structured" in help_text and "stdout" in help_text + assert all(token in help_text for token in ("without", "changing", "stdout", "mode")) + assert all(token in help_text for token in ("line-delimited", "json", "trace", "stderr")) + assert "--json" in help_text + assert "--json-output" in help_text + assert "legacy alias" in help_text + assert "canonical" in help_text + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_mixed_case_target_renders_canonical_target_in_dry_run_output(): + """Dry-run output should show canonical target casing even when input target is mixed-case.""" + app = build_cli_app() + result = runner.invoke(app, ["invoke", "OPEN_PROJECT", "--dry-run", "path=C:/Temp/demo.mfproj"]) + assert result.exit_code == 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "Dry run for synergy.open_project" in combined + assert "invoke synergy.open_project path=" in combined + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_large_number_of_args_performance_hint(): + """Sanity check: invoking with many parameters parses and executes.""" + app = build_cli_app() + + # Create a function that accepts **kwargs but exposes a large signature. + param_count = 120 + + def cli_many_args(self, **kwargs): + return f"count={len(kwargs)}" + + sig_params = [inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)] + sig_params.extend( + [ + inspect.Parameter(f"p{i}", inspect.Parameter.POSITIONAL_OR_KEYWORD, default=None) + for i in range(param_count) + ] + ) + cli_many_args.__signature__ = inspect.Signature(sig_params) + + orig = getattr(moldflow.Synergy, "cli_many_args", None) + setattr(moldflow.Synergy, "cli_many_args", cli_many_args) + + class Sy: + def cli_many_args(self, **kwargs): + return f"count={len(kwargs)}" + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + # Build argument list for half of the params to keep command length reasonable. + args = ["invoke", "synergy.cli_many_args"] + [ + f"p{i}=1" for i in range(param_count // 2) + ] + result = runner.invoke(app, args) + assert result.exit_code == 0 + assert "count=" in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_many_args") + else: + setattr(moldflow.Synergy, "cli_many_args", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_bool_and_none_parsing_variants(): + """_parse_scalar should accept YES/NO/null and numeric parsing for invoke args.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_bool_and_none", None) + + def cli_bool_and_none(self, flag: bool, opt: int | None) -> str: # type: ignore[name-defined] + return f"flag={flag};opt={opt}" + + setattr(moldflow.Synergy, "cli_bool_and_none", cli_bool_and_none) + + class SynergyForTest: + def cli_bool_and_none(self, flag: bool, opt: int | None) -> str: + return f"flag={flag};opt={opt}" + + sy = SynergyForTest() + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + result = runner.invoke( + app, ["invoke", "synergy.cli_bool_and_none", "flag=YES", "opt=null"] + ) + + assert result.exit_code == 0 + assert "flag=True" in result.stdout + assert "opt=None" in result.stdout or "opt=null" in result.stdout.lower() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_bool_and_none") + else: + setattr(moldflow.Synergy, "cli_bool_and_none", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_nested_attribute_error_is_reported_as_badparameter(): + """Missing nested attributes should produce a clean CLI validation error.""" + app = build_cli_app() + + class Fake: + def create_vector(self): + class Vec: + x = 0 + y = 0 + z = 0 + + return Vec() + + def do(self, obj) -> str: + return "ok" + + sy = Fake() + orig = getattr(moldflow.Synergy, "do", None) + + def _do(self, obj: "Vector"): + return "ok" + + setattr(moldflow.Synergy, "do", _do) + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + result = runner.invoke(app, ["invoke", "synergy.do", "do.obj.nonexist.leaf=5"]) + assert result.exit_code != 0 + stderr_text = (getattr(result, "stderr_bytes", b"") or b"").decode("utf-8", "ignore") + combined = f"{stderr_text}\n{result.exception}" + assert "invalid nested argument path" in combined.lower() + finally: + if orig is None: + delattr(moldflow.Synergy, "do") + else: + setattr(moldflow.Synergy, "do", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_nested_leaf_attribute_must_exist(): + """Leaf typo in nested paths should fail instead of creating dynamic attributes.""" + app = build_cli_app() + + class Wrapper: + def __init__(self) -> None: + self.x = 0 + self.y = 0 + self.z = 0 + + class Fake: + def create_vector(self): + return Wrapper() + + def do(self, obj) -> str: + return "ok" + + sy = Fake() + orig = getattr(moldflow.Synergy, "do", None) + + def _do(self, obj: "Vector"): + return "ok" + + setattr(moldflow.Synergy, "do", _do) + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + result = runner.invoke(app, ["invoke", "synergy.do", "do.obj.typo_leaf=5"]) + assert result.exit_code != 0 + stderr_text = (getattr(result, "stderr_bytes", b"") or b"").decode("utf-8", "ignore") + combined = f"{stderr_text}\n{result.exception}" + assert "invalid nested argument path" in combined.lower() + assert "typo_leaf" in combined + finally: + if orig is None: + delattr(moldflow.Synergy, "do") + else: + setattr(moldflow.Synergy, "do", orig) diff --git a/tests/api/unit_tests/test_cli_invoke_validation.py b/tests/api/unit_tests/test_cli_invoke_validation.py new file mode 100644 index 0000000..45a9195 --- /dev/null +++ b/tests/api/unit_tests/test_cli_invoke_validation.py @@ -0,0 +1,214 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Focused unit tests for invoke argument validation helpers.""" + +from __future__ import annotations + +from unittest.mock import patch +import json + +import pytest +import typer +from typer.testing import CliRunner +import moldflow + +from moldflow_cli.commands import build_cli_app +from moldflow_cli.invoke_binding import _append_payload_items, _validate_step_item_path_conflicts + +runner = CliRunner() + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_non_object_json_payload(): + """Non-object JSON payloads should fail with a clear contract error.""" + parsed_items = [] + method_steps = [{"name": "cli_echo"}] + with pytest.raises(typer.BadParameter, match="JSON parameters must be a JSON object"): + _append_payload_items(parsed_items, [1, 2, 3], method_steps) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_duplicate_argument_path(): + """The same argument path provided multiple times should be rejected.""" + items = [(["value"], "a", "cli"), (["value"], "b", "cli")] + with pytest.raises(typer.BadParameter, match="Duplicate argument path"): + _validate_step_item_path_conflicts("cli_echo", items) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_duplicate_argument_path_case_insensitive_after_normalization(): + """Case-variant duplicate params should still be rejected.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_echo", None) + + def _cli_echo(self, value: str): + del self + return value + + setattr(moldflow.Synergy, "cli_echo", _cli_echo) + try: + + class Sy: + """Minimal synergy test double for cli_echo.""" + + def cli_echo(self, value: str): + """Echo helper used for duplicate-argument validation.""" + return value + + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.cli_echo", "Value=a", "value=b"]) + + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "duplicate argument path" in combined.lower() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_echo") + else: + setattr(moldflow.Synergy, "cli_echo", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_single_step_json_wrapper_is_not_unwrapped_when_param_matches_step_name(): + """Do not unwrap when method really expects a param named like the step.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_echo_dict", None) + + def _cli_echo_dict(self, cli_echo_dict): + del self + return cli_echo_dict + + setattr(moldflow.Synergy, "cli_echo_dict", _cli_echo_dict) + + class Sy: + """Fake Synergy object exposing cli_echo_dict.""" + + def cli_echo_dict(self, cli_echo_dict): + """Echo dict payload unchanged.""" + return cli_echo_dict + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, + [ + "invoke", + "synergy.cli_echo_dict", + "--params-json", + '{"cli_echo_dict": {"a": 1}}', + "--json-output", + ], + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["ok"] is True + assert payload["result"]["a"] == 1 + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_echo_dict") + else: + setattr(moldflow.Synergy, "cli_echo_dict", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_describe_json_includes_schema_version(): + """Describe structured output should include a stable schema version.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "synergy.open_project", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["schema_version"] == "1.0" + assert "target" in payload and "params" in payload + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_json_object_output_includes_schema_version(): + """Object-like invoke JSON output should include schema_version.""" + app = build_cli_app() + orig = getattr(moldflow.Synergy, "cli_echo_obj", None) + + def _cli_echo_obj(self): + del self + + class Obj: + """Simple object-like return payload.""" + + def __init__(self): + self.name = "x" + + return Obj() + + setattr(moldflow.Synergy, "cli_echo_obj", _cli_echo_obj) + + class Sy: + """Fake Synergy object exposing cli_echo_obj.""" + + def cli_echo_obj(self): + """Return a simple object-like payload.""" + + class Obj: + """Simple object-like return payload.""" + + def __init__(self): + self.name = "x" + + return Obj() + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.cli_echo_obj", "--json-output"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["schema_version"] == "1.0" + assert payload["ok"] is True + assert payload["result"]["attributes"]["name"] == "x" + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_echo_obj") + else: + setattr(moldflow.Synergy, "cli_echo_obj", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_invalid_target_identifier_segment(): + """Invalid target segments should fail fast with a clear validation error.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke(app, ["invoke", "synergy.open-project"]) + assert result.exit_code != 0 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "must be a valid identifier" in combined.lower() + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_rejects_invalid_argument_path_identifier_segment(): + """Invalid argument path segments should be rejected before invocation.""" + app = build_cli_app() + with patch("moldflow_cli.context.get_synergy") as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy" + ) as mock_fact_synergy: + result = runner.invoke(app, ["invoke", "synergy.open_project", "pa-th=test.mpi"]) + assert result.exit_code != 0 + assert result.exception is not None + mock_ctx_synergy.assert_not_called() + mock_fact_synergy.assert_not_called() diff --git a/tests/api/unit_tests/test_cli_output.py b/tests/api/unit_tests/test_cli_output.py new file mode 100644 index 0000000..6612207 --- /dev/null +++ b/tests/api/unit_tests/test_cli_output.py @@ -0,0 +1,905 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Focused invoke output serialization tests for moldflow CLI.""" + +# Test modules intentionally use many tiny inline doubles to mirror CLI call patterns. +# pylint: disable=missing-function-docstring,missing-class-docstring,unused-argument,redefined-outer-name + +from __future__ import annotations + +from unittest.mock import patch +import json + +import pytest +from rich import box +from typer.testing import CliRunner +import moldflow + +from moldflow_cli.commands import build_cli_app +from moldflow_cli.output_utils import human_table_kwargs + +runner = CliRunner() +_UNICODE_BOX_CHARS = ("╭", "╮", "╰", "╯", "│", "─", "┌", "┐", "└", "┘") + + +@pytest.mark.cli +@pytest.mark.unit +def test_captured_help_uses_ascii_panels(): + """Captured help output should avoid Unicode panel borders.""" + app = build_cli_app() + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert not any(char in result.stdout for char in _UNICODE_BOX_CHARS) + + +@pytest.mark.cli +@pytest.mark.unit +def test_captured_error_uses_ascii_panels(): + """Captured error output should avoid Unicode panel borders.""" + app = build_cli_app() + result = runner.invoke(app, ["describe", "boundary_conditions.create_entity_list"]) + assert result.exit_code != 0 + text = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert not any(char in text for char in _UNICODE_BOX_CHARS) + + +@pytest.mark.cli +@pytest.mark.unit +def test_human_table_kwargs_use_ascii_when_output_is_captured(): + """Captured human table output should use ASCII borders for portability.""" + + class CapturedConsole: + is_terminal = False + is_dumb_terminal = False + + kwargs = human_table_kwargs(CapturedConsole()) + assert kwargs == {"box": box.ASCII, "safe_box": True} + + +def _unwrap_invoke_envelope(stdout_text: str): + payload = json.loads(stdout_text) + assert "ok" in payload and "result" in payload and "result_type" in payload + return payload["result"] + + +class FakeEntList: + """Fake EntList-like wrapper exposing convert_to_string and size.""" + + def __init__(self) -> None: + self._vals = ["node1", "node2", "node3"] + + @property + def size(self) -> int: + return len(self._vals) + + def convert_to_string(self) -> str: + return ",".join(self._vals) + + +class FakeDoubleArray: + """Fake DoubleArray-like wrapper exposing to_list and size.""" + + def __init__(self) -> None: + self._vals = [1.0, 2.5, 3.75] + + @property + def size(self) -> int: + return len(self._vals) + + def to_list(self) -> list[float]: + return list(self._vals) + + +class FakeVectorArray: + """Fake VectorArray-like wrapper exposing x/y/z and size.""" + + def __init__(self) -> None: + self._vals = [(0.0, 0.0, 0.0), (1.0, 2.0, 3.0)] + + @property + def size(self) -> int: + return len(self._vals) + + def x(self, index: int) -> float: + return self._vals[index][0] + + def y(self, index: int) -> float: + return self._vals[index][1] + + def z(self, index: int) -> float: + return self._vals[index][2] + + +class FakeProperty: + """Fake Property-like wrapper exposing id/name/type.""" + + def __init__(self) -> None: + self.id = 42 + self.name = "TestProperty" + self.type = 7 + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_synergy_entlist_human_and_json(): + """Ensure EntList-like wrappers render nicely in text and JSON modes.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_return_entlist", None) + + def cli_return_entlist(self) -> FakeEntList: # type: ignore[unused-argument] + return FakeEntList() + + setattr(moldflow.Synergy, "cli_return_entlist", cli_return_entlist) + + class SynergyForTest: + def cli_return_entlist(self) -> FakeEntList: + return FakeEntList() + + sy = SynergyForTest() + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy) as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ) as mock_fact_synergy: + # Human-readable mode + result_txt = runner.invoke(app, ["invoke", "synergy.cli_return_entlist"]) + # JSON mode + result_json = runner.invoke( + app, ["invoke", "synergy.cli_return_entlist", "--json-output"] + ) + + assert result_txt.exit_code == 0 + assert result_json.exit_code == 0 + assert mock_ctx_synergy.call_count >= 1 or mock_fact_synergy.call_count >= 1 + assert "node1,node2,node3" in result_txt.stdout + + payload = json.loads(result_json.stdout) + data = payload["result"] + assert payload["ok"] is True + assert data["type"] == "FakeEntList" + assert data["size"] == 3 + assert data["string"] == "node1,node2,node3" + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_return_entlist") + else: + setattr(moldflow.Synergy, "cli_return_entlist", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_synergy_double_array_human_output_is_labeled(): + """Array-like human output should include a label instead of a bare JSON fragment.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_return_double_array", None) + + def cli_return_double_array(self) -> FakeDoubleArray: # type: ignore[unused-argument] + return FakeDoubleArray() + + setattr(moldflow.Synergy, "cli_return_double_array", cli_return_double_array) + + class SynergyForTest: + def cli_return_double_array(self) -> FakeDoubleArray: + return FakeDoubleArray() + + sy = SynergyForTest() + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + result = runner.invoke(app, ["invoke", "synergy.cli_return_double_array"]) + + assert result.exit_code == 0 + assert "FakeDoubleArray values (3 items):" in result.stdout + assert "1. 1.0" in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_return_double_array") + else: + setattr(moldflow.Synergy, "cli_return_double_array", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_synergy_double_array_json(): + """Ensure DoubleArray-like wrappers render as value lists in JSON mode.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_return_double_array", None) + + def cli_return_double_array(self) -> FakeDoubleArray: # type: ignore[unused-argument] + return FakeDoubleArray() + + setattr(moldflow.Synergy, "cli_return_double_array", cli_return_double_array) + + class SynergyForTest: + def cli_return_double_array(self) -> FakeDoubleArray: + return FakeDoubleArray() + + sy = SynergyForTest() + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy) as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ) as mock_fact_synergy: + result = runner.invoke( + app, ["invoke", "synergy.cli_return_double_array", "--json-output"] + ) + + assert result.exit_code == 0 + assert mock_ctx_synergy.call_count >= 1 or mock_fact_synergy.call_count >= 1 + data = _unwrap_invoke_envelope(result.stdout) + assert data["type"] == "FakeDoubleArray" + assert data["size"] == 3 + assert data["values"] == [1.0, 2.5, 3.75] + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_return_double_array") + else: + setattr(moldflow.Synergy, "cli_return_double_array", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_synergy_vector_array_json(): + """Ensure VectorArray-like wrappers render as coordinate lists in JSON mode.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_return_vector_array", None) + + def cli_return_vector_array(self) -> FakeVectorArray: # type: ignore[unused-argument] + return FakeVectorArray() + + setattr(moldflow.Synergy, "cli_return_vector_array", cli_return_vector_array) + + class SynergyForTest: + def cli_return_vector_array(self) -> FakeVectorArray: + return FakeVectorArray() + + sy = SynergyForTest() + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy) as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ) as mock_fact_synergy: + result = runner.invoke( + app, ["invoke", "synergy.cli_return_vector_array", "--json-output"] + ) + + assert result.exit_code == 0 + assert mock_ctx_synergy.call_count >= 1 or mock_fact_synergy.call_count >= 1 + data = _unwrap_invoke_envelope(result.stdout) + assert data["type"] == "FakeVectorArray" + assert data["size"] == 2 + assert data["values"] == [{"x": 0.0, "y": 0.0, "z": 0.0}, {"x": 1.0, "y": 2.0, "z": 3.0}] + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_return_vector_array") + else: + setattr(moldflow.Synergy, "cli_return_vector_array", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_synergy_property_json(): + """Ensure Property-like wrappers render with id/name/type in JSON mode.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_return_property", None) + + def cli_return_property(self) -> FakeProperty: # type: ignore[unused-argument] + return FakeProperty() + + setattr(moldflow.Synergy, "cli_return_property", cli_return_property) + + class SynergyForTest: + def cli_return_property(self) -> FakeProperty: + return FakeProperty() + + sy = SynergyForTest() + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy) as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ) as mock_fact_synergy: + result = runner.invoke(app, ["invoke", "synergy.cli_return_property", "--json-output"]) + + assert result.exit_code == 0 + assert mock_ctx_synergy.call_count >= 1 or mock_fact_synergy.call_count >= 1 + data = _unwrap_invoke_envelope(result.stdout) + assert data["type"] == "FakeProperty" + assert data["id"] == 42 + assert data["name"] == "TestProperty" + assert data["prop_type"] == 7 + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_return_property") + else: + setattr(moldflow.Synergy, "cli_return_property", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_json_serialization_does_not_probe_arbitrary_properties(): + """Fallback serialization should use instance fields without touching property getters.""" + app = build_cli_app() + + class RiskyWrapper: + property_calls = 0 + + def __init__(self) -> None: + self.safe_value = 123 + + @property + def expensive_property(self) -> int: + type(self).property_calls += 1 + raise RuntimeError("property getter should not be called during serialization") + + orig = getattr(moldflow.Synergy, "cli_return_risky_wrapper", None) + + def cli_return_risky_wrapper(self) -> RiskyWrapper: # type: ignore[unused-argument] + return RiskyWrapper() + + setattr(moldflow.Synergy, "cli_return_risky_wrapper", cli_return_risky_wrapper) + + class SynergyForTest: + def cli_return_risky_wrapper(self) -> RiskyWrapper: + return RiskyWrapper() + + sy = SynergyForTest() + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + result = runner.invoke( + app, ["invoke", "synergy.cli_return_risky_wrapper", "--json-output"] + ) + + assert result.exit_code == 0 + data = _unwrap_invoke_envelope(result.stdout) + assert data["type"] == "RiskyWrapper" + assert data["attributes"]["safe_value"] == 123 + assert RiskyWrapper.property_calls == 0 + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_return_risky_wrapper") + else: + setattr(moldflow.Synergy, "cli_return_risky_wrapper", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_human_output_labels_object_attributes(): + """Generic object human output should name the object before showing attributes.""" + app = build_cli_app() + + class RiskyWrapper: + def __init__(self) -> None: + self.safe_value = 123 + + orig = getattr(moldflow.Synergy, "cli_return_risky_wrapper_human", None) + + def cli_return_risky_wrapper_human(self) -> RiskyWrapper: # type: ignore[unused-argument] + return RiskyWrapper() + + setattr(moldflow.Synergy, "cli_return_risky_wrapper_human", cli_return_risky_wrapper_human) + + class SynergyForTest: + def cli_return_risky_wrapper_human(self) -> RiskyWrapper: + return RiskyWrapper() + + sy = SynergyForTest() + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + result = runner.invoke(app, ["invoke", "synergy.cli_return_risky_wrapper_human"]) + + assert result.exit_code == 0 + assert "RiskyWrapper attributes:" in result.stdout + assert '"safe_value": 123' in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_return_risky_wrapper_human") + else: + setattr(moldflow.Synergy, "cli_return_risky_wrapper_human", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_synergy_json_scalar_bool(): + """Ensure JSON mode emits primitive booleans without extra quoting.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_return_bool", None) + + def cli_return_bool(self) -> bool: # type: ignore[unused-argument] + return True + + setattr(moldflow.Synergy, "cli_return_bool", cli_return_bool) + + class SynergyForTest: + def cli_return_bool(self) -> bool: + return True + + sy = SynergyForTest() + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy) as mock_ctx_synergy, patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ) as mock_fact_synergy: + result = runner.invoke(app, ["invoke", "synergy.cli_return_bool", "--json-output"]) + + assert result.exit_code == 0 + assert mock_ctx_synergy.call_count >= 1 or mock_fact_synergy.call_count >= 1 + payload = json.loads(result.stdout) + assert payload["ok"] is True + assert payload["result"] is True + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_return_bool") + else: + setattr(moldflow.Synergy, "cli_return_bool", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_return_complex_structure_roundtrip_json(): + """Return nested fake structures and ensure --json emits nested JSON shapes.""" + app = build_cli_app() + + class FakeEntList: + def __init__(self): + self._vals = ["a", "b"] + + @property + def size(self): + return len(self._vals) + + def convert_to_string(self): + return ",".join(self._vals) + + class FakeVectorArray: + def __init__(self): + self._vals = [(1.0, 2.0, 3.0)] + + @property + def size(self): + return len(self._vals) + + def x(self, i): + return self._vals[i][0] + + def y(self, i): + return self._vals[i][1] + + def z(self, i): + return self._vals[i][2] + + class Fake: + def complex(self): + return {"list": FakeEntList(), "vectors": FakeVectorArray()} + + sy = Fake() + orig = getattr(moldflow.Synergy, "complex", None) + + def _complex(self): + return {"list": FakeEntList(), "vectors": FakeVectorArray()} + + setattr(moldflow.Synergy, "complex", _complex) + try: + with patch("moldflow_cli.context.get_synergy", return_value=sy), patch( + "moldflow_cli.factories.get_synergy", return_value=sy + ): + r = runner.invoke(app, ["invoke", "synergy.complex", "--json-output"]) + assert r.exit_code == 0 + payload = _unwrap_invoke_envelope(getattr(r, "stdout", "") or getattr(r, "output", "")) + assert isinstance(payload, dict) + assert payload["list"]["type"] == "FakeEntList" + assert payload["list"]["string"] == "a,b" + assert payload["vectors"]["type"] == "FakeVectorArray" + assert payload["vectors"]["values"] == [{"x": 1.0, "y": 2.0, "z": 3.0}] + finally: + if orig is None: + delattr(moldflow.Synergy, "complex") + else: + setattr(moldflow.Synergy, "complex", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_json_output_and_file_output(tmp_path): + """--json-output prints JSON and --json-file-output writes JSON to a file.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_return_entlist", None) + + def cli_return_entlist(self) -> FakeEntList: # type: ignore[unused-argument] + return FakeEntList() + + setattr(moldflow.Synergy, "cli_return_entlist", cli_return_entlist) + + class Sy: + def cli_return_entlist(self) -> FakeEntList: + return FakeEntList() + + out_file = tmp_path / "out.json" + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke( + app, + [ + "invoke", + "synergy.cli_return_entlist", + "--json-output", + "--json-file-output", + str(out_file), + ], + ) + + assert r.exit_code == 0 + # stdout should contain JSON-like output + assert "FakeEntList" in r.stdout or "node1" in r.stdout + # file should exist and contain JSON-ish content + assert out_file.exists() + content = out_file.read_text(encoding="utf-8") + assert "FakeEntList" in content or "node1" in content + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_return_entlist") + else: + setattr(moldflow.Synergy, "cli_return_entlist", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_json_file_output_preserves_human_stdout_by_default(tmp_path): + """--json-file-output should write JSON to a file without forcing JSON stdout.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_return_entlist", None) + + def cli_return_entlist(self) -> FakeEntList: # type: ignore[unused-argument] + return FakeEntList() + + setattr(moldflow.Synergy, "cli_return_entlist", cli_return_entlist) + + class Sy: + def cli_return_entlist(self) -> FakeEntList: + return FakeEntList() + + out_file = tmp_path / "out.json" + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + r = runner.invoke( + app, ["invoke", "synergy.cli_return_entlist", "--json-file-output", str(out_file)] + ) + + assert r.exit_code == 0 + assert "node1,node2,node3" in r.stdout + assert '"schema_version"' not in r.stdout + assert "Wrote structured output to" in r.stdout + assert out_file.exists() + payload = json.loads(out_file.read_text(encoding="utf-8")) + assert payload["result"]["type"] == "FakeEntList" + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_return_entlist") + else: + setattr(moldflow.Synergy, "cli_return_entlist", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_json_output_preserves_markup_like_string_value(): + """Invoke --json should preserve bracketed text literally, not rich-parse it.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_markup_value", None) + + def cli_markup_value(self) -> str: + return "[bold]keep-me[/bold]" + + setattr(moldflow.Synergy, "cli_markup_value", cli_markup_value) + + class Sy: + def cli_markup_value(self) -> str: + return "[bold]keep-me[/bold]" + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.cli_markup_value", "--json-output"]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["ok"] is True + assert payload["result"] == "[bold]keep-me[/bold]" + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_markup_value") + else: + setattr(moldflow.Synergy, "cli_markup_value", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_plain_output_preserves_markup_like_string_value(): + """Invoke plain output should print bracketed text literally (no rich markup parsing).""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_markup_value", None) + + def cli_markup_value(self) -> str: + return "[bold]keep-me[/bold]" + + setattr(moldflow.Synergy, "cli_markup_value", cli_markup_value) + + class Sy: + def cli_markup_value(self) -> str: + return "[bold]keep-me[/bold]" + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.cli_markup_value"]) + + assert result.exit_code == 0 + assert "[bold]keep-me[/bold]" in result.stdout + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_markup_value") + else: + setattr(moldflow.Synergy, "cli_markup_value", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_json_output_reports_serialization_errors_with_cli_validation_exit(): + """Structured output serialization errors should surface as BadParameter (exit code 2).""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_markup_value", None) + + def cli_markup_value(self) -> str: + return "ok" + + setattr(moldflow.Synergy, "cli_markup_value", cli_markup_value) + + class Sy: + def cli_markup_value(self) -> str: + return "ok" + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ), patch("moldflow_cli.output_utils._json.dumps", side_effect=TypeError("json exploded")): + result = runner.invoke(app, ["invoke", "synergy.cli_markup_value", "--json-output"]) + + assert result.exit_code == 2 + combined = (result.stdout or "") + (getattr(result, "stderr", "") or "") + assert "failed to render json output" in combined.lower() + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_markup_value") + else: + setattr(moldflow.Synergy, "cli_markup_value", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_json_output_unknown_wrapper_fallback_is_structured(): + """Unknown wrappers should serialize to structured JSON via public attributes.""" + app = build_cli_app() + + class UnknownWrapper: + def __init__(self) -> None: + self.alpha = 3 + self.beta = "ok" + self._private = "secret" + + def helper(self): # pragma: no cover - callables are intentionally skipped + return 1 + + orig = getattr(moldflow.Synergy, "cli_unknown_wrapper", None) + + def cli_unknown_wrapper(self): + return UnknownWrapper() + + setattr(moldflow.Synergy, "cli_unknown_wrapper", cli_unknown_wrapper) + + class Sy: + def cli_unknown_wrapper(self): + return UnknownWrapper() + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.cli_unknown_wrapper", "--json-output"]) + + assert result.exit_code == 0 + payload = _unwrap_invoke_envelope(result.stdout) + assert payload["type"] == "UnknownWrapper" + assert payload["attributes"]["alpha"] == 3 + assert payload["attributes"]["beta"] == "ok" + assert "_private" not in payload["attributes"] + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_unknown_wrapper") + else: + setattr(moldflow.Synergy, "cli_unknown_wrapper", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_json_output_unknown_wrapper_circular_reference_is_safe(): + """Unknown wrappers with self/cyclic refs should not recurse indefinitely.""" + app = build_cli_app() + + class UnknownWrapperCycle: + def __init__(self) -> None: + self.name = "root" + self.self_ref = self + + orig = getattr(moldflow.Synergy, "cli_unknown_wrapper_cycle", None) + + def cli_unknown_wrapper_cycle(self): + return UnknownWrapperCycle() + + setattr(moldflow.Synergy, "cli_unknown_wrapper_cycle", cli_unknown_wrapper_cycle) + + class Sy: + def cli_unknown_wrapper_cycle(self): + return UnknownWrapperCycle() + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, ["invoke", "synergy.cli_unknown_wrapper_cycle", "--json-output"] + ) + + assert result.exit_code == 0 + payload = _unwrap_invoke_envelope(result.stdout) + assert payload["type"] == "UnknownWrapperCycle" + attrs = payload["attributes"] + assert attrs["name"] == "root" + assert attrs["self_ref"]["type"] == "UnknownWrapperCycle" + assert attrs["self_ref"]["circular_ref"] is True + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_unknown_wrapper_cycle") + else: + setattr(moldflow.Synergy, "cli_unknown_wrapper_cycle", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_json_output_shared_references_are_not_marked_as_circular(): + """Repeated references in sibling fields should serialize fully, not as circular refs.""" + app = build_cli_app() + + class Child: + def __init__(self) -> None: + self.value = 99 + + class UnknownWrapperShared: + def __init__(self) -> None: + shared = Child() + self.left = shared + self.right = shared + + orig = getattr(moldflow.Synergy, "cli_unknown_wrapper_shared", None) + + def cli_unknown_wrapper_shared(self): + return UnknownWrapperShared() + + setattr(moldflow.Synergy, "cli_unknown_wrapper_shared", cli_unknown_wrapper_shared) + + class Sy: + def cli_unknown_wrapper_shared(self): + return UnknownWrapperShared() + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke( + app, ["invoke", "synergy.cli_unknown_wrapper_shared", "--json-output"] + ) + + assert result.exit_code == 0 + payload = _unwrap_invoke_envelope(result.stdout) + assert payload["type"] == "UnknownWrapperShared" + attrs = payload["attributes"] + assert attrs["left"]["type"] == "Child" + assert attrs["left"]["attributes"]["value"] == 99 + assert attrs["right"]["type"] == "Child" + assert attrs["right"]["attributes"]["value"] == 99 + assert attrs["right"].get("circular_ref") is not True + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_unknown_wrapper_shared") + else: + setattr(moldflow.Synergy, "cli_unknown_wrapper_shared", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_json_output_set_values_are_stably_sorted(): + """Set serialization should be deterministic for automation-friendly JSON output.""" + app = build_cli_app() + + orig = getattr(moldflow.Synergy, "cli_return_set", None) + + def cli_return_set(self): # type: ignore[unused-argument] + return {"b", "a", "c"} + + setattr(moldflow.Synergy, "cli_return_set", cli_return_set) + + class Sy: + def cli_return_set(self): + return {"b", "a", "c"} + + try: + with patch("moldflow_cli.context.get_synergy", return_value=Sy()), patch( + "moldflow_cli.factories.get_synergy", return_value=Sy() + ): + result = runner.invoke(app, ["invoke", "synergy.cli_return_set", "--json-output"]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["ok"] is True + assert payload["result"] == ["a", "b", "c"] + finally: + if orig is None: + delattr(moldflow.Synergy, "cli_return_set") + else: + setattr(moldflow.Synergy, "cli_return_set", orig) + + +@pytest.mark.cli +@pytest.mark.unit +def test_invoke_batch_results_use_stable_schema_for_all_items(tmp_path): + """Batch results should keep a consistent key set across success and validation failures.""" + app = build_cli_app() + batch_file = tmp_path / "batch.json" + batch_file.write_text( + json.dumps( + [ + {"target": "synergy.open_project", "args": ["path=C:/Temp/demo.mfproj"]}, + {"target": "synergy.open_project", "unknown_field": 1}, + ] + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, ["invoke", "--batch-file", str(batch_file), "--dry-run", "--json-output"] + ) + assert result.exit_code != 0 + payload = json.loads(result.stdout) + items = payload.get("batch_results", []) + assert isinstance(items, list) + assert len(items) == 2 + expected_keys = { + "index", + "target", + "request", + "ok", + "result", + "result_type", + "plan", + "diagnostics", + "error_type", + "error", + } + for item in items: + assert isinstance(item, dict) + assert set(item.keys()) == expected_keys diff --git a/tests/api/unit_tests/test_unit_animation_export_options.py b/tests/api/unit_tests/test_unit_animation_export_options.py index 77e3fb4..4751094 100644 --- a/tests/api/unit_tests/test_unit_animation_export_options.py +++ b/tests/api/unit_tests/test_unit_animation_export_options.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + """ Test for AnimationExportOptions Wrapper Class of moldflow-api module. Test Details: diff --git a/tests/api/unit_tests/test_unit_cad_diagnostic.py b/tests/api/unit_tests/test_unit_cad_diagnostic.py index 7815e43..b624731 100644 --- a/tests/api/unit_tests/test_unit_cad_diagnostic.py +++ b/tests/api/unit_tests/test_unit_cad_diagnostic.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + """ Test for CADDiagnostic Wrapper Class of moldflow-api module. """ diff --git a/tests/api/unit_tests/test_unit_image_export_options.py b/tests/api/unit_tests/test_unit_image_export_options.py index 5bc242b..6720569 100644 --- a/tests/api/unit_tests/test_unit_image_export_options.py +++ b/tests/api/unit_tests/test_unit_image_export_options.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + """ Test for ImageExportOptions Wrapper Class of moldflow-api module. Test Details: @@ -59,6 +62,7 @@ def mock_image_export_options(self, mock_object) -> ImageExportOptions: + [("ShowHistogram", "show_histogram", x) for x in VALID_BOOL] + [("ShowMinMax", "show_min_max", x) for x in VALID_BOOL] + [("FitToScreen", "fit_to_screen", x) for x in VALID_BOOL] + + [("TransparentBackground", "transparent_background", x) for x in VALID_BOOL] + [("CaptureMode", "capture_mode", x.value) for x in CaptureModes], ) # pylint: disable-next=R0913, R0917 @@ -103,6 +107,7 @@ def test_get_properties( + [("ShowHistogram", "show_histogram", x, x) for x in VALID_BOOL] + [("ShowMinMax", "show_min_max", x, x) for x in VALID_BOOL] + [("FitToScreen", "fit_to_screen", x, x) for x in VALID_BOOL] + + [("TransparentBackground", "transparent_background", x, x) for x in VALID_BOOL] + [("CaptureMode", "capture_mode", x, x.value) for x in CaptureModes], ) # pylint: disable-next=R0913, R0917 @@ -145,6 +150,7 @@ def test_set_properties( + [("ShowHistogram", "show_histogram", x) for x in INVALID_BOOL] + [("ShowMinMax", "show_min_max", x) for x in INVALID_BOOL] + [("FitToScreen", "fit_to_screen", x) for x in INVALID_BOOL] + + [("TransparentBackground", "transparent_background", x) for x in INVALID_BOOL] + [("CaptureMode", "capture_mode", x) for x in INVALID_INT], ) # pylint: disable-next=R0913, R0917 diff --git a/tests/api/unit_tests/test_unit_run.py b/tests/api/unit_tests/test_unit_run.py new file mode 100644 index 0000000..6ac7dea --- /dev/null +++ b/tests/api/unit_tests/test_unit_run.py @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for run.py helper behaviors.""" + +import os +from unittest.mock import patch + +import pytest + +import run as run_script + + +@pytest.mark.unit +def test_install_package_uses_direct_file_uri_with_cli_extras(): + """A local wheel should be installed via PEP 508 direct reference with extras.""" + run_script.VERSION = "1.2.3" + wheel_name = "moldflow-1.2.3-py3-none-any.whl" + + with patch("run.os.path.isdir", return_value=True), patch( + "run.os.listdir", return_value=[wheel_name] + ), patch("run.os.path.getmtime", return_value=1), patch("run.run_command") as mock_run_command: + run_script.install_package(build=False) + + args = mock_run_command.call_args[0][0] + # pip install ... ... + package_spec = args[7] + assert package_spec.startswith("moldflow[cli] @ file:///") + assert package_spec.endswith(".whl") + + +@pytest.mark.unit +def test_install_package_falls_back_when_dist_directory_missing(): + """Missing dist directory should not crash install helper.""" + run_script.VERSION = "1.2.3" + + with patch("run.os.path.isdir", return_value=False), patch( + "run.run_command" + ) as mock_run_command: + run_script.install_package(build=False) + + args = mock_run_command.call_args[0][0] + package_spec = args[7] + assert package_spec == "moldflow[cli]==1.2.3" + + +@pytest.mark.unit +def test_cli_smoke_runs_expected_commands_without_rebuilding(): + """CLI smoke should create a venv, install the wheel, and run the smoke commands.""" + wheel_path = os.path.join(run_script.DIST_DIR, "moldflow-1.2.3-py3-none-any.whl") + expected_python = os.path.join(run_script.CLI_SMOKE_VENV_DIR, "Scripts", "python.exe") + package_spec = run_script.wheel_package_spec(wheel_path) + + with patch("run.build_package") as mock_build, patch( + "run.os.path.isfile", return_value=True + ), patch("run._latest_dist_wheel", return_value=wheel_path), patch( + "run.run_command" + ) as mock_run_command: + run_script.cli_smoke(skip_build=True) + + mock_build.assert_not_called() + assert [call.args[0] for call in mock_run_command.call_args_list] == [ + run_script.python_module_command("venv", run_script.CLI_SMOKE_VENV_DIR), + [expected_python, "-m", "pip", "install", "--upgrade", "pip"], + [expected_python, "-m", "pip", "install", package_spec], + [expected_python, "-m", "moldflow_cli", "--help"], + [expected_python, "-m", "moldflow_cli", "invoke", "--help"], + [expected_python, "-m", "moldflow_cli", "list", "--json"], + ] + + +@pytest.mark.unit +def test_cli_smoke_rebuilds_and_replaces_existing_venv(): + """CLI smoke should rebuild by default and clean an existing venv first.""" + wheel_path = os.path.join(run_script.DIST_DIR, "moldflow-1.2.3-py3-none-any.whl") + + with patch("run.build_package") as mock_build, patch( + "run._remove_directory_if_present" + ) as mock_remove_dir, patch("run._latest_dist_wheel", return_value=wheel_path), patch( + "run.os.path.isfile", return_value=True + ), patch( + "run.run_command" + ): + run_script.cli_smoke() + + mock_build.assert_called_once_with(install=False) + mock_remove_dir.assert_called_once_with(run_script.CLI_SMOKE_VENV_DIR) + + +@pytest.mark.unit +def test_cli_smoke_checks_for_built_wheel_before_removing_existing_venv(): + """CLI smoke should fail before deleting the existing smoke venv when no wheel is present.""" + with patch("run.build_package"), patch( + "run._latest_dist_wheel", side_effect=RuntimeError("missing wheel") + ), patch("run._remove_directory_if_present") as mock_remove_dir: + with pytest.raises(RuntimeError, match="missing wheel"): + run_script.cli_smoke(skip_build=True) + + mock_remove_dir.assert_not_called() + + +@pytest.mark.unit +def test_cli_smoke_raises_when_venv_python_is_missing(): + """CLI smoke should raise a clear error when venv creation produces no Python.""" + wheel_path = os.path.join(run_script.DIST_DIR, "moldflow-1.2.3-py3-none-any.whl") + + with patch("run._latest_dist_wheel", return_value=wheel_path), patch( + "run._remove_directory_if_present" + ) as mock_remove_dir, patch("run.run_command"), patch("run.os.path.isfile", return_value=False): + with pytest.raises(RuntimeError, match="Python executable was not found"): + run_script.cli_smoke(skip_build=True) + + assert mock_remove_dir.call_count == 2 + + +@pytest.mark.unit +def test_wheel_package_spec_rejects_non_wheel_paths(): + """Wheel package helper should reject invalid input early.""" + with pytest.raises(ValueError, match="non-empty string"): + run_script.wheel_package_spec("") + with pytest.raises(ValueError, match="wheel file"): + run_script.wheel_package_spec("dist\\not-a-wheel.txt") + + +@pytest.mark.unit +def test_remove_directory_if_present_tolerates_concurrent_deletion(): + """Directory cleanup should ignore only a concurrent FileNotFound case.""" + with patch("run.os.path.exists", return_value=True), patch( + "run.os.path.isdir", return_value=True + ), patch("run.shutil.rmtree", side_effect=FileNotFoundError): + getattr(run_script, "_remove_directory_if_present")("C:\\temp\\gone") + + +@pytest.mark.unit +def test_remove_directory_if_present_rejects_file_paths(): + """Directory cleanup should fail clearly when given a file path.""" + with patch("run.os.path.exists", return_value=True), patch( + "run.os.path.isdir", return_value=False + ): + with pytest.raises(NotADirectoryError, match="Expected a directory path"): + getattr(run_script, "_remove_directory_if_present")("C:\\temp\\not-a-dir") + + +@pytest.mark.unit +def test_cli_smoke_cleans_up_venv_when_command_fails(): + """CLI smoke should remove the temporary venv again when a smoke command fails.""" + wheel_path = os.path.join(run_script.DIST_DIR, "moldflow-1.2.3-py3-none-any.whl") + + with patch("run._latest_dist_wheel", return_value=wheel_path), patch( + "run._remove_directory_if_present" + ) as mock_remove_dir, patch("run.os.path.isfile", return_value=True), patch( + "run.run_command", side_effect=[None, None, RuntimeError("smoke failed")] + ): + with pytest.raises(RuntimeError, match="smoke failed"): + run_script.cli_smoke(skip_build=True) + + assert mock_remove_dir.call_count == 2 diff --git a/tests/conftest.py b/tests/conftest.py index 5642926..bc08bee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,14 +3,29 @@ """This module contains the common test fixtures for the moldflow-api tests.""" -import os +# pylint: disable=wrong-import-position # tests bootstrap repo/src onto sys.path before moldflow imports + import logging +import os +import sys from enum import Enum +from pathlib import Path from unittest.mock import Mock + +import polib import pytest + +ROOT_DIR = Path(__file__).resolve().parents[1] +SRC_DIR = ROOT_DIR / "src" +LOCALE_PO_FILES = tuple(sorted((SRC_DIR / "moldflow" / "locale").glob("**/*.po"))) +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from moldflow.constants import DEFAULT_THREE_LETTER_CODE from moldflow.localization import set_language from moldflow.logger import set_is_logging -from moldflow.constants import DEFAULT_THREE_LETTER_CODE # Logging LOGGING = True @@ -79,12 +94,22 @@ def list_intersection(list1, list2): return list(set(list1) & set(list2)) +def _compile_test_translations() -> None: + """Compile locale catalogs in-place so gettext-based tests work from source.""" + for po_path in LOCALE_PO_FILES: + mo_path = po_path.with_suffix(".mo") + if mo_path.exists() and mo_path.stat().st_mtime >= po_path.stat().st_mtime: + continue + polib.pofile(str(po_path)).save_as_mofile(str(mo_path)) + + # Fixtures @pytest.fixture(scope="session") def _(): """ A pytest fixture that provides a mock object for the gettext translation function. """ + _compile_test_translations() return set_language(version=TEST_VERSION, locale=DEFAULT_THREE_LETTER_CODE) diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..eee830b --- /dev/null +++ b/tests/core/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/core/test_check_localization_script.py b/tests/core/test_check_localization_script.py new file mode 100644 index 0000000..dbdc883 --- /dev/null +++ b/tests/core/test_check_localization_script.py @@ -0,0 +1,169 @@ +# SPDX-FileCopyrightText: 2026 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the localization checker helper script.""" + +from __future__ import annotations + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +import pytest + + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "check_localization.py" + + +def _load_check_localization_module(): + """Import the standalone localization checker script as a module.""" + spec = spec_from_file_location("check_localization_script", SCRIPT_PATH) + assert spec is not None + assert spec.loader is not None + module = module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _write_base_locale(locale_root: Path) -> None: + """Create a minimal English locale file for script tests.""" + po_dir = locale_root / "en-US" / "LC_MESSAGES" + po_dir.mkdir(parents=True) + (po_dir / "locale.en-US.po").write_text( + "\n".join( + [ + 'msgid ""', + 'msgstr ""', + '"Content-Type: text/plain; charset=UTF-8\\n"', + '"Language: en-US\\n"', + "", + ] + ), + encoding="utf-8", + ) + + +@pytest.mark.core +@pytest.mark.unit +def test_check_localization_detects_get_text_aliases_and_wrappers(tmp_path: Path): + """The checker should find direct aliases and wrapper-based translation calls.""" + module = _load_check_localization_module() + src_root = tmp_path / "src" + locale_root = tmp_path / "locale" + src_root.mkdir() + _write_base_locale(locale_root) + + source_file = src_root / "sample_cli.py" + source_file.write_text( + "\n".join( + [ + "from moldflow.i18n import get_text", + "", + "_T = get_text()", + "", + "def _tr(message: str, **kwargs):", + " text = _T(message)", + " return text.format(**kwargs) if kwargs else text", + "", + "def _validate_batch_mode_inputs(*, translate):", + " return translate(\"Batch mode validation\")", + "", + "def local_alias():", + " _ = get_text()", + " return _(\"Local alias message\")", + "", + "def main():", + " _T(\"Module alias message\")", + " _tr(\"Wrapper helper message\")", + " _validate_batch_mode_inputs(translate=_T)", + " local_alias()", + ] + ), + encoding="utf-8", + ) + + checker = module.LocalizationChecker(src_root, locale_root) + violations, fixes = checker.check_file(source_file) + + assert violations + assert {fix.string_value for fix in fixes} == { + "Batch mode validation", + "Local alias message", + "Module alias message", + "Wrapper helper message", + } + + +@pytest.mark.core +@pytest.mark.unit +def test_check_localization_marks_translate_parameter_from_call_site(tmp_path: Path): + """Translate parameters passed from a call site should be treated as localizers.""" + module = _load_check_localization_module() + src_root = tmp_path / "src" + locale_root = tmp_path / "locale" + src_root.mkdir() + _write_base_locale(locale_root) + + source_file = src_root / "sample_translate_param.py" + source_file.write_text( + "\n".join( + [ + "from moldflow.i18n import get_text", + "", + "def emit_message(translate):", + " return translate(\"Translate parameter message\")", + "", + "def run():", + " translator = get_text()", + " return emit_message(translator)", + ] + ), + encoding="utf-8", + ) + + checker = module.LocalizationChecker(src_root, locale_root) + violations, fixes = checker.check_file(source_file) + + assert violations == [ + ( + f"{source_file}:4: missing localization for " + "'Translate parameter message' (translate() call)" + ) + ] + assert [fix.string_value for fix in fixes] == ["Translate parameter message"] + + +@pytest.mark.core +@pytest.mark.unit +def test_default_source_paths_include_cli_folder_when_present( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """Default source paths should include both library and CLI roots.""" + module = _load_check_localization_module() + (monkeypatch.chdir(tmp_path)) + (tmp_path / "src" / "moldflow").mkdir(parents=True) + (tmp_path / "src" / "moldflow_cli").mkdir(parents=True) + + paths = getattr(module, "_default_source_paths")() + + assert paths == [Path("src/moldflow"), Path("src/moldflow_cli")] + + +@pytest.mark.core +@pytest.mark.unit +def test_collect_python_files_supports_multiple_roots(tmp_path: Path): + """Python file collection should aggregate files from all provided roots.""" + module = _load_check_localization_module() + first_root = tmp_path / "first" + second_root = tmp_path / "second" + first_root.mkdir() + second_root.mkdir() + first_file = first_root / "one.py" + second_file = second_root / "two.py" + first_file.write_text("print('one')\n", encoding="utf-8") + second_file.write_text("print('two')\n", encoding="utf-8") + + py_files = getattr(module, "_collect_python_files")([first_root, second_root]) + + assert py_files == [first_file, second_file] diff --git a/tests/core/test_localization.py b/tests/core/test_localization.py index ab1baa9..b500277 100644 --- a/tests/core/test_localization.py +++ b/tests/core/test_localization.py @@ -20,19 +20,41 @@ """ import os +from unittest.mock import patch import pytest -from moldflow.localization import set_language -from moldflow.constants import LOCALE_ENVIRONMENT_VARIABLE_NAME +from moldflow.localization import set_language, _normalize_locale_code +from moldflow.constants import LOCALE_ENVIRONMENT_VARIABLE_NAME, THREE_LETTER_TO_BCP_47 from tests.core.conftest import TEST_STRING, TEST_TRANSLATION_DICT, DEFAULT_LANG, ENV_LANG from tests.conftest import TEST_VERSION, VALID_STR +EXPECTED_TRANSLATIONS = dict(TEST_TRANSLATION_DICT) +EXPECTED_TRANSLATIONS.update( + { + bcp47_locale: TEST_TRANSLATION_DICT[three_letter_locale] + for three_letter_locale, bcp47_locale in THREE_LETTER_TO_BCP_47.items() + if three_letter_locale in TEST_TRANSLATION_DICT + } +) + + +def _expected_translation_for(locale: str | None) -> str: + normalized_locale = _normalize_locale_code(locale) or DEFAULT_LANG + return EXPECTED_TRANSLATIONS[normalized_locale] + + @pytest.mark.core class TestLocalization: """ Test suite for Localization. """ + @pytest.fixture(autouse=True) + def no_windows_locale_fallback(self): + """Keep legacy tests stable unless they explicitly exercise the OS fallback.""" + with patch("moldflow.localization._get_windows_locale_name", return_value=None): + yield + @pytest.mark.parametrize("locale", list(TEST_TRANSLATION_DICT.keys())) def test_set_language(self, locale): """ @@ -47,7 +69,7 @@ def test_set_language_invalid_version(self, version): Test set_language function with invalid version. """ _ = set_language(version=version) - assert _(TEST_STRING) == TEST_TRANSLATION_DICT[ENV_LANG] + assert _(TEST_STRING) == _expected_translation_for(ENV_LANG) @pytest.mark.usefixtures("environment_locale") @pytest.mark.parametrize("version", VALID_STR) @@ -71,14 +93,14 @@ def test_set_language_none(self): Test set_language function with invalid locale. """ _ = set_language(version=TEST_VERSION, locale=None) - assert _(TEST_STRING) == TEST_TRANSLATION_DICT[ENV_LANG] + assert _(TEST_STRING) == _expected_translation_for(ENV_LANG) def test_set_language_empty(self): """ Test set_language function with invalid locale. """ _ = set_language(version=TEST_VERSION) - assert _(TEST_STRING) == TEST_TRANSLATION_DICT[ENV_LANG] + assert _(TEST_STRING) == _expected_translation_for(ENV_LANG) @pytest.mark.usefixtures("environment_locale") def test_set_language_reg(self): @@ -93,7 +115,7 @@ def test_set_language_no_param(self): Test set_language function with invalid locale. """ _ = set_language() - assert _(TEST_STRING) == TEST_TRANSLATION_DICT[ENV_LANG] + assert _(TEST_STRING) == _expected_translation_for(ENV_LANG) @pytest.mark.parametrize("locale", list(TEST_TRANSLATION_DICT.keys())) def test_set_language_env(self, locale): @@ -104,3 +126,14 @@ def test_set_language_env(self, locale): _ = set_language() assert _(TEST_STRING) == TEST_TRANSLATION_DICT[locale] del os.environ[LOCALE_ENVIRONMENT_VARIABLE_NAME] + + @pytest.mark.usefixtures("environment_locale") + def test_set_language_windows_locale_fallback(self): + """ + Test set_language falls back to the Windows user locale. + """ + with patch("moldflow.localization.winreg.OpenKey", side_effect=FileNotFoundError), patch( + "moldflow.localization._get_windows_locale_name", return_value="ja-JP" + ): + _ = set_language(version=TEST_VERSION) + assert _(TEST_STRING) == "テスト文字列" diff --git a/tests/core/test_localization_i18n.py b/tests/core/test_localization_i18n.py index 5618c1e..0eb0361 100644 --- a/tests/core/test_localization_i18n.py +++ b/tests/core/test_localization_i18n.py @@ -38,3 +38,23 @@ def test_set_language_updates_gettext(self): set_language(locale="deu") args, _ = mocked.call_args assert args[2] == ["de-DE"] + + def test_set_language_accepts_bcp47_locale_tags(self): + """ + Test set_language accepts direct BCP-47 locale tags. + """ + with patch("moldflow.localization.install_translation") as mocked: + set_language(locale="ja-JP") + args, _ = mocked.call_args + assert args[0] == "locale.ja-JP" + assert args[2] == ["ja-JP"] + + def test_set_language_normalizes_underscored_locale_tags(self): + """ + Test set_language normalizes locale tags that use underscores. + """ + with patch("moldflow.localization.install_translation") as mocked: + set_language(locale="ja_jp") + args, _ = mocked.call_args + assert args[0] == "locale.ja-JP" + assert args[2] == ["ja-JP"] diff --git a/version.json b/version.json index 31ed73a..92c3675 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { "major": "27", - "minor": "0", - "patch": "1" + "minor": "1", + "patch": "0" }