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
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,18 @@ Alternatively the map method can be used to return a new type instance with the
│ ├── ci_tests.yaml
│ └── publish.yaml
├── dev_tools
│ ├── create_examples
│ │ ├── collection
│ │ │ ├── __init__.py
│ │ │ ├── example.py
│ │ │ ├── record.py
│ │ │ └── recorder.py # User entrypoint `example` and it's inner class `Example`
│ │ ├── transformation
│ │ │ ├── __init__.py
│ │ │ ├── ast_editing.py
│ │ │ ├── format_examples.py
│ │ │ └── transform.py
│ │ └── __init__.py
│ ├── __init__.py
│ ├── update_cov.py
│ └── update_readme.py
Expand All @@ -268,26 +280,26 @@ Alternatively the map method can be used to return a new type instance with the
│ └── danom
│ ├── _monads
│ │ ├── __init__.py
│ │ ├── _either.py # A simple Either monad, includes the base Either, Right and Left.
│ │ ├── _either.py # A simple Either monad, includes the base Either, Right and Left.
│ │ ├── _option.py
│ │ ├── _result.py # A simple Result monad, includes the base Result, Ok and Err.
│ │ └── _safe.py # decorators that except given exception types and return a monad of the result
│ │ ├── _result_v2.py
│ │ └── _safe.py # decorators that except given exception types and return a monad of the result
│ ├── _stream
│ │ ├── __init__.py
│ │ ├── _async.py
│ │ ├── _base.py # the base class for Stream
│ │ ├── _base.py # the base class for Stream
│ │ ├── _par.py
│ │ └── _sync.py
│ ├── __init__.py
│ ├── _new_type.py # function to create a new type, probably worth deprecating soon
│ └── _utils.py # random junk I can't think of place to put. compose, all_of, any_of, etc
│ ├── _new_type.py # function to create a new type, probably worth deprecating soon
│ └── _utils.py # random junk I can't think of place to put. compose, all_of, any_of, etc
├── tests
│ ├── monads
│ │ ├── __init__.py
│ │ ├── test_either.py
│ │ ├── test_monad_laws.py
│ │ ├── test_option.py
│ │ └── test_result.py
│ │ └── test_result_v2.py
│ ├── stream
│ │ ├── __init__.py
│ │ ├── _common.py
Expand Down
Empty file.
Empty file.
53 changes: 53 additions & 0 deletions dev_tools/create_examples/collection/example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from __future__ import annotations

from collections.abc import Callable
from typing import Any

import attrs

from .record import ExampleRecord
from .recorder import _RECORDER, Recorder


@attrs.define(frozen=True)
class Example[T]:
fn: Callable
args: tuple[Any, ...]
kwargs: dict[str, Any]
actual_result: T
recorder: Recorder
description: str | None = None

def __eq__(self, expected: T) -> bool:
self.recorder.record_example(
ExampleRecord.new(
self.fn,
self.args,
self.kwargs,
returned=self.actual_result,
expected=expected,
description=self.description,
)
)
return self.actual_result == expected

def __hash__(self) -> int:
hash_value = "".join(
[
self.fn.__qualname__,
self.fn.__module__,
str(self.args),
str(self.kwargs),
str(self.actual_result),
]
)
return hash(hash_value)


def example(
fn: Callable, *args: tuple[Any, ...], description: str | None = None, **kwargs: dict[str, Any]
) -> Example:
value = fn(*args, **kwargs)
return Example(
fn, args, kwargs, actual_result=value, description=description, recorder=_RECORDER
)
64 changes: 64 additions & 0 deletions dev_tools/create_examples/collection/record.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from __future__ import annotations

import inspect
from collections.abc import Callable
from pathlib import Path
from typing import Any, Self

import attrs


@attrs.define(frozen=True, eq=True)
class ExampleRecord:
cls_name: str
fn_name: str
module: str
src_file: str
ent_repr: str
args: tuple[Any, ...]
kwargs: dict[str, Any]
returned: Any
expected: Any
description: str | None = None

@classmethod
def new( # noqa: PLR0913
cls,
fn: Callable,
args: tuple[Any, ...],
kwargs: dict[str, Any],
returned: Any, # noqa: ANN401
expected: Any, # noqa: ANN401
description: str | None = None,
) -> Self:
return cls(
cls_name=fn.__self__.__class__.__name__,
fn_name=fn.__name__,
module=fn.__module__,
src_file=str(Path(inspect.getsourcefile(fn))),
ent_repr=f"{fn.__self__!r}.{fn.__name__}"
if fn.__class__.__name__ == "method"
else fn.__name__,
args=tuple(_get_repr(arg) for arg in args),
kwargs={k: _get_repr(v) for k, v in kwargs.items()},
returned=_get_repr(returned),
expected=_get_repr(expected),
description=description,
)

def to_dict(self) -> dict[str, str]:
return attrs.asdict(self)


def _get_repr(arg: Any) -> str: # noqa: ANN401
return _clean_repr(repr(arg))


def _clean_repr(raw: str) -> str:
if raw.startswith("<function "):
return raw.removeprefix("<function ").split(" at ")[0]
if raw.startswith("<class "):
return raw.removeprefix("<class ").strip("'>").split(".")[-1]
if raw.startswith("<built-in function "):
return raw.removeprefix("<built-in function ").strip(" >")
return raw
42 changes: 42 additions & 0 deletions dev_tools/create_examples/collection/recorder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""repo-map-desc: User entrypoint `example` and it's inner class `Example`

The equality operator is where the magic happens
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Self

import attrs

from .record import ExampleRecord


@attrs.define
class Recorder:
path: Path = Path("./.papertrail_cache/examples.json")
records: list[ExampleRecord] = attrs.field(factory=list)
files: dict[Path, str] = attrs.field(factory=dict)

def __attrs_post_init__(self) -> None:
self.path = Path(self.path)

def record_example(self, example: ExampleRecord) -> Self:
self.records.append(example)
return self

def prepare_files(self) -> Self:
self.files[self.path] = json.dumps([r.to_dict() for r in self.records], indent=2)
self.files[self.path.parent / ".gitignore"] = "# automatically created by papertrail\n*"
return self

def write_examples(self) -> Self:
for path, data in self.files.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(data)
return self


_RECORDER = Recorder()
Empty file.
97 changes: 97 additions & 0 deletions dev_tools/create_examples/transformation/ast_editing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import re
import textwrap

import libcst as cst


def update_function_docstrings(code: str, examples: dict[str, str]) -> str:
"""Add or update the example section in selected function docstrings."""
module = cst.parse_module(code)
return module.visit(DocstringTransformer(examples)).code


class DocstringTransformer(cst.CSTTransformer):
"""Add, append, or replace example sections in function docstrings."""

def __init__(self, examples: dict[str, str]) -> None:
self.examples = examples

def leave_FunctionDef( # noqa: N802
self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef
) -> cst.FunctionDef:
example = self.examples.get(original_node.name.value, "")
if not example:
return updated_node

body = updated_node.body
if isinstance(body, cst.IndentedBlock):
statements = body.body
docstring = _get_docstring(statements)
if docstring is None:
statements = (_make_docstring(example), *statements)
else:
index, statement, value = docstring
new_value = _update_docstring(value.raw_value, example)
replacement = statement.with_changes(
body=(cst.Expr(value=cst.SimpleString(new_value)),)
)
statements = (*statements[:index], replacement, *statements[index + 1 :])

return updated_node.with_changes(body=body.with_changes(body=statements))

statements = tuple(cst.SimpleStatementLine(body=(statement,)) for statement in body.body)
return updated_node.with_changes(
body=cst.IndentedBlock(body=(_make_docstring(example), *statements), indent=" ")
)


def _get_docstring(
statements: tuple[cst.BaseStatement, ...],
) -> tuple[int, cst.SimpleStatementLine, cst.SimpleString] | None:
if not statements or not isinstance(statements[0], cst.SimpleStatementLine):
return None

statement = statements[0]
if len(statement.body) != 1 or not isinstance(statement.body[0], cst.Expr):
return None

value = statement.body[0].value
if not isinstance(value, cst.SimpleString):
return None

return 0, statement, value


def _make_docstring(example: str) -> cst.SimpleStatementLine:
value = _update_docstring("", example)
return cst.SimpleStatementLine(body=(cst.Expr(value=cst.SimpleString(value)),))


def _update_docstring(docstring: str, example: str) -> str:
example = _format_example(example)
pattern = (
r"(?ms)^[ \t]*Papertrail examples:\n.*?\n[ \t]*::"
r"|^[ \t]*\.\. code-block:: python\n.*?\n[ \t]*::"
)
matches = list(re.finditer(pattern, docstring))
if matches:
first = matches[0]
sections = [docstring[: first.start()], example]
end = first.end()
for match in matches[1:]:
sections.append(docstring[end : match.start()])
end = match.end()
sections.append(docstring[end:])
new_doc = "".join(sections)
else:
new_doc = f"{docstring}\n\n{example}"

return f'"""{new_doc.strip()}\n """'


def _format_example(example: str) -> str:
lines = example.splitlines(keepends=True)
if not lines:
return ""

return textwrap.indent("".join(lines), " ", predicate=lambda line: bool(line.strip()))
50 changes: 50 additions & 0 deletions dev_tools/create_examples/transformation/format_examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from collections import defaultdict

import black

from ..collection.record import ExampleRecord # noqa: TID252


def collect_example_strs(examples: list[dict]) -> dict[str, dict[str, list[str]]]:
fn_examples = defaultdict(_inner)

for data in examples:
ex = ExampleRecord(**data)

if ex.returned != ex.expected:
continue

fn_examples[ex.src_file][ex.fn_name].append(example_to_str(ex))

return {k: dict(v) for k, v in fn_examples.items()}


def _inner() -> defaultdict:
return defaultdict(list)


def example_to_str(example: ExampleRecord) -> str:
sig = ", ".join(
part
for part in (
", ".join(map(str, example.args)),
", ".join(f"{k}={v}" for k, v in example.kwargs.items()),
)
if part
)
expr = f"{example.ent_repr}({sig}) == {example.returned}"
formatted = black.format_str(expr, mode=black.Mode())
lines = formatted.rstrip().splitlines()
doctest = "\n".join(
f"{' >>>' if i == 0 else ' ...'} {line}" for i, line in enumerate(lines)
)
return f"{example.description or ''}\n\n.. code-block:: python\n\n{doctest}\n True"


def reduce_examples_to_example_str(
fn_examples: dict[str, dict[str, list[str]]],
) -> dict[str, dict[str, str]]:
return {
path: {k: "Papertrail examples:\n\n" + "\n".join(v) + "\n::" for k, v in fn.items()}
for path, fn in fn_examples.items()
}
16 changes: 16 additions & 0 deletions dev_tools/create_examples/transformation/transform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import json
from pathlib import Path

from .ast_editing import update_function_docstrings
from .format_examples import collect_example_strs, reduce_examples_to_example_str


def update_modified_docstrings(examples_cache_path: Path) -> None:
examples = json.loads(examples_cache_path.read_text())
reduced_examples = reduce_examples_to_example_str(collect_example_strs(examples))

for path, module_examples in reduced_examples.items():
code = Path(path).read_text()
new_code = update_function_docstrings(code, module_examples)
if new_code != code:
Path(path).write_text(new_code)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ danom = ["py.typed"]
dev = [
"hypothesis>=6.148.2",
"ipykernel>=7.1.0",
"libcst>=1.9.0",
"papertrail>=0.1.2",
"pre-commit>=4.5.0",
"pytest>=9.0.1",
Expand Down
2 changes: 1 addition & 1 deletion src/danom/_monads/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from ._either import Either, Left, Right
from ._option import Null, Option, Some
from ._result import Err, Ok, Result
from ._result_v2 import Err, Ok, Result
from ._safe import safe, safe_method

__all__ = [
Expand Down
Loading
Loading