waymark-ai 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ import ast
2
+
3
+ from determystic.external import DeterministicTraverser
4
+
5
+
6
+ class PytestFixturePlacementTraverser(DeterministicTraverser):
7
+ """Require pytest fixtures to live in the initial top-of-file block."""
8
+
9
+ FIXTURE_MODULES = {"pytest", "pytest_asyncio"}
10
+
11
+ def __init__(self, *args, **kwargs):
12
+ super().__init__(*args, **kwargs)
13
+ self._allowed_fixture_nodes: set[int] = set()
14
+ self._reported_fixture_nodes: set[int] = set()
15
+
16
+ def visit_Module(self, node: ast.Module) -> None:
17
+ self._allowed_fixture_nodes = self._find_top_fixture_nodes(node.body)
18
+ self.generic_visit(node)
19
+
20
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
21
+ self._check_fixture_placement(node)
22
+ self.generic_visit(node)
23
+
24
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
25
+ self._check_fixture_placement(node)
26
+ self.generic_visit(node)
27
+
28
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
29
+ self._check_fixture_placement(node)
30
+ self.generic_visit(node)
31
+
32
+ def _find_top_fixture_nodes(self, body: list[ast.stmt]) -> set[int]:
33
+ allowed: set[int] = set()
34
+ seen_normal_code = False
35
+
36
+ for index, stmt in enumerate(body):
37
+ if not seen_normal_code and self._is_allowed_preamble(stmt, index):
38
+ continue
39
+
40
+ if self._fixture_decorator(stmt):
41
+ if not seen_normal_code:
42
+ allowed.add(id(stmt))
43
+ continue
44
+
45
+ seen_normal_code = True
46
+
47
+ return allowed
48
+
49
+ def _check_fixture_placement(
50
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef
51
+ ) -> None:
52
+ decorator = self._fixture_decorator(node)
53
+ if not decorator:
54
+ return
55
+ if id(node) in self._allowed_fixture_nodes:
56
+ return
57
+ if id(node) in self._reported_fixture_nodes:
58
+ return
59
+
60
+ self._reported_fixture_nodes.add(id(node))
61
+ self.add_error(
62
+ decorator,
63
+ "Move pytest fixtures to the top of the file before test/helper code.",
64
+ )
65
+
66
+ def _fixture_decorator(self, node: ast.AST) -> ast.AST | None:
67
+ decorators = getattr(node, "decorator_list", [])
68
+ for decorator in decorators:
69
+ expr = decorator.func if isinstance(decorator, ast.Call) else decorator
70
+ if (
71
+ isinstance(expr, ast.Attribute)
72
+ and expr.attr == "fixture"
73
+ and isinstance(expr.value, ast.Name)
74
+ and expr.value.id in self.FIXTURE_MODULES
75
+ ):
76
+ return decorator
77
+ return None
78
+
79
+ def _is_allowed_preamble(self, stmt: ast.stmt, index: int) -> bool:
80
+ if index == 0 and isinstance(stmt, ast.Expr) and isinstance(
81
+ stmt.value, ast.Constant
82
+ ) and isinstance(stmt.value.value, str):
83
+ return True
84
+ if isinstance(stmt, ast.ClassDef) and self._fixture_decorator(stmt) is None:
85
+ return True
86
+ preamble_types = (ast.Import, ast.ImportFrom, ast.Assign, ast.AnnAssign)
87
+ type_alias = getattr(ast, "TypeAlias", None)
88
+ if type_alias is not None:
89
+ preamble_types = (*preamble_types, type_alias)
90
+ return isinstance(stmt, preamble_types)
@@ -0,0 +1,29 @@
1
+ import ast
2
+
3
+ from determystic.external import DeterministicTraverser
4
+
5
+
6
+ class NoFutureAnnotationsTraverser(DeterministicTraverser):
7
+ """Reject `from __future__ import annotations` imports."""
8
+
9
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
10
+ """Flag postponed-annotation future imports."""
11
+ if self._imports_future_annotations(node):
12
+ self.add_error(
13
+ node,
14
+ (
15
+ "Do not use 'from __future__ import annotations'; "
16
+ "this project runs on a Python version where it is not needed."
17
+ ),
18
+ )
19
+
20
+ self.generic_visit(node)
21
+
22
+ @staticmethod
23
+ def _imports_future_annotations(node: ast.ImportFrom) -> bool:
24
+ """Return True for absolute `__future__.annotations` imports only."""
25
+ return (
26
+ node.level == 0
27
+ and node.module == "__future__"
28
+ and any(alias.name == "annotations" for alias in node.names)
29
+ )
@@ -0,0 +1,139 @@
1
+ import ast
2
+
3
+ from determystic.external import DeterministicTraverser
4
+
5
+
6
+ MAX_SIMPLE_STATEMENTS = 4
7
+ COMPLEX_NODES = (
8
+ ast.If,
9
+ ast.For,
10
+ ast.AsyncFor,
11
+ ast.While,
12
+ ast.Try,
13
+ ast.With,
14
+ ast.AsyncWith,
15
+ ast.Match,
16
+ ast.ListComp,
17
+ ast.SetComp,
18
+ ast.DictComp,
19
+ ast.GeneratorExp,
20
+ )
21
+
22
+
23
+ class SingleUseTrivialHelperTraverser(DeterministicTraverser):
24
+ """Flag private helpers that only add indirection to one simple caller."""
25
+
26
+ def visit_Module(self, node: ast.Module) -> None:
27
+ module_helpers = {
28
+ item.name: item
29
+ for item in node.body
30
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
31
+ and self._is_private_helper(item.name)
32
+ }
33
+
34
+ module_calls = self._collect_module_calls(node)
35
+ for name, helper in module_helpers.items():
36
+ calls = module_calls.get(name, [])
37
+ if self._should_flag(helper, calls):
38
+ self._add_helper_error(helper, calls[0][1])
39
+
40
+ for class_node in [item for item in node.body if isinstance(item, ast.ClassDef)]:
41
+ self._check_class_helpers(class_node)
42
+
43
+ self.generic_visit(node)
44
+
45
+ def _check_class_helpers(self, class_node: ast.ClassDef) -> None:
46
+ helpers = {
47
+ item.name: item
48
+ for item in class_node.body
49
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
50
+ and self._is_private_helper(item.name)
51
+ }
52
+
53
+ calls = self._collect_self_calls(class_node)
54
+ for name, helper in helpers.items():
55
+ helper_calls = calls.get(name, [])
56
+ if self._should_flag(helper, helper_calls):
57
+ self._add_helper_error(helper, helper_calls[0][1])
58
+
59
+ def _should_flag(
60
+ self,
61
+ helper: ast.FunctionDef | ast.AsyncFunctionDef,
62
+ calls: list[tuple[ast.Call, ast.FunctionDef | ast.AsyncFunctionDef]],
63
+ ) -> bool:
64
+ if len(calls) != 1:
65
+ return False
66
+
67
+ caller = calls[0][1]
68
+ if caller is helper:
69
+ return False
70
+
71
+ return self._is_simple_function(helper) and self._is_simple_function(caller)
72
+
73
+ def _add_helper_error(
74
+ self,
75
+ helper: ast.FunctionDef | ast.AsyncFunctionDef,
76
+ caller: ast.FunctionDef | ast.AsyncFunctionDef,
77
+ ) -> None:
78
+ self.add_error(
79
+ helper,
80
+ (
81
+ f"Private helper '{helper.name}' is called only by simple "
82
+ f"'{caller.name}'; inline it unless the helper or caller grows "
83
+ "real branching, error handling, or reuse."
84
+ ),
85
+ )
86
+
87
+ def _is_private_helper(self, name: str) -> bool:
88
+ return name.startswith("_") and not (name.startswith("__") and name.endswith("__"))
89
+
90
+ def _is_simple_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
91
+ body = self._body_without_docstring(node)
92
+ if len(body) > MAX_SIMPLE_STATEMENTS:
93
+ return False
94
+ return not any(isinstance(child, COMPLEX_NODES) for stmt in body for child in ast.walk(stmt))
95
+
96
+ def _body_without_docstring(
97
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef
98
+ ) -> list[ast.stmt]:
99
+ body = list(node.body)
100
+ if (
101
+ body
102
+ and isinstance(body[0], ast.Expr)
103
+ and isinstance(body[0].value, ast.Constant)
104
+ and isinstance(body[0].value.value, str)
105
+ ):
106
+ return body[1:]
107
+ return body
108
+
109
+ def _collect_module_calls(
110
+ self, node: ast.Module
111
+ ) -> dict[str, list[tuple[ast.Call, ast.FunctionDef | ast.AsyncFunctionDef]]]:
112
+ calls: dict[str, list[tuple[ast.Call, ast.FunctionDef | ast.AsyncFunctionDef]]] = {}
113
+ for item in node.body:
114
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
115
+ for call in self._calls_inside(item):
116
+ if isinstance(call.func, ast.Name):
117
+ calls.setdefault(call.func.id, []).append((call, item))
118
+ return calls
119
+
120
+ def _collect_self_calls(
121
+ self, class_node: ast.ClassDef
122
+ ) -> dict[str, list[tuple[ast.Call, ast.FunctionDef | ast.AsyncFunctionDef]]]:
123
+ calls: dict[str, list[tuple[ast.Call, ast.FunctionDef | ast.AsyncFunctionDef]]] = {}
124
+ for item in class_node.body:
125
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
126
+ for call in self._calls_inside(item):
127
+ if self._is_self_attr_call(call):
128
+ calls.setdefault(call.func.attr, []).append((call, item))
129
+ return calls
130
+
131
+ def _calls_inside(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.Call]:
132
+ return [child for stmt in self._body_without_docstring(node) for child in ast.walk(stmt) if isinstance(child, ast.Call)]
133
+
134
+ def _is_self_attr_call(self, node: ast.Call) -> bool:
135
+ return (
136
+ isinstance(node.func, ast.Attribute)
137
+ and isinstance(node.func.value, ast.Name)
138
+ and node.func.value.id in {"self", "cls"}
139
+ )
@@ -0,0 +1 @@
1
+ OPENAI_API_KEY=sk-your-key-here
@@ -0,0 +1,60 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ lint:
14
+ runs-on: ubuntu-latest
15
+ timeout-minutes: 15
16
+ steps:
17
+ - uses: actions/checkout@v7
18
+
19
+ - uses: actions/setup-python@v7
20
+ with:
21
+ python-version: "3.12"
22
+
23
+ - name: Clone Determystic
24
+ run: git clone --depth 1 https://github.com/piercefreeman/deterministic.git ../deterministic
25
+
26
+ - name: Install uv
27
+ run: |
28
+ curl -LsSf https://astral.sh/uv/install.sh | sh
29
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
30
+
31
+ - name: Install dependencies
32
+ run: uv sync --frozen
33
+
34
+ - name: Run linting
35
+ run: |
36
+ uv run ruff format --check src tests examples
37
+ uv run ruff check src tests examples
38
+ uv run ty check src tests examples
39
+ uv run --with-editable ../deterministic determystic validate
40
+
41
+ test:
42
+ runs-on: ubuntu-latest
43
+ timeout-minutes: 15
44
+ steps:
45
+ - uses: actions/checkout@v7
46
+
47
+ - uses: actions/setup-python@v7
48
+ with:
49
+ python-version: "3.12"
50
+
51
+ - name: Install uv
52
+ run: |
53
+ curl -LsSf https://astral.sh/uv/install.sh | sh
54
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
55
+
56
+ - name: Install dependencies
57
+ run: uv sync --frozen
58
+
59
+ - name: Run tests
60
+ run: uv run pytest
@@ -0,0 +1,61 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ steps:
13
+ - uses: actions/checkout@v7.0.1
14
+
15
+ - uses: actions/setup-python@v7.0.0
16
+ with:
17
+ python-version: "3.12"
18
+
19
+ - name: Install uv
20
+ run: |
21
+ curl -LsSf https://astral.sh/uv/install.sh | sh
22
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
23
+
24
+ - name: Install dependencies
25
+ run: uv sync --frozen
26
+
27
+ - name: Run tests
28
+ run: uv run pytest
29
+
30
+ - name: Set package version from tag
31
+ run: uv version "${GITHUB_REF_NAME#v}" --frozen
32
+
33
+ - name: Build packages
34
+ run: |
35
+ uv build
36
+ sed -i 's/name = "pydantic-ai-waymark"/name = "waymark-ai"/' pyproject.toml
37
+ uv build
38
+
39
+ - uses: actions/upload-artifact@v7.0.1
40
+ with:
41
+ name: distributions
42
+ path: dist/
43
+
44
+ publish:
45
+ needs: build
46
+ runs-on: ubuntu-latest
47
+ environment:
48
+ name: pypi
49
+ url: https://pypi.org/p/pydantic-ai-waymark
50
+ permissions:
51
+ id-token: write
52
+ steps:
53
+ - uses: actions/download-artifact@v8.0.1
54
+ with:
55
+ name: distributions
56
+ path: dist/
57
+
58
+ - name: Publish to PyPI
59
+ uses: pypa/gh-action-pypi-publish@v1.14.2
60
+ with:
61
+ print-hash: true
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .ruff_cache/
4
+ .coverage
5
+ .env
6
+ __pycache__/
7
+ *.py[cod]
8
+ dist/
@@ -0,0 +1 @@
1
+ 3.12