From ea463c48a9beb29c25a5fa97aea6b7211af3efe7 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sat, 18 Jul 2026 23:00:40 -0400 Subject: [PATCH 1/4] Document Python code generator design --- designs/codegen/cli.md | 95 ++++++++++++++++++++++++++++++++++++++++ designs/codegen/index.md | 60 +++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 designs/codegen/cli.md create mode 100644 designs/codegen/index.md diff --git a/designs/codegen/cli.md b/designs/codegen/cli.md new file mode 100644 index 000000000..dec4395fa --- /dev/null +++ b/designs/codegen/cli.md @@ -0,0 +1,95 @@ +# Code Generator CLI + +The `smithy-python` command is the process interface described in the +[Python Code Generation](index.md) overview. It supports direct use and +invocation from Smithy's +[`run` plugin](https://smithy.io/2.0/guides/smithy-build-json.html#run-plugin). + +## Commands + +Generation is organized by artifact type: + +```console +smithy-python generate client [OPTIONS] +smithy-python generate types [OPTIONS] +``` + +`client` generates a service client and its required types. `types` generates a +standalone types package. Both commands accept the following process options: + +* `--model PATH` reads a JSON AST from a file instead of standard input. +* `--output PATH` selects the output directory for direct invocation. + +Settings specific to each artifact will be added with the functionality that +consumes them. + +The command MUST return zero after successful generation and non-zero when +arguments, settings, the model, or generation are invalid. Diagnostics are +written to standard error. Invalid command syntax and invocation inputs return +2, while I/O and generation failures return 1. + +## Smithy `run` Plugin + +The Smithy `run` plugin executes an external program during a build. It sends the +projection's Smithy model as a JSON AST to the process's standard input and runs +the process in the plugin's output directory. + +A plugin ID MUST use `run::` followed by a custom artifact name. The configured +command identifies the artifact to generate: + +```json +{ + "version": "1.0", + "projections": { + "client": { + "plugins": { + "run::python-client": { + "command": ["smithy-python", "generate", "client"] + } + } + } + } +} +``` + +Artifact-specific options will be appended to `command` after they are defined. + +The `smithy-python` executable MUST be installed or otherwise available on the +Smithy process's `PATH`. Smithy passes no arguments other than those in +`command`. + +### Input and Output + +When invoked by Smithy, the CLI reads one JSON AST document from standard input. +The document represents the model after projection transforms have been applied. + +The presence of `SMITHY_PLUGIN_DIR` identifies an invocation by the `run` plugin. +Generated files are written beneath this directory, which Smithy also uses as the +process's working directory. The CLI MUST NOT write generated files outside it, +and `--model` and `--output` MUST NOT be used in this mode. + +The `run` plugin provides the following environment variables: + +| Name | Purpose | +|------|---------| +| `SMITHY_ROOT_DIR` | Root directory of the Smithy build. | +| `SMITHY_PLUGIN_DIR` | Output and working directory for the plugin. | +| `SMITHY_PROJECTION_NAME` | Name of the active projection. | +| `SMITHY_ARTIFACT_NAME` | Custom artifact name from the plugin ID. | +| `SMITHY_INCLUDES_PRELUDE` | Whether the JSON AST includes prelude shapes. | + +The CLI uses this context to interpret the model. Protocol and platform +integrations MAY also use it while generating files. + +Smithy omits prelude shapes by default. A build MAY set `sendPrelude` to `true` +in the `run` plugin configuration when those shapes are needed. + +## Direct Invocation + +When `SMITHY_PLUGIN_DIR` is absent, the CLI treats the command as a direct +invocation and requires `--output`. It follows the same +generation path as Smithy invocation and can read a JSON AST from a file instead +of standard input by using `--model`. When standard input is an interactive +terminal, `--model` is required so that an omitted input does not wait indefinitely +for input. This mode is intended for development, testing, and integration with +tools other than the Smithy CLI. diff --git a/designs/codegen/index.md b/designs/codegen/index.md new file mode 100644 index 000000000..c6924759d --- /dev/null +++ b/designs/codegen/index.md @@ -0,0 +1,60 @@ +# Python Code Generation + +Smithy Python currently generates clients with the Java implementation in +`codegen`. This document describes the Python code generator that will replace +that implementation over time. + +The Python generator is distributed as `smithy-python`. It is separate from the +runtime packages used by generated code, and is only needed while generating a +package. + +## Goals + +* Generate Python clients and standalone types packages from Smithy models. +* Integrate with standard Smithy builds without requiring a Java code generator. +* Provide extension points for protocol and platform-specific behavior. +* Produce code compatible with the existing Smithy Python runtime packages. +* Allow the Python and Java generators to coexist during migration. + +## Architecture + +The generator consumes a Smithy JSON AST and settings for an artifact. It loads +the model, applies artifact and protocol-specific behavior, and writes a Python +package. + +```text +Smithy JSON AST + settings + | + v + smithy-python generator + | + v + client or types package +``` + +Two artifact types are initially planned: + +* `client` will generate a service client and its required types. +* `types` will generate a standalone package of types selected from a model. + +The command-line interface is the generator's first entry point. Smithy's `run` +plugin invokes it as an external process, so the generator does not need to be +loaded into the Smithy CLI or implemented in Java. + +Generated packages MUST NOT depend on `smithy-python` at runtime. They MAY +depend on the handwritten runtime packages in this repository. + +## Migration + +The Java generator remains authoritative while the Python generator is under +development. Features may be implemented and reviewed incrementally without +changing the Java path. A generated artifact SHOULD move to the Python generator +only after the required behavior is supported and tested. + +The Python generator does not need to reproduce Java implementation details or +byte-for-byte output. It MUST preserve the supported Smithy semantics and public +behavior of generated packages. + +## Designs + +* [Code Generator CLI](cli.md) From cd22e2a8b61bbc199c4f6729894885c1325c8c1a Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sat, 18 Jul 2026 23:01:30 -0400 Subject: [PATCH 2/4] Add experimental Python codegen CLI --- README.md | 11 +- .../smithy-python-feature-codegen-cli.json | 4 + packages/smithy-python/CHANGELOG.md | 1 + packages/smithy-python/NOTICE | 1 + packages/smithy-python/README.md | 19 ++ packages/smithy-python/pyproject.toml | 51 ++++ .../src/smithy_python/__init__.py | 5 + .../src/smithy_python/__main__.py | 7 + .../smithy-python/src/smithy_python/cli.py | 140 +++++++++++ .../src/smithy_python/environment.py | 40 ++++ .../src/smithy_python/exceptions.py | 15 ++ .../smithy-python/src/smithy_python/py.typed | 0 packages/smithy-python/tests/unit/__init__.py | 2 + packages/smithy-python/tests/unit/test_cli.py | 225 ++++++++++++++++++ .../tests/unit/test_environment.py | 43 ++++ .../tests/unit/test_exceptions.py | 14 ++ pyproject.toml | 3 +- uv.lock | 5 + 18 files changed, 582 insertions(+), 4 deletions(-) create mode 100644 packages/smithy-python/.changes/next-release/smithy-python-feature-codegen-cli.json create mode 100644 packages/smithy-python/CHANGELOG.md create mode 100644 packages/smithy-python/NOTICE create mode 100644 packages/smithy-python/README.md create mode 100644 packages/smithy-python/pyproject.toml create mode 100644 packages/smithy-python/src/smithy_python/__init__.py create mode 100644 packages/smithy-python/src/smithy_python/__main__.py create mode 100644 packages/smithy-python/src/smithy_python/cli.py create mode 100644 packages/smithy-python/src/smithy_python/environment.py create mode 100644 packages/smithy-python/src/smithy_python/exceptions.py create mode 100644 packages/smithy-python/src/smithy_python/py.typed create mode 100644 packages/smithy-python/tests/unit/__init__.py create mode 100644 packages/smithy-python/tests/unit/test_cli.py create mode 100644 packages/smithy-python/tests/unit/test_environment.py create mode 100644 packages/smithy-python/tests/unit/test_exceptions.py diff --git a/README.md b/README.md index a8422154d..fe192f4ed 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ This code generator, and the clients it generates, are unstable and should not be used in production systems yet. Several features, such as detailed logging, have not been implemented yet. +> [!NOTE] +> The Java generator in `codegen` remains the authoritative implementation. +> `packages/smithy-python` contains an experimental Python-native CLI scaffold +> that does not generate code yet. + ### What is this repository? This repository contains two major components: @@ -20,9 +25,9 @@ This repository contains two major components: 2) Core modules and interfaces for building service clients in Python These components facilitate generating clients for any [Smithy](https://smithy.io/) -service. The `codegen` directory contains the source code for generating clients. -The `python-packages` directory contains the source code for the handwritten python -components. +service. The `codegen` directory contains the current Java generator, +`packages/smithy-python` contains the Python-native generator scaffold, and the +other directories under `packages` contain the handwritten Python components. This repository does *not* contain any generated clients, such as for S3 or other AWS services. Rather, these are the tools that facilitate the generation of those diff --git a/packages/smithy-python/.changes/next-release/smithy-python-feature-codegen-cli.json b/packages/smithy-python/.changes/next-release/smithy-python-feature-codegen-cli.json new file mode 100644 index 000000000..e9ed269f8 --- /dev/null +++ b/packages/smithy-python/.changes/next-release/smithy-python-feature-codegen-cli.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Added the experimental smithy-python package and CLI scaffold." +} diff --git a/packages/smithy-python/CHANGELOG.md b/packages/smithy-python/CHANGELOG.md new file mode 100644 index 000000000..825c32f0d --- /dev/null +++ b/packages/smithy-python/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/packages/smithy-python/NOTICE b/packages/smithy-python/NOTICE new file mode 100644 index 000000000..616fc5889 --- /dev/null +++ b/packages/smithy-python/NOTICE @@ -0,0 +1 @@ +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/packages/smithy-python/README.md b/packages/smithy-python/README.md new file mode 100644 index 000000000..9154bc542 --- /dev/null +++ b/packages/smithy-python/README.md @@ -0,0 +1,19 @@ +# smithy-python + +> [!WARNING] +> This package is an experimental scaffold. It does not generate code yet. The +> Java generator in the repository's `codegen` directory remains authoritative. + +`smithy-python` will provide Python-native code generation for Smithy models. +The initial command-line interface exposes the planned client and types generation +commands so that their top-level shape can be developed independently from the +generator implementation. + +```console +smithy-python generate client +smithy-python generate types +``` + +Both generation commands currently exit with an error explaining that generation +has not been implemented. The package is included in workspace builds to validate +its packaging and entry points. diff --git a/packages/smithy-python/pyproject.toml b/packages/smithy-python/pyproject.toml new file mode 100644 index 000000000..3bf5dccd0 --- /dev/null +++ b/packages/smithy-python/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = "smithy-python" +dynamic = ["version"] +requires-python = ">=3.12" +authors = [ + {name = "Amazon Web Services"}, +] +description = "A Smithy code generator for Python clients and types." +readme = "README.md" +license = {text = "Apache License 2.0"} +keywords = ["smithy", "codegen", "sdk"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Natural Language :: English", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Free Threading :: 2 - Beta", + "Topic :: Software Development :: Code Generators", +] +dependencies = [] + +[project.scripts] +smithy-python = "smithy_python.cli:main" + +[project.urls] +"Changelog" = "https://github.com/smithy-lang/smithy-python/blob/develop/packages/smithy-python/CHANGELOG.md" +"Code" = "https://github.com/smithy-lang/smithy-python/tree/develop/packages/smithy-python/" +"Issue tracker" = "https://github.com/smithy-lang/smithy-python/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/smithy_python/__init__.py" + +[tool.hatch.build] +exclude = [ + "tests", +] + +[tool.ruff] +src = ["src"] diff --git a/packages/smithy-python/src/smithy_python/__init__.py b/packages/smithy-python/src/smithy_python/__init__.py new file mode 100644 index 000000000..c3621cb0c --- /dev/null +++ b/packages/smithy-python/src/smithy_python/__init__.py @@ -0,0 +1,5 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""A Smithy code generator for Python clients and types.""" + +__version__ = "0.0.0" diff --git a/packages/smithy-python/src/smithy_python/__main__.py b/packages/smithy-python/src/smithy_python/__main__.py new file mode 100644 index 000000000..b8aaf8e60 --- /dev/null +++ b/packages/smithy-python/src/smithy_python/__main__.py @@ -0,0 +1,7 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/smithy-python/src/smithy_python/cli.py b/packages/smithy-python/src/smithy_python/cli.py new file mode 100644 index 000000000..7736312c8 --- /dev/null +++ b/packages/smithy-python/src/smithy_python/cli.py @@ -0,0 +1,140 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Command-line interface for the Smithy Python code generator.""" + +from __future__ import annotations + +import argparse +import os +import sys +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO, Final + +from . import __version__ +from .environment import PluginEnvironment +from .exceptions import CodegenError, InvalidInvocationError + +_GENERATION_NOT_IMPLEMENTED: Final = ( + "smithy-python: error: {artifact} generation is not implemented yet\n" +) + + +@dataclass(frozen=True, slots=True) +class _Invocation: + artifact: str + model_source: bytes + output_dir: Path + environment: PluginEnvironment + + +def main( + argv: Sequence[str] | None = None, + *, + environ: Mapping[str, str] | None = None, + stdin: BinaryIO | None = None, +) -> int: + """Run the CLI with the provided process inputs and return its exit code.""" + parser = _create_parser() + + try: + args = parser.parse_args(argv) + except SystemExit as error: + return error.code if isinstance(error.code, int) else 1 + + try: + _resolve_invocation( + args, + environ=os.environ if environ is None else environ, + stdin=stdin, + ) + except InvalidInvocationError as error: + sys.stderr.write(f"smithy-python: error: {error}\n") + return 2 + except (CodegenError, OSError) as error: + sys.stderr.write(f"smithy-python: error: {error}\n") + return 1 + + sys.stderr.write(_GENERATION_NOT_IMPLEMENTED.format(artifact=args.artifact)) + return 1 + + +def _create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="smithy-python", + description="Generate Python source from Smithy models.", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", + ) + + commands = parser.add_subparsers(required=True) + generate = commands.add_parser("generate", help="Generate Python source") + artifacts = generate.add_subparsers(dest="artifact", required=True) + for name, help_text in ( + ("client", "Generate a client package"), + ("types", "Generate a standalone types package"), + ): + artifact = artifacts.add_parser(name, help=help_text) + artifact.add_argument( + "--model", + type=Path, + help="Read the JSON AST from a file instead of standard input", + ) + artifact.add_argument( + "--output", + type=Path, + help="Output directory for direct invocation", + ) + + return parser + + +def _resolve_invocation( + args: argparse.Namespace, + *, + environ: Mapping[str, str], + stdin: BinaryIO | None, +) -> _Invocation: + environment = PluginEnvironment.from_environ(environ) + model_path: Path | None = args.model + output_path: Path | None = args.output + + if (plugin_dir := environment.plugin_dir) is not None: + if model_path is not None: + raise InvalidInvocationError( + "--model cannot be used with the Smithy run plugin" + ) + if output_path is not None: + raise InvalidInvocationError( + "--output cannot be used with the Smithy run plugin" + ) + output_dir = plugin_dir + else: + if output_path is None: + raise InvalidInvocationError("Direct invocation requires --output") + output_dir = output_path + + if model_path is not None: + if not model_path.is_file(): + raise InvalidInvocationError(f"Model path is not a file: {model_path}") + model_source = model_path.read_bytes() + else: + model_stream = sys.stdin.buffer if stdin is None else stdin + if environment.plugin_dir is None and model_stream.isatty(): + raise InvalidInvocationError( + "Direct invocation requires --model or a model piped to standard input" + ) + model_source = model_stream.read() + if not model_source: + raise InvalidInvocationError("Expected a Smithy JSON AST model") + + return _Invocation( + artifact=args.artifact, + model_source=model_source, + output_dir=output_dir, + environment=environment, + ) diff --git a/packages/smithy-python/src/smithy_python/environment.py b/packages/smithy-python/src/smithy_python/environment.py new file mode 100644 index 000000000..5137519ae --- /dev/null +++ b/packages/smithy-python/src/smithy_python/environment.py @@ -0,0 +1,40 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Smithy build environment provided to the code generator.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Self + + +@dataclass(frozen=True, slots=True) +class PluginEnvironment: + """Environment values supplied by Smithy's process-based run plugin.""" + + root_dir: Path | None = None + plugin_dir: Path | None = None + projection_name: str | None = None + artifact_name: str | None = None + includes_prelude: bool = False + + @classmethod + def from_environ(cls, environ: Mapping[str, str] | None = None) -> Self: + """Load the Smithy run plugin environment from a mapping.""" + + source = os.environ if environ is None else environ + + def path(name: str) -> Path | None: + return Path(value) if (value := source.get(name)) else None + + return cls( + root_dir=path("SMITHY_ROOT_DIR"), + plugin_dir=path("SMITHY_PLUGIN_DIR"), + projection_name=source.get("SMITHY_PROJECTION_NAME"), + artifact_name=source.get("SMITHY_ARTIFACT_NAME"), + includes_prelude=source.get("SMITHY_INCLUDES_PRELUDE", "false").lower() + == "true", + ) diff --git a/packages/smithy-python/src/smithy_python/exceptions.py b/packages/smithy-python/src/smithy_python/exceptions.py new file mode 100644 index 000000000..a0cff3f93 --- /dev/null +++ b/packages/smithy-python/src/smithy_python/exceptions.py @@ -0,0 +1,15 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Exceptions raised by Smithy Python code generation.""" + + +class SmithyPythonError(Exception): + """Base exception for errors raised by the Smithy Python generator.""" + + +class CodegenError(SmithyPythonError): + """Raised when code generation fails.""" + + +class InvalidInvocationError(SmithyPythonError): + """Raised when command-line inputs do not form a valid invocation.""" diff --git a/packages/smithy-python/src/smithy_python/py.typed b/packages/smithy-python/src/smithy_python/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/packages/smithy-python/tests/unit/__init__.py b/packages/smithy-python/tests/unit/__init__.py new file mode 100644 index 000000000..04f8b7b76 --- /dev/null +++ b/packages/smithy-python/tests/unit/__init__.py @@ -0,0 +1,2 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/packages/smithy-python/tests/unit/test_cli.py b/packages/smithy-python/tests/unit/test_cli.py new file mode 100644 index 000000000..a82c87a1a --- /dev/null +++ b/packages/smithy-python/tests/unit/test_cli.py @@ -0,0 +1,225 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import importlib +from io import BytesIO +from pathlib import Path + +import pytest +from smithy_python import __version__ +from smithy_python.cli import main + + +class _InteractiveStdin(BytesIO): + def isatty(self) -> bool: + return True + + +@pytest.mark.parametrize( + ("argv", "expected"), + [ + (("--help",), "usage: smithy-python"), + (("--version",), f"smithy-python {__version__}"), + ], +) +def test_information_commands( + argv: tuple[str, ...], expected: str, capsys: pytest.CaptureFixture[str] +) -> None: + assert main(argv) == 0 + assert capsys.readouterr().out.startswith(expected) + + +@pytest.mark.parametrize("artifact", ["client", "types"]) +def test_generation_commands_are_explicitly_unavailable( + artifact: str, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + model = tmp_path / "model.json" + model.write_text("{}") + + assert ( + main( + ( + "generate", + artifact, + "--model", + str(model), + "--output", + str(tmp_path / "output"), + ), + environ={}, + ) + == 1 + ) + assert capsys.readouterr().err == ( + f"smithy-python: error: {artifact} generation is not implemented yet\n" + ) + + +@pytest.mark.parametrize( + ("argv", "expected_usage"), + [ + ((), "usage: smithy-python"), + (("generate",), "usage: smithy-python generate"), + ], +) +def test_missing_command_identifies_available_subcommands( + argv: tuple[str, ...], + expected_usage: str, + capsys: pytest.CaptureFixture[str], +) -> None: + assert main(argv) == 2 + assert capsys.readouterr().err.startswith(expected_usage) + + +def test_main_module_can_be_imported() -> None: + importlib.import_module("smithy_python.__main__") + + +def test_run_plugin_invocation_reads_standard_input( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert ( + main( + ("generate", "client"), + environ={"SMITHY_PLUGIN_DIR": str(tmp_path)}, + stdin=BytesIO(b"{}"), + ) + == 1 + ) + assert "generation is not implemented yet" in capsys.readouterr().err + + +@pytest.mark.parametrize("option", ["--model", "--output"]) +def test_run_plugin_rejects_direct_invocation_options( + option: str, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + assert ( + main( + ("generate", "client", option, str(tmp_path / "value")), + environ={"SMITHY_PLUGIN_DIR": str(tmp_path)}, + stdin=BytesIO(b"{}"), + ) + == 2 + ) + assert f"{option} cannot be used with the Smithy run plugin" in ( + capsys.readouterr().err + ) + + +def test_direct_invocation_requires_output( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + model = tmp_path / "model.json" + model.write_text("{}") + + assert main(("generate", "client", "--model", str(model)), environ={}) == 2 + assert "Direct invocation requires --output" in capsys.readouterr().err + + +def test_invocation_rejects_empty_model( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert ( + main( + ("generate", "client", "--output", str(tmp_path)), + environ={}, + stdin=BytesIO(), + ) + == 2 + ) + assert "Expected a Smithy JSON AST model" in capsys.readouterr().err + + +def test_direct_invocation_rejects_interactive_model_input( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert ( + main( + ("generate", "client", "--output", str(tmp_path)), + environ={}, + stdin=_InteractiveStdin(), + ) + == 2 + ) + assert ( + "Direct invocation requires --model or a model piped to standard input" + in capsys.readouterr().err + ) + + +def test_invocation_reports_unreadable_model( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + missing = tmp_path / "missing.json" + + assert ( + main( + ( + "generate", + "client", + "--model", + str(missing), + "--output", + str(tmp_path), + ), + environ={}, + ) + == 2 + ) + assert f"Model path is not a file: {missing}" in capsys.readouterr().err + + +def test_invocation_rejects_empty_model_path( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert ( + main( + ( + "generate", + "client", + "--model", + "", + "--output", + str(tmp_path), + ), + environ={}, + ) + == 2 + ) + assert "Model path is not a file: ." in capsys.readouterr().err + + +def test_invocation_reports_model_io_error( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + model = tmp_path / "model.json" + model.write_text("{}") + + def raise_io_error(self: Path) -> bytes: + raise OSError("unable to read model") + + monkeypatch.setattr(Path, "read_bytes", raise_io_error) + + assert ( + main( + ( + "generate", + "client", + "--model", + str(model), + "--output", + str(tmp_path), + ), + environ={}, + ) + == 1 + ) + assert "unable to read model" in capsys.readouterr().err diff --git a/packages/smithy-python/tests/unit/test_environment.py b/packages/smithy-python/tests/unit/test_environment.py new file mode 100644 index 000000000..ea8e270fd --- /dev/null +++ b/packages/smithy-python/tests/unit/test_environment.py @@ -0,0 +1,43 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path + +import pytest +from smithy_python.environment import PluginEnvironment + + +def test_loads_run_plugin_environment() -> None: + environment = PluginEnvironment.from_environ( + { + "SMITHY_ROOT_DIR": "/tmp/root", + "SMITHY_PLUGIN_DIR": "/tmp/plugin", + "SMITHY_PROJECTION_NAME": "client", + "SMITHY_ARTIFACT_NAME": "python-client", + "SMITHY_INCLUDES_PRELUDE": "true", + } + ) + + assert environment.root_dir == Path("/tmp/root") + assert environment.plugin_dir == Path("/tmp/plugin") + assert environment.projection_name == "client" + assert environment.artifact_name == "python-client" + assert environment.includes_prelude + + +def test_defaults_to_direct_invocation() -> None: + environment = PluginEnvironment.from_environ({}) + + assert environment.root_dir is None + assert environment.plugin_dir is None + assert environment.projection_name is None + assert environment.artifact_name is None + assert not environment.includes_prelude + + +def test_loads_os_environment_by_default( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("SMITHY_PLUGIN_DIR", str(tmp_path)) + + assert PluginEnvironment.from_environ().plugin_dir == tmp_path diff --git a/packages/smithy-python/tests/unit/test_exceptions.py b/packages/smithy-python/tests/unit/test_exceptions.py new file mode 100644 index 000000000..bd1867f56 --- /dev/null +++ b/packages/smithy-python/tests/unit/test_exceptions.py @@ -0,0 +1,14 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from smithy_python.exceptions import ( + CodegenError, + InvalidInvocationError, + SmithyPythonError, +) + + +def test_error_hierarchy_distinguishes_invocation_and_codegen_failures() -> None: + assert issubclass(CodegenError, SmithyPythonError) + assert issubclass(InvalidInvocationError, SmithyPythonError) + assert not issubclass(InvalidInvocationError, CodegenError) diff --git a/pyproject.toml b/pyproject.toml index 152cf5f61..42a463e81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "smithy-python" +name = "smithy-python-workspace" version = "0.1.0" description = "Add your description here" readme = "README.md" @@ -39,6 +39,7 @@ smithy_xml = { workspace = true } smithy_aws_core = { workspace = true } smithy_aws_event_stream = { workspace = true } aws_sdk_signers = {workspace = true } +smithy_python = { workspace = true } [tool.pyright] typeCheckingMode = "strict" diff --git a/uv.lock b/uv.lock index e90f42d74..6cbd215ef 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,7 @@ members = [ "smithy-http", "smithy-json", "smithy-python", + "smithy-python-workspace", "smithy-xml", ] @@ -776,6 +777,10 @@ requires-dist = [ [[package]] name = "smithy-python" +source = { editable = "packages/smithy-python" } + +[[package]] +name = "smithy-python-workspace" version = "0.1.0" source = { virtual = "." } From 83ce7d626caeab70c0713b5fb32dc4bae7669011 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sat, 18 Jul 2026 23:21:24 -0400 Subject: [PATCH 3/4] Remove redundant exception hierarchy test --- .../smithy-python/tests/unit/test_exceptions.py | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 packages/smithy-python/tests/unit/test_exceptions.py diff --git a/packages/smithy-python/tests/unit/test_exceptions.py b/packages/smithy-python/tests/unit/test_exceptions.py deleted file mode 100644 index bd1867f56..000000000 --- a/packages/smithy-python/tests/unit/test_exceptions.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 - -from smithy_python.exceptions import ( - CodegenError, - InvalidInvocationError, - SmithyPythonError, -) - - -def test_error_hierarchy_distinguishes_invocation_and_codegen_failures() -> None: - assert issubclass(CodegenError, SmithyPythonError) - assert issubclass(InvalidInvocationError, SmithyPythonError) - assert not issubclass(InvalidInvocationError, CodegenError) From e9a3c7b7f06bb5da374fad70acd15bdeaa702872 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sun, 19 Jul 2026 17:23:54 -0400 Subject: [PATCH 4/4] Clarify CLI examples and test module entry point Mark generation command examples as schematic and clarify option validation behavior. Exercise python -m smithy_python in a subprocess. --- packages/smithy-python/README.md | 10 +++++----- packages/smithy-python/tests/unit/test_cli.py | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/smithy-python/README.md b/packages/smithy-python/README.md index 9154bc542..79db2d4bc 100644 --- a/packages/smithy-python/README.md +++ b/packages/smithy-python/README.md @@ -10,10 +10,10 @@ commands so that their top-level shape can be developed independently from the generator implementation. ```console -smithy-python generate client -smithy-python generate types +smithy-python generate client [OPTIONS] +smithy-python generate types [OPTIONS] ``` -Both generation commands currently exit with an error explaining that generation -has not been implemented. The package is included in workspace builds to validate -its packaging and entry points. +After validating their invocation options, both generation commands currently exit +with an error explaining that generation has not been implemented. The package is +included in workspace builds to validate its packaging and entry points. diff --git a/packages/smithy-python/tests/unit/test_cli.py b/packages/smithy-python/tests/unit/test_cli.py index a82c87a1a..74a2418cb 100644 --- a/packages/smithy-python/tests/unit/test_cli.py +++ b/packages/smithy-python/tests/unit/test_cli.py @@ -3,7 +3,8 @@ from __future__ import annotations -import importlib +import subprocess +import sys from io import BytesIO from pathlib import Path @@ -75,8 +76,17 @@ def test_missing_command_identifies_available_subcommands( assert capsys.readouterr().err.startswith(expected_usage) -def test_main_module_can_be_imported() -> None: - importlib.import_module("smithy_python.__main__") +def test_main_module_invokes_cli() -> None: + result = subprocess.run( + [sys.executable, "-m", "smithy_python", "--version"], + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 0 + assert result.stdout == f"smithy-python {__version__}\n" + assert result.stderr == "" def test_run_plugin_invocation_reads_standard_input(