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.
- waymark_ai-0.1.2/.determystic/validations/fixtures_at_top.determystic +90 -0
- waymark_ai-0.1.2/.determystic/validations/future_annotations.determystic +29 -0
- waymark_ai-0.1.2/.determystic/validations/inline-simple-helpers.determystic +139 -0
- waymark_ai-0.1.2/.env.example +1 -0
- waymark_ai-0.1.2/.github/workflows/ci.yml +60 -0
- waymark_ai-0.1.2/.github/workflows/release.yml +61 -0
- waymark_ai-0.1.2/.gitignore +8 -0
- waymark_ai-0.1.2/.python-version +1 -0
- waymark_ai-0.1.2/PKG-INFO +321 -0
- waymark_ai-0.1.2/README.md +307 -0
- waymark_ai-0.1.2/examples/Dockerfile +16 -0
- waymark_ai-0.1.2/examples/__init__.py +1 -0
- waymark_ai-0.1.2/examples/docker-compose.yml +57 -0
- waymark_ai-0.1.2/examples/support_agent.py +153 -0
- waymark_ai-0.1.2/examples/web.py +176 -0
- waymark_ai-0.1.2/pyproject.toml +46 -0
- waymark_ai-0.1.2/src/pydantic_ai_waymark/__init__.py +23 -0
- waymark_ai-0.1.2/src/pydantic_ai_waymark/actions.py +295 -0
- waymark_ai-0.1.2/src/pydantic_ai_waymark/agent.py +20 -0
- waymark_ai-0.1.2/src/pydantic_ai_waymark/durability.py +70 -0
- waymark_ai-0.1.2/src/pydantic_ai_waymark/registry.py +64 -0
- waymark_ai-0.1.2/src/pydantic_ai_waymark/request.py +52 -0
- waymark_ai-0.1.2/src/pydantic_ai_waymark/serialization.py +194 -0
- waymark_ai-0.1.2/src/pydantic_ai_waymark/types.py +195 -0
- waymark_ai-0.1.2/src/pydantic_ai_waymark/workflow.py +257 -0
- waymark_ai-0.1.2/tests/test_agent.py +785 -0
- waymark_ai-0.1.2/tests/test_web.py +101 -0
- waymark_ai-0.1.2/uv.lock +983 -0
|
@@ -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 @@
|
|
|
1
|
+
3.12
|