Skip to content
Merged
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
4 changes: 3 additions & 1 deletion src/pycodify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -30,6 +31,7 @@
"PathFactoring",
"PathRendering",
"PythonSourceLiteral",
"SourceDocument",
"SourceFormatter",
"SourceFragment",
"generate_python_source",
Expand Down
65 changes: 65 additions & 0 deletions src/pycodify/documents.py
Original file line number Diff line number Diff line change
@@ -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)
45 changes: 45 additions & 0 deletions tests/test_documents.py
Original file line number Diff line number Diff line change
@@ -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")
Loading