diff --git a/pyproject.toml b/pyproject.toml index ad0e2fe..7f1355d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pycodify" -version = "0.2.0" +version = "0.2.1" description = "Python source code as a serialization format with automatic import resolution" authors = [{name = "Tristan Simas", email = "tristan.simas@mail.mcgill.ca"}] license = {text = "MIT"} diff --git a/src/pycodify/__init__.py b/src/pycodify/__init__.py index b8f2947..63aceda 100644 --- a/src/pycodify/__init__.py +++ b/src/pycodify/__init__.py @@ -14,10 +14,11 @@ imported_name, to_source, ) +from .documents import SourceDocument from .imports import resolve_imports from .paths import PathCollector, PathConstructor, PathFactoring -__version__ = "0.2.0" +__version__ = "0.2.1" __all__ = [ "Assignment", @@ -30,6 +31,7 @@ "PathFactoring", "PathRendering", "PythonSourceLiteral", + "SourceDocument", "SourceFormatter", "SourceFragment", "generate_python_source", diff --git a/src/pycodify/documents.py b/src/pycodify/documents.py new file mode 100644 index 0000000..4ad7dfc --- /dev/null +++ b/src/pycodify/documents.py @@ -0,0 +1,65 @@ +"""A dataclass saved as one Python module of assignments.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import MISSING, Field, fields +from typing import Any, TypeVar + +from .core import Assignment, BlankLine, CodeBlock, generate_python_source + +DocumentT = TypeVar("DocumentT", bound="SourceDocument") + + +class SourceDocument: + """A dataclass whose constructor fields are a module's public assignments. + + Each ``init=True`` field is written as one module-level assignment named + after the field, in declaration order, and read back by that name. The + field declaration is the only place the name is spelled. A field with a + default may be absent from a loaded module; a field without one must be + assigned. ``init=False`` fields are not part of the saved module. + """ + + __slots__ = () + + @classmethod + def assignment_fields(cls) -> tuple[Field[Any], ...]: + """Return the fields saved as assignments, in declaration order.""" + return tuple(field for field in fields(cls) if field.init) # type: ignore[arg-type] + + @classmethod + def assignment_names(cls) -> tuple[str, ...]: + """Return the names this document assigns at module level.""" + return tuple(field.name for field in cls.assignment_fields()) + + @classmethod + def from_namespace(cls: type[DocumentT], namespace: Mapping[str, object]) -> DocumentT: + """Build the document from the names an executed module defines.""" + values: dict[str, object] = {} + for field in cls.assignment_fields(): + if field.name in namespace: + values[field.name] = namespace[field.name] + elif field.default is MISSING and field.default_factory is MISSING: + raise ValueError(f"{cls.__name__} source must define {field.name!r}.") + return cls(**values) + + @classmethod + def from_source(cls: type[DocumentT], source: str) -> DocumentT: + """Execute Python source and build the document from its assignments.""" + namespace: dict[str, object] = {} + exec(compile(source, f"<{cls.__name__}>", "exec"), namespace) # noqa: S102 + return cls.from_namespace(namespace) + + def body(self) -> CodeBlock: + """Return one assignment per saved field, separated by blank lines.""" + items: list[object] = [] + for name in self.assignment_names(): + if items: + items.append(BlankLine()) + items.append(Assignment(name, getattr(self, name))) + return CodeBlock.from_items(items) + + def to_source(self, *, header: str = "", clean_mode: bool = False) -> str: + """Render the document as a complete Python module.""" + return generate_python_source(self.body(), header=header, clean_mode=clean_mode) diff --git a/tests/test_documents.py b/tests/test_documents.py new file mode 100644 index 0000000..62b6647 --- /dev/null +++ b/tests/test_documents.py @@ -0,0 +1,45 @@ +"""A SourceDocument dataclass is saved as, and loaded from, its field names.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import pytest + +from pycodify import SourceDocument + + +@dataclass(frozen=True) +class Recipe(SourceDocument): + title: str + steps: list[str] = field(default_factory=list) + loaded_from: str | None = field(default=None, init=False, compare=False) + + +def test_assignment_names_are_the_constructor_fields() -> None: + assert Recipe.assignment_names() == ("title", "steps") + + +def test_source_assigns_each_field_once_and_loads_back_equal() -> None: + recipe = Recipe(title="tea", steps=["boil", "steep"]) + + source = recipe.to_source(header="# Recipe") + + assert source.splitlines()[0] == "# Recipe" + namespace: dict[str, object] = {} + exec(source, namespace) + assert {name for name in namespace if not name.startswith("__")} == {"title", "steps"} + assert Recipe.from_source(source) == recipe + + +def test_absent_field_takes_its_declared_default() -> None: + assert Recipe.from_namespace({"title": "tea"}) == Recipe(title="tea") + + +def test_absent_required_field_is_an_error() -> None: + with pytest.raises(ValueError, match="Recipe source must define 'title'"): + Recipe.from_namespace({"steps": []}) + + +def test_names_outside_the_declaration_are_ignored() -> None: + assert Recipe.from_namespace({"title": "tea", "other": 1}) == Recipe(title="tea")