-
Notifications
You must be signed in to change notification settings - Fork 663
feat(config): add vision_agents.config with load_settings
#566
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,131 @@ | ||||||||||||||||||||||||||
| """Project-level configuration for Vision Agents. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Reads ``[tool.vision-agents.*]`` from the nearest ``pyproject.toml``. | ||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| import tomllib | ||||||||||||||||||||||||||
| from dataclasses import dataclass, field | ||||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||||
| from typing import cast | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| PYPROJECT_FILENAME = "pyproject.toml" | ||||||||||||||||||||||||||
| TOOL_TABLE = "tool.vision-agents" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @dataclass(frozen=True) | ||||||||||||||||||||||||||
| class AgentSettings: | ||||||||||||||||||||||||||
| """``[tool.vision-agents.agent]`` — how to invoke the project's ``Runner``.""" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| module: str # "agent" in "agent:runner" | ||||||||||||||||||||||||||
| attribute: str # "runner" in "agent:runner" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @property | ||||||||||||||||||||||||||
| def entrypoint(self) -> str: | ||||||||||||||||||||||||||
| """Round-trip ``module:attribute`` representation.""" | ||||||||||||||||||||||||||
| return f"{self.module}:{self.attribute}" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @dataclass(frozen=True) | ||||||||||||||||||||||||||
| class Settings: | ||||||||||||||||||||||||||
| """The resolved Vision Agents project configuration. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Attributes: | ||||||||||||||||||||||||||
| project_root: Directory containing the discovered ``pyproject.toml``. | ||||||||||||||||||||||||||
| pyproject_path: Path to the discovered ``pyproject.toml`` itself. | ||||||||||||||||||||||||||
| agent: Parsed ``[tool.vision-agents.agent]`` table or ``None``. | ||||||||||||||||||||||||||
| raw: Full ``[tool.vision-agents]`` dict — escape hatch for plugin | ||||||||||||||||||||||||||
| or user code reading custom subsections we don't model yet. | ||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| project_root: Path | ||||||||||||||||||||||||||
| pyproject_path: Path | ||||||||||||||||||||||||||
| agent: AgentSettings | None = None | ||||||||||||||||||||||||||
| raw: dict[str, object] = field(default_factory=dict) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def find_pyproject(start: Path) -> Path: | ||||||||||||||||||||||||||
| """Walk up from ``start`` looking for ``pyproject.toml``. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Raises: | ||||||||||||||||||||||||||
| FileNotFoundError: if no ``pyproject.toml`` is found anywhere on the | ||||||||||||||||||||||||||
| way up. Callers should reformat to a domain-appropriate error. | ||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||
| for directory in (start, *start.parents): | ||||||||||||||||||||||||||
| candidate = directory / PYPROJECT_FILENAME | ||||||||||||||||||||||||||
| if candidate.is_file(): | ||||||||||||||||||||||||||
| return candidate | ||||||||||||||||||||||||||
| raise FileNotFoundError(f"no {PYPROJECT_FILENAME} found starting from {start}") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def parse_entrypoint(spec: object) -> tuple[str, str]: | ||||||||||||||||||||||||||
| """Parse a gunicorn-style ``module:attribute`` spec. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Raises: | ||||||||||||||||||||||||||
| ValueError: if ``spec`` is not a non-empty ``module:attribute`` string. | ||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||
| if not isinstance(spec, str) or ":" not in spec: | ||||||||||||||||||||||||||
| raise ValueError( | ||||||||||||||||||||||||||
| f"entrypoint {spec!r} must be in 'module:attribute' form " | ||||||||||||||||||||||||||
| "(e.g. 'agent:runner')" | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| if spec.count(":") != 1: | ||||||||||||||||||||||||||
| raise ValueError( | ||||||||||||||||||||||||||
| f"entrypoint {spec!r} is malformed; expected exactly one ':' " | ||||||||||||||||||||||||||
| "separator in 'module:attribute'" | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| module, _, attribute = spec.partition(":") | ||||||||||||||||||||||||||
| if not module or not attribute: | ||||||||||||||||||||||||||
| raise ValueError( | ||||||||||||||||||||||||||
| f"entrypoint {spec!r} is malformed; expected 'module:attribute'" | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| if module.endswith(".py"): | ||||||||||||||||||||||||||
| raise ValueError( | ||||||||||||||||||||||||||
| f"entrypoint {spec!r} looks like a file path; entrypoint is a " | ||||||||||||||||||||||||||
| f"Python import name, not a filename — try {module[:-3]!r}:{attribute!r} " | ||||||||||||||||||||||||||
| f"(i.e. '{module[:-3]}:{attribute}')" | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| return module, attribute | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def load_settings(cwd: Path | None = None) -> Settings: | ||||||||||||||||||||||||||
| """Load Vision Agents settings from the nearest ``pyproject.toml``. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Walks up from ``cwd`` (default: :func:`Path.cwd`) to find the project's | ||||||||||||||||||||||||||
| ``pyproject.toml``, then parses ``[tool.vision-agents.*]`` sections into | ||||||||||||||||||||||||||
| a :class:`Settings` instance. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||
| cwd: Directory to start the upward search from. Defaults to the | ||||||||||||||||||||||||||
| current working directory. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Raises: | ||||||||||||||||||||||||||
| FileNotFoundError: when no ``pyproject.toml`` is found. | ||||||||||||||||||||||||||
| ValueError: when ``[tool.vision-agents.*]`` is malformed (wrong | ||||||||||||||||||||||||||
| type for a known field, malformed entrypoint, etc.). | ||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||
| start = cwd if cwd is not None else Path.cwd() | ||||||||||||||||||||||||||
| pyproject_path = find_pyproject(start) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| with pyproject_path.open("rb") as handle: | ||||||||||||||||||||||||||
| data = tomllib.load(handle) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| tool = data.get("tool") | ||||||||||||||||||||||||||
| raw = tool.get("vision-agents") if isinstance(tool, dict) else None | ||||||||||||||||||||||||||
| if not isinstance(raw, dict): | ||||||||||||||||||||||||||
| raw = {} | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
Comment on lines
+112
to
+116
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Raise Current logic silently downgrades invalid schema to Proposed fix- tool = data.get("tool")
- raw = tool.get("vision-agents") if isinstance(tool, dict) else None
- if not isinstance(raw, dict):
- raw = {}
+ tool = data.get("tool")
+ if tool is not None and not isinstance(tool, dict):
+ raise ValueError("[tool] must be a table")
+
+ raw_section = tool.get("vision-agents") if isinstance(tool, dict) else None
+ if raw_section is not None and not isinstance(raw_section, dict):
+ raise ValueError(f"[{TOOL_TABLE}] must be a table")
+ raw = raw_section if isinstance(raw_section, dict) else {}📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||
| return Settings( | ||||||||||||||||||||||||||
| project_root=pyproject_path.parent.resolve(), | ||||||||||||||||||||||||||
| pyproject_path=pyproject_path, | ||||||||||||||||||||||||||
| agent=_parse_agent(raw.get("agent")), | ||||||||||||||||||||||||||
| raw=cast(dict[str, object], raw), | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def _parse_agent(section: object) -> AgentSettings | None: | ||||||||||||||||||||||||||
| if section is None: | ||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||
| if not isinstance(section, dict): | ||||||||||||||||||||||||||
| raise ValueError(f"[{TOOL_TABLE}.agent] must be a table") | ||||||||||||||||||||||||||
| module, attribute = parse_entrypoint(section.get("entrypoint")) | ||||||||||||||||||||||||||
| return AgentSettings(module=module, attribute=attribute) | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trim entrypoint segments before emptiness checks.
"pkg :runner"and"pkg: runner"currently pass validation but produce downstream import errors. Normalize whitespace in parser so invalid forms fail early with config errors.Proposed fix