Skip to content

Add experimental Python codegen CLI scaffold - #744

Merged
jonathan343 merged 10 commits into
developfrom
add-smithy-python
Sep 16, 2026
Merged

jonathan343 merged 10 commits into
developfrom
add-smithy-python

Conversation

@jonathan343

@jonathan343 jonathan343 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Overview

This PR lays the foundation for a Python-native Smithy code generator. It adds an experimental smithy-python package with the command-line interface that will eventually generate Python clients and standalone types packages.

The CLI currently validates its inputs and then exits with an explicit “generation is not implemented yet” error. The existing Java generator remains the primary one while the Python implementation is developed incrementally.

Background

The Python generator will consume Smithy JSON AST models and produce packages compatible with the existing Smithy Python runtime. It is distributed separately from those runtime packages and is only needed during code generation.

Smithy’s run plugin invokes the generator as an external process, allowing it to integrate with standard Smithy builds without being loaded into the Smithy CLI or implemented in Java. Read Smithy run plugin docs for more info.

Changes Summary

  • Created a new smithy-python package.
  • Add generate client and generate types commands.
  • Support direct invocation using --model and --output.
    • Note: This list of params will grow as in subsequent PRs
  • Support Smithy run plugin invocation through standard input and the provided plugin environment.
  • Validate invocation inputs and distinguish argument errors from I/O and generation failures.
  • Add unit tests covering CLI behavior, environment parsing, and the error hierarchy.
  • Document the generator architecture, CLI contract, and migration strategy.

CLI smoke tests

$ smithy-python --version
smithy-python 0.0.0
$ smithy-python generate --help
usage: smithy-python generate [-h] {client,types} ...

positional arguments:
  {client,types}
    client        Generate a client package
    types         Generate a standalone types package

options:
  -h, --help      show this help message and exit

Both generation commands accept a model and reach the expected placeholder behavior:

$ echo '{}' | smithy-python generate client --output /tmp/smithy-python-client
smithy-python: error: client generation is not implemented yet
$ echo '{}' | smithy-python generate types --output /tmp/smithy-python-types
smithy-python: error: types generation is not implemented yet

Both generation commands exit with status 1, as expected for the current implementation.


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@jonathan343
jonathan343 requested a review from a team as a code owner July 19, 2026 03:18
@@ -0,0 +1 @@
# Changelog

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for reviewers: Our release infra will automatically handle version bumping and changelog management.

Comment thread designs/codegen/cli.md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't talked much about this publicly since I'm still working on implementations and a blog post, but check a look at shape closures. This is a way to drive generation that doesn't root everything in a service.

What it means for you is relatively little, other than that you should tolerate generating without a set service. Current type codegen generates a synthetic one, but you shouldn't operate that way.

Computing a closure is beyond your scope as a non-java plugin. I would suggest rather that you generate what's given to you and put the onus on customers to use smithy-build transforms to narrow it down to what they want. Maybe later you could implement everything needed to compute it and relax that restriction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thanks for making us aware of this.

I agree with you. I think not requiring --service <service_id> is also an improved user experience. It's pretty easy to detect on our end. For the AWS services, we've automated our infra to do smithy select --selector service to find it for us. I think we can do that at the Python layer here. The only issue I see here is if someone defines two services (not sure if smithy allows it). But in that case, we'd ask them to specify.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's basically how it works on the java side right now. You can ask that there be exactly one service. It may be possible to generate for multiple services, but there's no expectation.

Comment thread designs/codegen/cli.md

## Commands

Generation is organized by artifact type:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But what if you could mix artifacts. You don't now - but you could. Types+client or types+server is an obvious combo that's easy to do without much difficulty.

smithy-python generate --modes client,types

You could default that to just be client since most will want that.

@jonathan343 jonathan343 Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit hesitant to combine multiple modes into one command since that complicates mode-specific options. I'm wondering if we instead could do something like smithy-python generate client ... --with-types or similar.

But honestly, I'm not sure what you mean by client+types compared to what we have now. Can you elaborate a bit?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is to support related types that aren't part of the service's operations. For example, say a service wants to define the events they send out through SNS. Those wouldn't be part of the service shape closure, so if you're filtering down to that then you exclude those shapes.

@jonathan343 jonathan343 Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, that makes sense. The gap is that the Java client generator only emits the service closure (directive.connectedShapes()), so shapes like your SNS events example just get dropped silently today.

My first thought was to have the Python generator skip the closure entirely and generate every data shape in the AST. Before committing to that I ran it over all 431 published AWS models. It broke healthlake out of the box (leaked, unconnected shapes in a second namespace colliding with real ones) and pulled in orphan Cfn* types for two others, while the service closure reproduced the existing SDK surface on all 431. So I'm keeping the service closure as the default, which also matches what Java, smithy-ruby, and smithy-php do. The CLI prints a note when unconnected shapes get left out so it's not silent anymore.

