Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions designs/codegen/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,4 @@ behavior of generated packages.
* [Code Generator CLI](cli.md)
* [Service and Data-Shape Selection](selection.md)
* [Native Python Symbols](symbols.md)
* [Native Python Writer](writer.md)
105 changes: 105 additions & 0 deletions designs/codegen/writer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Native Python writer

`PythonWriter` turns lines of Python text and `TypeReference` values into the
source of one module. Pass references from `SymbolProvider.type_reference()`
or construct them directly. The writer chooses annotation spellings and imports.
The caller decides which declarations, fields and defaults to write.

```python
from smithy_python.symbols import TypeReference
from smithy_python.writer import PythonWriter

node = TypeReference("Node", "example.models", nullable=True)
writer = PythonWriter(
"example.models", declarations={"Node"}, local_names={"children", "amount"}
)
writer.line("class Node:")
with writer.indent():
writer.line("children: ", TypeReference("list", "builtins", (node,)))
writer.line("amount: ", TypeReference("Decimal", "decimal"))
source = writer.render()
```

The result is:

```python
from __future__ import annotations

from decimal import Decimal


class Node:
children: list[Node | None]
amount: Decimal
```

## Writing a module

`PythonWriter(module, *, declarations=(), local_names=())` takes the destination
module name and the names the caller will use. Supply valid Python names.
`declarations` contains module-level names. Duplicates raise `CodegenError`.
`local_names` contains field and local names that can shadow annotations across
the module. Do not automatically include enum constants or other class members
that are not in an annotation's scope. Repeated local names are allowed because
different classes can have the same field name.
The symbol provider already checks for duplicate fields within a declaration.

* `line(*parts)` joins strings and type references without separators at the
current indentation. `line()` writes a blank line without spaces.
* `indent()` adds four spaces inside a context manager and restores indentation
even when the block raises an exception.
* `render()` returns source ending in a newline, with a future-annotations
header, sorted imports and the body in written order. It does not modify the
writer. Adding another reference can change aliases in the next render.

Raw text is trusted Python, not a template. The writer does not parse it to
find missing name reservations. Callers supply blank lines between declarations.

## Annotations and imports

Nested arguments retain their order. `nullable=True` adds `| None` only at that
level: `list[str | None]` differs from `list[str] | None`. The `None` literal
stays `None`, including when marked nullable. Only that literal can omit its
module. Other module-less references raise `CodegenError`.

Generated source targets Python 3.12+. The future import permits references to
classes defined later, including recursive types. These are annotations, not
expressions to evaluate while defining classes.

* Builtins normally use `str`, `int`, `list[T]` and `dict[K, V]`.
* Types in the current module use their bare names without imports.
* Other types normally use `from module import Name`.

Imports are deduplicated by module and name, then sorted by module, name and
alias. Referenced packages don't have to be installed in the generator's environment.
For example, rendering a reference to `smithy_core.documents.Document` does not
import `smithy_core`.

## Names that collide

An import matching a declaration, local name, builtin, the future import's
`annotations` name, or another imported type uses a module-derived alias.
For example, `decimal.Decimal` becomes `_decimal_Decimal`. Dots in a module
path become underscores. All imports sharing a short name receive aliases,
regardless of the order they were written.

A field named `list` makes builtin list references use `_builtins.list[T]`, with
`import builtins as _builtins`. An alias that still collides raises
`CodegenError`. The writer never adds numbered suffixes or renames declarations.
The builtin-name list combines Python 3.12 through 3.15, including
platform-specific names, so imports do not depend on the generator host.
Update the list when adding support for another Python version.

Comparisons follow Python's treatment of identifier spellings. For example,
`K` and `K` bind the same name and cannot be separate declarations. Original
spellings, including `_2HTTPServer`, are retained in emitted source. Module names
follow the same comparison rules, so equivalent spellings do not cause self-imports.
Equivalent references share one import, using the lexicographically smallest
supplied module/name pair so the choice does not depend on reference order.

A same-module reference also listed in `local_names` raises `CodegenError`.
This first version does not add self-imports or aliases for local declarations.
Listing it only in `declarations` is normal.

The writer returns source in memory. File output, documentation conversion and
actual declaration generation remain separate follow-ups.

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.

not a blocker, but I have a strong feeling that the Writer will need a full featured code manipulation API, with a whole method section for imports.

It can be incrementally added as the need arises.

Thinking of potential cases:

  • dotted imports, relative imports
  • merged imports from same module
  • from X import A, B, C
  • import caller-chosen aliasing
  • runtime imports
  • guarded imports
  • system conditional imports
  • re-exports
  • __all__

Also on the topic of formatting, I would encourage writing codegen in a way that has tools available to simply emit proper formatting rather than rely on post-emit tools like ruff. This saves downstream trouble, if you can guarantee codegen customers won't need to apply a formatter.

It doesn't need to be ideally formatted, just readable enough that applying a formatter is optional.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "feature",
"description": "Added a native Python writer with structured annotations, deterministic collision-safe imports, indentation contexts, and forward-reference support."
}
215 changes: 215 additions & 0 deletions packages/smithy-python/src/smithy_python/writer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
"""Structured annotation writing for a single Python module."""

from __future__ import annotations

from collections import Counter
from collections.abc import Generator, Iterable
from contextlib import contextmanager
from unicodedata import normalize

from .exceptions import CodegenError
from .symbols import TypeReference

# Union of Python 3.12-3.15 builtins, including platform-specific names.
# Keep this fixed so import planning does not depend on the generator host.
_BUILTINS = frozenset(
"ArithmeticError AssertionError AttributeError BaseException BaseExceptionGroup "
"BlockingIOError BrokenPipeError BufferError BytesWarning ChildProcessError "
"ConnectionAbortedError ConnectionError ConnectionRefusedError ConnectionResetError "
"DeprecationWarning EOFError Ellipsis EncodingWarning EnvironmentError Exception "
"ExceptionGroup False FileExistsError FileNotFoundError FloatingPointError "
"FutureWarning GeneratorExit IOError ImportCycleError ImportError ImportWarning IndentationError "
"IndexError InterruptedError IsADirectoryError KeyError KeyboardInterrupt "
"LookupError MemoryError ModuleNotFoundError NameError None NotADirectoryError "
"NotImplemented NotImplementedError OSError OverflowError PendingDeprecationWarning "
"PermissionError ProcessLookupError PythonFinalizationError RecursionError ReferenceError ResourceWarning "
"RuntimeError RuntimeWarning StopAsyncIteration StopIteration SyntaxError "
"SyntaxWarning SystemError SystemExit TabError TimeoutError True TypeError "
"UnboundLocalError UnicodeDecodeError UnicodeEncodeError UnicodeError "
"UnicodeTranslateError UnicodeWarning UserWarning ValueError Warning WindowsError "
"ZeroDivisionError _IncompleteInputError __build_class__ __debug__ __doc__ __import__ __lazy_import__ __loader__ "
"__name__ __package__ __spec__ abs aiter all anext any ascii bin bool breakpoint "
"bytearray bytes callable chr classmethod compile complex copyright credits "
"delattr dict dir divmod enumerate eval exec exit filter float format frozendict frozenset "
"getattr globals hasattr hash help hex id input int isinstance issubclass iter "
"len license list locals map max memoryview min next object oct open ord pow "
"print property quit range repr reversed round sentinel set setattr slice sorted "
"staticmethod str sum super tuple type vars zip".split()
)


def _binding(name: str) -> str:
"""Compare names as Python binds them, without changing emitted spelling."""
return normalize("NFKC", name)


class PythonWriter:
"""Write trusted Python lines, retaining type references until rendering."""

def __init__(
self,
module: str,
*,
declarations: Iterable[str] = (),
local_names: Iterable[str] = (),
) -> None:
self._module = _binding(module)
self._declarations: set[str] = set()
for name in declarations:
binding = _binding(name)
if binding in self._declarations:
raise CodegenError(f"Duplicate generated declaration: {name!r}")
self._declarations.add(binding)
self._local_names = frozenset(_binding(name) for name in local_names)
self._depth = 0
self._lines: list[tuple[int, tuple[str | TypeReference, ...]]] = []

def line(self, *parts: str | TypeReference) -> None:
"""Append one line, concatenating its parts without separators."""
self._lines.append((self._depth, parts))

@contextmanager
def indent(self) -> Generator[None]:
"""Indent by four spaces for the duration of the block."""
self._depth += 1
try:
yield
finally:
self._depth -= 1

def _plan_imports(self) -> tuple[dict[tuple[str | None, str], str], list[str]]:
identities: set[tuple[str | None, str]] = set()
pending = [
part
for _, parts in self._lines
for part in parts
if isinstance(part, TypeReference)
]
while pending:
ref = pending.pop()
if ref.module is None and (ref.name != "None" or ref.arguments):
raise CodegenError(
f"Only the None literal may omit its module: {ref.name!r}"
)
identities.add((ref.module, ref.name))
pending.extend(ref.arguments)
# Equivalent Python identifiers share one import. Pick a supplied spelling
# deterministically, without rewriting the caller's source names.
representatives: dict[tuple[str | None, str], tuple[str | None, str]] = {}
for module, name in sorted(
identities, key=lambda item: (item[0] or "", item[1])
):
identity = (
_binding(module) if module is not None else None,
_binding(name),
)
representatives.setdefault(identity, (module, name))
current_names = {
name for module, name in representatives if module == self._module
}
for name in sorted(current_names & self._local_names):
raise CodegenError(
f"Same-module reference {self._module}.{name} conflicts with local name {name!r}"
)
reserved = self._declarations | self._local_names | current_names
external = sorted(
(module, name)
for (module_binding, _), (module, name) in representatives.items()
if module is not None and module_binding not in ("builtins", self._module)
)
counts = Counter(_binding(name) for _, name in external)
names: dict[tuple[str | None, str], str] = {}
imports: list[tuple[str, str, str, str]] = []
for module, name in external:
alias = (
f"_{module.replace('.', '_')}_{name}"
if _binding(name) in reserved
or _binding(name) in _BUILTINS
or _binding(name) == "annotations"
or counts[_binding(name)] > 1
else name
)
names[(module, name)] = alias
suffix = f" as {alias}" if alias != name else ""
imports.append(
(module, name, alias, f"from {module} import {name}{suffix}")
)
qualify_builtins = False
for (module_binding, name_binding), (module, name) in representatives.items():
if module is None:
names[(module, name)] = "None"
elif module_binding == "builtins":
shadowed = name_binding in reserved
names[(module, name)] = f"_builtins.{name}" if shadowed else name
qualify_builtins |= shadowed
elif module_binding == self._module:
names[(module, name)] = name
if qualify_builtins:
imports.append(
("builtins", "", "_builtins", "import builtins as _builtins")
)

# Check the complete plan, including unaliased imports. No binding gets
# priority merely because its reference was encountered first.
bindings = dict.fromkeys(reserved, "a generated declaration or local name")
imports.sort()
for module, name, alias, _ in imports:
owner = f"{module}.{name}" if name else module
binding = _binding(alias)
if binding in bindings or binding in _BUILTINS:
conflict = bindings.get(binding, "a builtin")
raise CodegenError(
f"Import binding {alias!r} for {owner} conflicts with {conflict}"
)
bindings[binding] = owner
for module, name in identities:
identity = (
_binding(module) if module is not None else None,
_binding(name),
)
names[(module, name)] = names[representatives[identity]]
return names, [statement for _, _, _, statement in imports]

@staticmethod
def _annotation(
reference: TypeReference, names: dict[tuple[str | None, str], str]
) -> str:
# Emit tokens rather than recursing through potentially deep collections.
pending: list[str | TypeReference] = [reference]
result: list[str] = []
while pending:
part = pending.pop()
if isinstance(part, str):
result.append(part)
continue
if part.module is None:
result.append("None")
continue
result.append(names[(part.module, part.name)])
if part.nullable:
pending.append(" | None")
if part.arguments:
pending.append("]")
for index in range(len(part.arguments) - 1, -1, -1):
pending.append(part.arguments[index])
if index:
pending.append(", ")
pending.append("[")
return "".join(result)

def render(self) -> str:
"""Return complete source without changing the recorded lines."""
names, imports = self._plan_imports()
header = "from __future__ import annotations\n"
if imports:
header += "\n" + "\n".join(imports) + "\n"
body: list[str] = []
for depth, parts in self._lines:
text = "".join(
part if isinstance(part, str) else self._annotation(part, names)
for part in parts
)
body.append(" " * depth + text if text else "")
return header + ("\n\n" + "\n".join(body) + "\n" if body else "")
Loading
Loading