-
Notifications
You must be signed in to change notification settings - Fork 34
Add native Python writer and import management #816
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
+776
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
4 changes: 4 additions & 0 deletions
4
packages/smithy-python/.changes/next-release/smithy-python-feature-writer.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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." | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 "") |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
from X import A, B, C__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.