Everything else you called out is in: --service is optional (we auto-detect when there's one), we don't synthesize a service, and types works without a service by just generating every data shape.

That does mean the "types outside the closure" case is deferred for now. I left room in the design doc for an all-shapes option or reading shapeClosures metadata later. I'd like to get moving on the core implementation and we can re-evaluate the interface before we cut a major version. Lmk if you see this as a blocker though and we can hash it out now.

Comment thread packages/smithy-python/src/smithy_python/cli.py Outdated
Comment thread packages/smithy-python/src/smithy_python/cli.py Outdated
Comment thread packages/smithy-python/src/smithy_python/cli.py Outdated
@dataclass(frozen=True, slots=True)
class _Invocation:
artifact: str
model_source: bytes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A readable would be better here I think. You don't want to have to buffer all of that only to pass it into json.reads or whatever else.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a readable would provide much more value here. json.load just does fp.read() internally, and we need the whole AST in memory anyway to resolve shape references. Using an open readable also means owning when to close it. We can probably do that cleanly with a context manager, but seems unnecessary. Additionally, the file and stdin cases close differently so we'd also have to keep track of that.

Lmk if I'm missing something and happy to re-evaluate. Since this is all under the hood, we can also always add if later if we find a good reason to.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json.load just does fp.read() internally

Why is python like this.

As long as you discard the string as soon as possible in favor of a loaded dict or whatever then it's fine.

Comment thread designs/codegen/cli.md
}
```

Artifact-specific options will be appended to `command` after they are defined.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is more for my own understanding: the existing Java generator receives service, module, and moduleVersion as plugin settings for each generated client. So in the future are we going to add options like the following?

{
  "run::python-client": {
    "command": [
      "smithy-python",
      "generate",
      "client",
      "--service",
      "com.amazonaws.bedrockruntime#AmazonBedrockFrontendService",
      "--module",
      "aws_sdk_bedrock_runtime",
      "--module-version",
      "0.8.0"
    ]
  }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup. I need to look into Jordon's comment to see if --service will still be needed. I'll also consider using --package-name which we derive the module name from w/ an optional override (.e.g a aws-sdk-s3 package name generates a aws_sdk_s3 module name). I also think --module-version should become --package-version.

We may also grow this overtime to add new options like --http-client aiohttp | httpx | awscrt

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that you can also put these into environment variables. The run plugin supports putting stuff there. So you could also accept them from the environment if you want.

@jonathan343 jonathan343 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @JordonPhillips!

I addressed a few of your comments. Others might need clarification or further discussion.

Comment thread packages/smithy-python/src/smithy_python/cli.py Outdated
Comment thread designs/codegen/cli.md

## Commands

Generation is organized by artifact type:

@jonathan343 jonathan343 Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit hesitant to combine multiple modes into one command since that complicates mode-specific options. I'm wondering if we instead could do something like smithy-python generate client ... --with-types or similar.

But honestly, I'm not sure what you mean by client+types compared to what we have now. Can you elaborate a bit?

@dataclass(frozen=True, slots=True)
class _Invocation:
artifact: str
model_source: bytes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a readable would provide much more value here. json.load just does fp.read() internally, and we need the whole AST in memory anyway to resolve shape references. Using an open readable also means owning when to close it. We can probably do that cleanly with a context manager, but seems unnecessary. Additionally, the file and stdin cases close differently so we'd also have to keep track of that.

Lmk if I'm missing something and happy to re-evaluate. Since this is all under the hood, we can also always add if later if we find a good reason to.

Comment thread designs/codegen/cli.md

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thanks for making us aware of this.

I agree with you. I think not requiring --service <service_id> is also an improved user experience. It's pretty easy to detect on our end. For the AWS services, we've automated our infra to do smithy select --selector service to find it for us. I think we can do that at the Python layer here. The only issue I see here is if someone defines two services (not sure if smithy allows it). But in that case, we'd ask them to specify.

Comment thread packages/smithy-python/src/smithy_python/cli.py Outdated
Comment thread packages/smithy-python/src/smithy_python/cli.py Outdated
Comment thread packages/smithy-python/src/smithy_python/cli.py Outdated
@jonathan343

Copy link
Copy Markdown
Contributor Author

I'm so used to reviewing other people's PRs I accidentally submitted my responses as a review. Sorry if it looks weird. I see duplicate responses now.

@jonathan343
jonathan343 requested a review from a team September 12, 2026 05:57
@jonathan343
jonathan343 added this pull request to stack #796 September 13, 2026 06:47
Mark generation command examples as schematic and clarify option validation behavior. Exercise python -m smithy_python in a subprocess.
Document that --service is optional when the model contains a single
service, that both artifacts generate every data shape in the model
rather than the service closure, and that case-insensitive name
conflicts are a hard error. Note that run plugin env settings may back
command-line options.
Generating every shape in the model diverged from every other Smithy
generator and fails on a published AWS model whose leaked, unconnected
shapes collide with real ones. Document the service closure as the
default selection, with a note when shapes are left out, and keep the
whole-model behavior for the types artifact when no service is present.

@stobrien89 stobrien89 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just one minor comment

Comment thread packages/smithy-python/src/smithy_python/cli.py Outdated
A missing --model path was pre-checked and reported as an invocation
error with exit 2, while a file that existed but could not be read
raised OSError and exited 1. The design classifies I/O failures as 1,
and tools that distinguish usage errors from runtime failures, including
the Smithy CLI, treat a missing input file as the latter. Drop the
pre-check so every unreadable model exits 1 with a message that names
the path and the cause.

Also state in the design that model failures return 1, which the
implementation already did but the text left implicit.

@arandito arandito left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had a tiny nit but looks good to me.

try:
_resolve_invocation(
args,
environ=os.environ if environ is None else environ,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tiny nit: The current path resolves environ twice: here and when PluginEnvironment.from_environ is called in _resolve_invocation. Is there a reason _resolve_invocation needs this to be resolved beforehand? If not, can we consolidate it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in c7e2f67. This was actually done in the stacked PR, pulled it in here: c7e2f67

main substituted os.environ for a missing environ argument before
passing it on, and PluginEnvironment.from_environ applied the same
default again. Pass the argument through untouched so the fallback
lives only in from_environ, and drop the now unused os import.
@jonathan343
jonathan343 dismissed JordonPhillips’s stale review September 16, 2026 02:42

This PR has received approval from another team member.

@jonathan343
jonathan343 merged commit 6ecbab2 into develop Sep 16, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants