whiletrue 0.1.0__py3-none-any.whl
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.
- whiletrue/__init__.py +0 -0
- whiletrue/__main__.py +3 -0
- whiletrue/abort.py +79 -0
- whiletrue/basics/__init__.py +99 -0
- whiletrue/basics/agent.py +249 -0
- whiletrue/basics/comments.py +113 -0
- whiletrue/basics/diff_size.py +80 -0
- whiletrue/basics/exec_.py +84 -0
- whiletrue/basics/history.py +117 -0
- whiletrue/basics/human.py +201 -0
- whiletrue/basics/json_source.py +35 -0
- whiletrue/basics/markdown_source.py +55 -0
- whiletrue/basics/process.py +99 -0
- whiletrue/basics/sessions.py +62 -0
- whiletrue/basics/set_.py +29 -0
- whiletrue/basics/shell.py +98 -0
- whiletrue/basics/wake.py +27 -0
- whiletrue/claude_code/__init__.py +58 -0
- whiletrue/claude_code/endpoint.py +143 -0
- whiletrue/claude_code/headless.py +181 -0
- whiletrue/claude_code/interactive.py +344 -0
- whiletrue/claude_code/learnings.py +32 -0
- whiletrue/claude_code/panes.py +164 -0
- whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
- whiletrue/claude_code/plugin/hook.py +44 -0
- whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
- whiletrue/claude_code/plugin/mcp_server.py +98 -0
- whiletrue/claude_code/session.py +345 -0
- whiletrue/claude_code/transcript.py +123 -0
- whiletrue/cli/__init__.py +15 -0
- whiletrue/cli/__main__.py +3 -0
- whiletrue/cli/app.py +550 -0
- whiletrue/cli/common.py +36 -0
- whiletrue/cli/gates.py +156 -0
- whiletrue/cli/ops.py +339 -0
- whiletrue/cli/panel.py +198 -0
- whiletrue/cli/registry.py +143 -0
- whiletrue/cli/templates.py +24 -0
- whiletrue/codex/__init__.py +14 -0
- whiletrue/codex/runner.py +243 -0
- whiletrue/control/__init__.py +38 -0
- whiletrue/control/chain.py +61 -0
- whiletrue/control/loop.py +139 -0
- whiletrue/control/map_.py +101 -0
- whiletrue/control/parallel.py +32 -0
- whiletrue/control/retry.py +100 -0
- whiletrue/control/sequence.py +40 -0
- whiletrue/control/switch.py +67 -0
- whiletrue/core/__init__.py +260 -0
- whiletrue/core/caps.py +139 -0
- whiletrue/core/comments.py +80 -0
- whiletrue/core/composite.py +140 -0
- whiletrue/core/config.py +461 -0
- whiletrue/core/context.py +177 -0
- whiletrue/core/duration.py +19 -0
- whiletrue/core/events.py +94 -0
- whiletrue/core/expr.py +85 -0
- whiletrue/core/history.py +83 -0
- whiletrue/core/lock.py +86 -0
- whiletrue/core/memory_store.py +352 -0
- whiletrue/core/module.py +146 -0
- whiletrue/core/outcomes.py +82 -0
- whiletrue/core/registry.py +129 -0
- whiletrue/core/replay.py +58 -0
- whiletrue/core/runner.py +98 -0
- whiletrue/core/runtime.py +538 -0
- whiletrue/core/source.py +45 -0
- whiletrue/core/store.py +207 -0
- whiletrue/core/template.py +60 -0
- whiletrue/core/validate.py +142 -0
- whiletrue/core/wake.py +117 -0
- whiletrue/core/workspace.py +55 -0
- whiletrue/git/__init__.py +31 -0
- whiletrue/git/modules.py +128 -0
- whiletrue/git/workspaces.py +77 -0
- whiletrue/github/__init__.py +30 -0
- whiletrue/github/client.py +269 -0
- whiletrue/github/filebacked.py +87 -0
- whiletrue/github/memory.py +96 -0
- whiletrue/github/plugin.py +343 -0
- whiletrue/github/specs.py +39 -0
- whiletrue/opencode/__init__.py +25 -0
- whiletrue/opencode/runner.py +186 -0
- whiletrue/py.typed +0 -0
- whiletrue/recipes/issue-loop.yaml +59 -0
- whiletrue/recipes/ralph.yaml +14 -0
- whiletrue/recipes/review-pass.yaml +27 -0
- whiletrue/recipes/review-respond.yaml +22 -0
- whiletrue/store_sqlite/__init__.py +6 -0
- whiletrue/store_sqlite/migrations.py +132 -0
- whiletrue/store_sqlite/sqlite_store.py +537 -0
- whiletrue/tmux/__init__.py +11 -0
- whiletrue/tmux/controller.py +197 -0
- whiletrue-0.1.0.dist-info/METADATA +48 -0
- whiletrue-0.1.0.dist-info/RECORD +98 -0
- whiletrue-0.1.0.dist-info/WHEEL +4 -0
- whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
- whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
whiletrue/core/caps.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CapError(Exception):
|
|
11
|
+
"""Caps are named schemas attached to ports (like GStreamer caps)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CapValidation:
|
|
16
|
+
ok: bool
|
|
17
|
+
value: Any = None
|
|
18
|
+
errors: list[str] = field(default_factory=lambda: [])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_CAP_NAME = re.compile(r"^[A-Z][A-Za-z0-9]*$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def format_validation_error(error: ValidationError) -> list[str]:
|
|
25
|
+
"""`path: message` per issue, in the order pydantic reports them."""
|
|
26
|
+
out: list[str] = []
|
|
27
|
+
for issue in error.errors():
|
|
28
|
+
path = ".".join(str(p) for p in issue["loc"])
|
|
29
|
+
out.append(f"{path}: {issue['msg']}" if path else issue["msg"])
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CapRegistry:
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self._caps: dict[str, TypeAdapter[Any]] = {}
|
|
36
|
+
|
|
37
|
+
def register(self, name: str, schema: Any) -> None:
|
|
38
|
+
"""`schema` is anything pydantic can validate: a model class, `str`, `Any`, ..."""
|
|
39
|
+
if not _CAP_NAME.match(name):
|
|
40
|
+
raise CapError(f'Invalid cap name "{name}" (use PascalCase)')
|
|
41
|
+
if name in self._caps:
|
|
42
|
+
raise CapError(f'Cap "{name}" is already registered')
|
|
43
|
+
self._caps[name] = schema if isinstance(schema, TypeAdapter) else TypeAdapter(schema) # pyright: ignore[reportUnknownVariableType]
|
|
44
|
+
|
|
45
|
+
def has(self, name: str) -> bool:
|
|
46
|
+
return name in self._caps
|
|
47
|
+
|
|
48
|
+
def names(self) -> list[str]:
|
|
49
|
+
return list(self._caps)
|
|
50
|
+
|
|
51
|
+
def _get(self, name: str) -> TypeAdapter[Any]:
|
|
52
|
+
adapter = self._caps.get(name)
|
|
53
|
+
if adapter is None:
|
|
54
|
+
raise CapError(f'Unknown cap "{name}"')
|
|
55
|
+
return adapter
|
|
56
|
+
|
|
57
|
+
def validate(self, name: str, value: Any) -> CapValidation:
|
|
58
|
+
adapter = self._get(name)
|
|
59
|
+
try:
|
|
60
|
+
parsed = adapter.validate_python(value)
|
|
61
|
+
except ValidationError as error:
|
|
62
|
+
return CapValidation(ok=False, errors=format_validation_error(error))
|
|
63
|
+
return CapValidation(ok=True, value=adapter.dump_python(parsed, mode="json", by_alias=True))
|
|
64
|
+
|
|
65
|
+
def to_json_schema(self, name: str) -> dict[str, Any]:
|
|
66
|
+
return self._get(name).json_schema()
|
|
67
|
+
|
|
68
|
+
def compatible(self, from_cap: str, to_cap: str) -> bool:
|
|
69
|
+
"""Can a value produced under `from_cap` be consumed under `to_cap`?
|
|
70
|
+
|
|
71
|
+
Nominal: identical caps, or either side is `Any`. Unknown caps are never compatible.
|
|
72
|
+
"""
|
|
73
|
+
if not self.has(from_cap) or not self.has(to_cap):
|
|
74
|
+
return False
|
|
75
|
+
return "Any" in (from_cap, to_cap) or from_cap == to_cap
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Strict(BaseModel):
|
|
79
|
+
model_config = ConfigDict(strict=True, populate_by_name=True)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Issue(_Strict):
|
|
83
|
+
id: str | int
|
|
84
|
+
number: int | None = None
|
|
85
|
+
title: str
|
|
86
|
+
body: str
|
|
87
|
+
labels: list[str] | None = None
|
|
88
|
+
url: str | None = None
|
|
89
|
+
spec_refs: list[str] | None = None
|
|
90
|
+
depends_on: list[str | int] | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class DiffFile(_Strict):
|
|
94
|
+
path: str
|
|
95
|
+
additions: int
|
|
96
|
+
deletions: int
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Diff(_Strict):
|
|
100
|
+
files: list[DiffFile]
|
|
101
|
+
patch: str | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PullRequest(_Strict):
|
|
105
|
+
number: int
|
|
106
|
+
url: str
|
|
107
|
+
branch: str
|
|
108
|
+
base: str | None = None
|
|
109
|
+
title: str | None = None
|
|
110
|
+
repo: str | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Verdict(_Strict):
|
|
114
|
+
pass_: bool = Field(alias="pass")
|
|
115
|
+
reason: str | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Finding(_Strict):
|
|
119
|
+
file: str | None = None
|
|
120
|
+
line: int | None = None
|
|
121
|
+
kind: str | None = None
|
|
122
|
+
severity: Literal["low", "medium", "high", "critical"]
|
|
123
|
+
message: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ReviewFindings(_Strict):
|
|
127
|
+
findings: list[Finding]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def create_builtin_caps() -> CapRegistry:
|
|
131
|
+
caps = CapRegistry()
|
|
132
|
+
caps.register("Any", Any)
|
|
133
|
+
caps.register("Text", str)
|
|
134
|
+
caps.register("Issue", Issue)
|
|
135
|
+
caps.register("Diff", Diff)
|
|
136
|
+
caps.register("PullRequest", PullRequest)
|
|
137
|
+
caps.register("Verdict", Verdict)
|
|
138
|
+
caps.register("ReviewFindings", ReviewFindings)
|
|
139
|
+
return caps
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Comments are human (or reviewer) input attached to an item; every one must get an answer.
|
|
2
|
+
|
|
3
|
+
Providers fetch comments from wherever they live (GitHub PR threads, a local file) and post
|
|
4
|
+
replies. The store remembers them across runs, so a comment is answered once.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol
|
|
11
|
+
|
|
12
|
+
from whiletrue.core.history import CommentRecord
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from whiletrue.core.registry import Registry
|
|
18
|
+
from whiletrue.core.store import StateStore
|
|
19
|
+
|
|
20
|
+
#: Prefix on every reply the pipeline posts, so people always know who wrote what.
|
|
21
|
+
REPLY_PREFIX = "[whiletrue]"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class IncomingComment:
|
|
26
|
+
external_id: str
|
|
27
|
+
author: str
|
|
28
|
+
body: str
|
|
29
|
+
created_at: int
|
|
30
|
+
thread: str | None = None
|
|
31
|
+
url: str | None = None
|
|
32
|
+
kind: Literal["human", "reviewer"] = "human"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class CommentsContext:
|
|
37
|
+
cwd: str
|
|
38
|
+
now: Callable[[], int]
|
|
39
|
+
item_id: str
|
|
40
|
+
item: dict[str, Any]
|
|
41
|
+
#: What the step before the gate produced (for GitHub: the opened PR).
|
|
42
|
+
input: Any = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CommentsProvider(Protocol):
|
|
46
|
+
"""Where comments come from and where replies go. Providers never resolve threads."""
|
|
47
|
+
|
|
48
|
+
async def fetch(self, ctx: CommentsContext) -> list[IncomingComment]: ...
|
|
49
|
+
|
|
50
|
+
async def reply(self, comment: CommentRecord, body: str, ctx: CommentsContext) -> str | None:
|
|
51
|
+
"""Posts a reply in the comment's thread; returns the new comment's id if there is one."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def with_prefix(body: str) -> str:
|
|
56
|
+
return body if body.startswith(REPLY_PREFIX) else f"{REPLY_PREFIX} {body}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def sync_comments(
|
|
60
|
+
registry: Registry, store: StateStore, ctx: CommentsContext
|
|
61
|
+
) -> list[CommentRecord]:
|
|
62
|
+
"""Pulls new comments from every provider into the store; returns the ones just added."""
|
|
63
|
+
added: list[CommentRecord] = []
|
|
64
|
+
for name, provider in registry.providers("comments"):
|
|
65
|
+
incoming = await provider.fetch(ctx)
|
|
66
|
+
for comment in incoming:
|
|
67
|
+
record = store.add_comment(
|
|
68
|
+
item_id=ctx.item_id,
|
|
69
|
+
source=name,
|
|
70
|
+
external_id=comment.external_id,
|
|
71
|
+
author=comment.author,
|
|
72
|
+
body=comment.body,
|
|
73
|
+
created_at=comment.created_at,
|
|
74
|
+
thread=comment.thread,
|
|
75
|
+
url=comment.url,
|
|
76
|
+
kind=comment.kind,
|
|
77
|
+
)
|
|
78
|
+
if record is not None:
|
|
79
|
+
added.append(record)
|
|
80
|
+
return added
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Composite expansion: `composite: { uses: <file>, params: {...} }` becomes a `sequence` node
|
|
2
|
+
holding the composite's steps, with `{{params.x}}` filled in. Done at load time, so validation,
|
|
3
|
+
planning and hashing see the real tree."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
13
|
+
|
|
14
|
+
from whiletrue.core.config import (
|
|
15
|
+
CompositeDefinition,
|
|
16
|
+
ConfigError,
|
|
17
|
+
PipelineNode,
|
|
18
|
+
SourceLoc,
|
|
19
|
+
load_composite,
|
|
20
|
+
)
|
|
21
|
+
from whiletrue.core.template import render_template
|
|
22
|
+
|
|
23
|
+
MAX_DEPTH = 10
|
|
24
|
+
RECIPE_PREFIX = "recipe:"
|
|
25
|
+
RECIPES_DIR = Path(__file__).resolve().parent.parent / "recipes"
|
|
26
|
+
|
|
27
|
+
_PARAM = re.compile(r"\{\{\s*params\.([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _CompositeStep(BaseModel):
|
|
31
|
+
model_config = ConfigDict(extra="forbid")
|
|
32
|
+
|
|
33
|
+
uses: str = Field(min_length=1)
|
|
34
|
+
params: dict[str, Any] = Field(default_factory=lambda: {})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve_path(uses: str, base_dir: Path, loc: SourceLoc) -> Path:
|
|
38
|
+
if uses.startswith(RECIPE_PREFIX):
|
|
39
|
+
path = RECIPES_DIR / f"{uses[len(RECIPE_PREFIX) :]}.yaml"
|
|
40
|
+
if not path.is_file():
|
|
41
|
+
raise ConfigError(loc, f'Unknown recipe "{uses[len(RECIPE_PREFIX) :]}"')
|
|
42
|
+
return path
|
|
43
|
+
path = (base_dir / uses).resolve()
|
|
44
|
+
if not path.is_file():
|
|
45
|
+
raise ConfigError(loc, f"Composite file not found: {uses}")
|
|
46
|
+
return path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _bind_params(
|
|
50
|
+
definition: CompositeDefinition, given: dict[str, Any], loc: SourceLoc
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
unknown = sorted(set(given) - set(definition.params))
|
|
53
|
+
if unknown:
|
|
54
|
+
known = ", ".join(sorted(definition.params)) or "none"
|
|
55
|
+
raise ConfigError(
|
|
56
|
+
loc, f'Unknown param "{unknown[0]}" for composite "{definition.name}" (known: {known})'
|
|
57
|
+
)
|
|
58
|
+
bound: dict[str, Any] = {}
|
|
59
|
+
for name, spec in definition.params.items():
|
|
60
|
+
if name in given:
|
|
61
|
+
bound[name] = given[name]
|
|
62
|
+
elif spec.required:
|
|
63
|
+
raise ConfigError(loc, f'Composite "{definition.name}" needs param "{name}"')
|
|
64
|
+
else:
|
|
65
|
+
bound[name] = spec.default
|
|
66
|
+
return bound
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _substitute(value: Any, params: dict[str, Any], loc: SourceLoc) -> Any:
|
|
70
|
+
"""Fills `{{params.x}}`; a string that is exactly one token keeps the param's own type."""
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
whole = _PARAM.fullmatch(value.strip())
|
|
73
|
+
if whole:
|
|
74
|
+
return _param(whole.group(1), params, loc)
|
|
75
|
+
return _PARAM.sub(
|
|
76
|
+
lambda m: render_template("{{v}}", {"v": _param(m.group(1), params, loc)}), value
|
|
77
|
+
)
|
|
78
|
+
if isinstance(value, list):
|
|
79
|
+
return [_substitute(v, params, loc) for v in value] # pyright: ignore[reportUnknownVariableType]
|
|
80
|
+
if isinstance(value, dict):
|
|
81
|
+
return {k: _substitute(v, params, loc) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType]
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _param(name: str, params: dict[str, Any], loc: SourceLoc) -> Any:
|
|
86
|
+
if name not in params:
|
|
87
|
+
raise ConfigError(loc, f'Unknown param "{name}"')
|
|
88
|
+
return copy.deepcopy(params[name])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _prepare(node: PipelineNode, prefix: str, params: dict[str, Any]) -> None:
|
|
92
|
+
node.config = _substitute(node.config, params, node.loc)
|
|
93
|
+
node.id = f"{prefix}.{node.id}"
|
|
94
|
+
node.wires = {port: f"{prefix}.{target}" for port, target in node.wires.items()}
|
|
95
|
+
if node.use == "switch": # `switch` names sibling steps in its config; keep them in sync
|
|
96
|
+
cases = node.config.get("cases")
|
|
97
|
+
if isinstance(cases, dict):
|
|
98
|
+
node.config["cases"] = {k: f"{prefix}.{v}" for k, v in cases.items()} # pyright: ignore[reportUnknownVariableType]
|
|
99
|
+
if isinstance(node.config.get("default"), str):
|
|
100
|
+
node.config["default"] = f"{prefix}.{node.config['default']}"
|
|
101
|
+
for child in node.children:
|
|
102
|
+
_prepare(child, prefix, params)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def expand_composites(
|
|
106
|
+
node: PipelineNode, base_dir: Path, sources: list[str], stack: tuple[Path, ...] = ()
|
|
107
|
+
) -> PipelineNode:
|
|
108
|
+
"""Returns `node` with every `composite` step replaced by its expansion (recursively)."""
|
|
109
|
+
if node.use != "composite":
|
|
110
|
+
node.children = [expand_composites(c, base_dir, sources, stack) for c in node.children]
|
|
111
|
+
return node
|
|
112
|
+
try:
|
|
113
|
+
step = _CompositeStep.model_validate(node.config)
|
|
114
|
+
except ValidationError as error:
|
|
115
|
+
raise ConfigError(
|
|
116
|
+
node.loc, f"Invalid composite step: {error.errors()[0]['msg']}"
|
|
117
|
+
) from error
|
|
118
|
+
path = _resolve_path(step.uses, base_dir, node.loc)
|
|
119
|
+
if path in stack:
|
|
120
|
+
raise ConfigError(
|
|
121
|
+
node.loc, f"Composite cycle: {' -> '.join(p.name for p in (*stack, path))}"
|
|
122
|
+
)
|
|
123
|
+
if len(stack) >= MAX_DEPTH:
|
|
124
|
+
raise ConfigError(node.loc, f"Composites nested deeper than {MAX_DEPTH} levels")
|
|
125
|
+
definition = load_composite(path)
|
|
126
|
+
params = _bind_params(definition, step.params, node.loc)
|
|
127
|
+
inner = definition.root
|
|
128
|
+
_prepare(inner, node.id, params)
|
|
129
|
+
if str(path) not in sources and not str(path).startswith(str(RECIPES_DIR)):
|
|
130
|
+
sources.append(str(path))
|
|
131
|
+
expanded = expand_composites(inner, path.parent, sources, (*stack, path))
|
|
132
|
+
return PipelineNode(
|
|
133
|
+
id=node.id,
|
|
134
|
+
use="sequence",
|
|
135
|
+
config={},
|
|
136
|
+
children=[expanded],
|
|
137
|
+
wires=node.wires,
|
|
138
|
+
loc=node.loc,
|
|
139
|
+
ports=definition.ports or None,
|
|
140
|
+
)
|