diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfd8b49..c2203d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,4 +14,5 @@ jobs: with: cache: true - run: pixi run lint + - run: pixi run typecheck - run: pixi run test diff --git a/README.md b/README.md index 5781c9e..4fe757c 100644 --- a/README.md +++ b/README.md @@ -75,4 +75,4 @@ Agents are untrusted code driven by text from issues, so they never run on the h | `docs/` | setup guides and the [mia-data workflow overview](docs/mia-data-workflow.md) | | `notes/` | design notes, runbook, specs | | `scripts/` | `autodev-up.sh`, `mia-data-up.sh` | -| `tests/` | unit tests (`pixi run test`); no GitHub, LSF or Temporal server needed. `pixi run lint` runs ruff | +| `tests/` | unit tests (`pixi run test`); no GitHub, LSF or Temporal server needed. `pixi run lint` runs ruff, `pixi run typecheck` runs mypy | diff --git a/autodev/activities.py b/autodev/activities.py index ae71d3c..d839d58 100644 --- a/autodev/activities.py +++ b/autodev/activities.py @@ -13,7 +13,7 @@ import re from collections.abc import Awaitable, Callable from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from temporalio import activity from temporalio.common import WorkflowIDConflictPolicy @@ -90,7 +90,7 @@ async def wrapper(*args, **kwargs): raise ApplicationError(str(error), non_retryable=True) from error raise - return wrapper + return cast(Fn, wrapper) # --- comment templates ------------------------------------------------------------------------ @@ -150,6 +150,13 @@ def __init__( ): self.cfg, self.gh, self.mirror, self.jobs, self.bot, self.client = cfg, gh, mirror, jobs, bot, client + @property + def temporal(self) -> Client: + """The Temporal client; only the worker's activities (poll_issues) have one.""" + if self.client is None: + raise RuntimeError("no Temporal client: this activity runs only inside the worker") + return self.client + def all(self) -> list: """The activities registered on the main task queue (await_agent runs on the -await queue).""" return [ @@ -222,7 +229,7 @@ async def prepare_round(self, request: RoundInput) -> Workspace: base_sha = await asyncio.to_thread(self.mirror.workspace, round_dir, start_branch, branch, self.cfg.git_url) issue = await self.gh.get_issue(request.issue) comments = await self.gh.issue_comments(request.issue) - logins = {(comment.get("user") or {}).get("login") for comment in comments} - {None} + logins = {login for comment in comments if (login := (comment.get("user") or {}).get("login"))} writers = {login for login in logins if await self.gh.permission(login) in WRITE_PERMISSIONS} comments = [comment for comment in comments if (comment.get("user") or {}).get("login") in writers] (round_dir / "prompt.md").write_text( @@ -298,7 +305,7 @@ async def observe_pr(self, request: ObserveInput) -> PrSnapshot: reviews = await self.gh.pr_reviews(request.pr) inline_comments = await self.gh.pr_review_comments(request.pr) comments = await self.gh.issue_comments(request.pr) - logins = {(item.get("user") or {}).get("login") for item in [*reviews, *comments]} - {None} + logins = {login for item in [*reviews, *comments] if (login := (item.get("user") or {}).get("login"))} # ponytail: one permission lookup per participant per poll; cache if the rate limit ever matters. permissions = {login: await self.gh.permission(login) for login in logins} marker_prefix = self.cfg.marker_prefix @@ -372,7 +379,7 @@ async def poll_issues(self) -> list[int]: marker, ) continue - await self.client.start_workflow( + await self.temporal.start_workflow( IssueWorkflow.execute, IssueInput(number, self.cfg.agent.max_revisions, self.cfg.temporal.poll_minutes), id=self.cfg.workflow_id(number), diff --git a/autodev/auth.py b/autodev/auth.py index 8275e6a..5f6c87a 100644 --- a/autodev/auth.py +++ b/autodev/auth.py @@ -19,10 +19,10 @@ import httpx from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric import padding, rsa if TYPE_CHECKING: - from .config import Config + from .config import Deployment, GitHubAuth _HEADERS = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} @@ -38,6 +38,8 @@ def app_jwt(app_id: int, pem: bytes, now: float | None = None) -> str: header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode()) payload = _b64url(json.dumps({"iat": now - 60, "exp": now + 540, "iss": str(app_id)}).encode()) key = serialization.load_pem_private_key(pem, password=None) + if not isinstance(key, rsa.RSAPrivateKey): + raise ValueError("the GitHub App private key must be an RSA key") signature = key.sign(f"{header}.{payload}".encode(), padding.PKCS1v15(), hashes.SHA256()) return f"{header}.{payload}.{_b64url(signature)}" @@ -83,11 +85,18 @@ def __call__(self) -> str: return self._token -def token_fn(cfg: Config) -> Callable[[], str]: +def _app_credentials(github: GitHubAuth) -> tuple[int, int, Path]: + """(app id, installation id, private key path); config validation guarantees them when auth is "app".""" + if github.app_id is None or github.installation_id is None or github.private_key_path is None: + raise ValueError("github.auth=app needs github.app_id, installation_id and private_key_path") + return github.app_id, github.installation_id, github.private_key_path + + +def token_fn(cfg: Deployment) -> Callable[[], str]: """A zero-argument callable that returns the current GitHub token for this config's auth mode.""" github = cfg.github if github.auth == "app": - return AppTokens(github.app_id, github.installation_id, github.private_key_path, github.api_url) + return AppTokens(*_app_credentials(github), github.api_url) def from_env_or_file() -> str: if token := os.environ.get(github.token_env): @@ -99,7 +108,7 @@ def from_env_or_file() -> str: return from_env_or_file -async def bot_login(cfg: Config) -> str: +async def bot_login(cfg: Deployment) -> str: """The login autodev's own comments, commits and PRs appear under ("[bot]" for an app).""" github = cfg.github async with httpx.AsyncClient(base_url=github.api_url, timeout=30, headers=_HEADERS) as http: @@ -107,13 +116,14 @@ async def bot_login(cfg: Config) -> str: response = await http.get("/user", headers={"Authorization": f"Bearer {token_fn(cfg)()}"}) response.raise_for_status() return response.json()["login"] - jwt = app_jwt(github.app_id, github.private_key_path.read_bytes()) + app_id, _, key_path = _app_credentials(github) + jwt = app_jwt(app_id, key_path.read_bytes()) response = await http.get("/app", headers={"Authorization": f"Bearer {jwt}"}) response.raise_for_status() return f"{response.json()['slug']}[bot]" -async def noreply_email(cfg: Config, login: str) -> str: +async def noreply_email(cfg: Deployment, login: str) -> str: """The address that links a commit to `login` on GitHub (for an app, the id of its [bot] user, not the app id).""" async with httpx.AsyncClient(base_url=cfg.github.api_url, timeout=30, headers=_HEADERS) as http: response = await http.get(f"/users/{quote(login)}") diff --git a/autodev/cli.py b/autodev/cli.py index c71d4da..83e3425 100644 --- a/autodev/cli.py +++ b/autodev/cli.py @@ -11,7 +11,7 @@ import os import signal import sys -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from datetime import timedelta from pathlib import Path from typing import TYPE_CHECKING, Any @@ -23,7 +23,7 @@ if TYPE_CHECKING: from temporalio.client import Client - from .config import Config + from .config import Config, Deployment OPS = { "up": ops.cmd_up, @@ -37,7 +37,7 @@ log = logging.getLogger("autodev") -def _setup_logging(cfg: Config, name: str | None) -> None: +def _setup_logging(cfg: Deployment, name: str | None) -> None: """Log to stderr, and also to state_dir/logs/.log when `name` is given (the worker).""" handlers: list[logging.Handler] = [logging.StreamHandler()] if name: @@ -51,13 +51,13 @@ def _setup_logging(cfg: Config, name: str | None) -> None: logging.getLogger("httpx").setLevel(logging.WARNING) -def proxy_target(environ: dict) -> str | None: +def proxy_target(environ: Mapping[str, str]) -> str | None: """The host:port of $https_proxy, if set.""" url = environ.get("https_proxy") or environ.get("HTTPS_PROXY") return urlsplit(url).netloc if url else None -async def _client(cfg: Config) -> Client: +async def _client(cfg: Deployment) -> Client: """Connect to Temporal, through the HTTPS proxy when the config says the sandbox requires it.""" from temporalio.client import Client from temporalio.service import HttpConnectProxyConfig @@ -188,6 +188,7 @@ async def cmd_worker(cfg: Config, args: argparse.Namespace) -> int: client, task_queue=f"{task_queue}-await", activities=[activities.await_agent], max_concurrent_activities=20 ) task = asyncio.current_task() + assert task is not None # we are inside the worker coroutine asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, task.cancel) log.info( "worker up as %s: %s ns=%s queues %s, %s-await", diff --git a/autodev/config.py b/autodev/config.py index 675dbc7..420bb49 100644 --- a/autodev/config.py +++ b/autodev/config.py @@ -11,7 +11,7 @@ import re import sys from pathlib import Path -from typing import Literal +from typing import Literal, Protocol import yaml from pydantic import BaseModel, ValidationError, model_validator @@ -82,6 +82,45 @@ class AgentCfg(BaseModel): api_key_env: str | None = None # bill the API with this env var's key instead of the broker account's login +class GitHubAuth(Protocol): + """The GitHub auth fields both deployments' configs have (see auth.py).""" + + @property + def auth(self) -> str: ... + @property + def token_env(self) -> str: ... + @property + def token_file(self) -> Path | None: ... + @property + def app_id(self) -> int | None: ... + @property + def installation_id(self) -> int | None: ... + @property + def private_key_path(self) -> Path | None: ... + @property + def api_url(self) -> str: ... + + +class StatePaths(Protocol): + @property + def state_dir(self) -> Path: ... + + +class Deployment(Protocol): + """What autodev's shared helpers (auth, the Temporal connection, logging, ops) need from a config. + + Both autodev's and mia-data's Config satisfy it, so mia-data reuses those helpers with its own config.""" + + @property + def deployment(self) -> str: ... + @property + def github(self) -> GitHubAuth: ... + @property + def temporal(self) -> TemporalCfg: ... + @property + def paths(self) -> StatePaths: ... + + class Config(BaseModel): """The whole autodev.yaml, validated, plus the derived names (labels, branches, markers) built from it.""" diff --git a/autodev/github.py b/autodev/github.py index 9b36658..8168bf5 100644 --- a/autodev/github.py +++ b/autodev/github.py @@ -12,8 +12,8 @@ import collections import logging import time -from collections.abc import AsyncIterator, Awaitable, Callable -from typing import TYPE_CHECKING, Any +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +from typing import TYPE_CHECKING, Any, Literal, overload import httpx @@ -108,6 +108,36 @@ def _rate_limit_delay(response: httpx.Response, attempt: int) -> float | None: return min(60.0 * 2**attempt, 900.0) # GitHub: wait ≥1 min, then back off exponentially return None + @overload + async def request( + self, + method: str, + path: str, + *, + json: Any = None, + params: dict | None = None, + ok404: Literal[False] = False, + write: bool | None = None, + attempts: int = 8, + headers: dict | None = None, + conditional: bool = False, + ) -> httpx.Response: ... + + @overload + async def request( + self, + method: str, + path: str, + *, + json: Any = None, + params: dict | None = None, + ok404: Literal[True], + write: bool | None = None, + attempts: int = 8, + headers: dict | None = None, + conditional: bool = False, + ) -> httpx.Response | None: ... + async def request( self, method: str, @@ -224,7 +254,7 @@ async def permission(self, login: str) -> str: async def label_event(self, issue: int, label: str) -> tuple[str | None, str | None, str | None]: """(actor login, event id, created_at) of the most recent `labeled` event for `label`.""" - found = (None, None, None) + found: tuple[str | None, str | None, str | None] = (None, None, None) async for event in self._paged(f"/repos/{self.repo}/issues/{issue}/events"): if event.get("event") == "labeled" and (event.get("label") or {}).get("name") == label: found = ( @@ -296,7 +326,7 @@ async def pr_review_comments(self, number: int) -> list[dict]: comment async for comment in self._paged(f"/repos/{self.repo}/pulls/{number}/comments", conditional=True) ] - async def check_state(self, sha: str, required: list[str] = ()) -> str: + async def check_state(self, sha: str, required: Sequence[str] = ()) -> str: """Overall CI state of a commit (success | pending | failure | none), combining check runs and statuses.""" runs = ( await self.request( diff --git a/autodev/jobs.py b/autodev/jobs.py index 49b86e3..e7fcc38 100644 --- a/autodev/jobs.py +++ b/autodev/jobs.py @@ -14,7 +14,7 @@ from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal, overload from .models import JobOutcome @@ -41,6 +41,12 @@ class JobSpec: env: dict[str, str] = field(default_factory=dict) +@overload +def read_untrusted( + job_dir: Path, rel: str, limit: int, tail: bool = ..., binary: Literal[False] = ... +) -> str | None: ... +@overload +def read_untrusted(job_dir: Path, rel: str, limit: int, tail: bool = ..., *, binary: Literal[True]) -> bytes | None: ... def read_untrusted(job_dir: Path, rel: str, limit: int, tail: bool = False, binary: bool = False) -> str | bytes | None: """Read a file a sandboxed job could have tampered with. Returns None for anything suspicious. diff --git a/autodev/ops.py b/autodev/ops.py index 75e90f5..66c78d8 100644 --- a/autodev/ops.py +++ b/autodev/ops.py @@ -17,12 +17,12 @@ from .config import PROJECT_DIR if TYPE_CHECKING: - from .config import Config + from .config import Deployment UP_SCRIPT = PROJECT_DIR / "scripts" / "autodev-up.sh" -def log_files(cfg: Config) -> dict[str, Path]: +def log_files(cfg: Deployment) -> dict[str, Path]: """Log name -> path. Worker logs are in the state dir; temporal/broker logs are host-only, beside it.""" state = cfg.paths.state_dir host = state.with_name(state.name + "-host") / "logs" @@ -36,17 +36,17 @@ def _tmux(*args: str, **kwargs) -> subprocess.CompletedProcess: sys.exit("tmux not found on PATH") -def is_running(cfg: Config) -> bool: +def is_running(cfg: Deployment) -> bool: return _tmux("has-session", "-t", f"={cfg.deployment}", capture_output=True).returncode == 0 -def cmd_up(cfg: Config, args: argparse.Namespace) -> int: +def cmd_up(cfg: Deployment, args: argparse.Namespace) -> int: script = getattr(args, "up_script", UP_SCRIPT) config_env = getattr(args, "config_env", "AUTODEV_CONFIG") return subprocess.run([str(script)], env={**os.environ, config_env: str(args.config_path)}).returncode -def cmd_down(cfg: Config, args: argparse.Namespace) -> int: +def cmd_down(cfg: Deployment, args: argparse.Namespace) -> int: if not is_running(cfg): print(f"{cfg.deployment}: not running") return 0 @@ -56,11 +56,11 @@ def cmd_down(cfg: Config, args: argparse.Namespace) -> int: return returncode -def cmd_restart(cfg: Config, args: argparse.Namespace) -> int: +def cmd_restart(cfg: Deployment, args: argparse.Namespace) -> int: return cmd_down(cfg, args) or cmd_up(cfg, args) -def cmd_ps(cfg: Config, args: argparse.Namespace) -> int: +def cmd_ps(cfg: Deployment, args: argparse.Namespace) -> int: """Print whether the session is up, its windows, and where the logs are.""" if not is_running(cfg): print(f"{cfg.deployment}: not running") @@ -81,13 +81,13 @@ def cmd_ps(cfg: Config, args: argparse.Namespace) -> int: return 0 -def cmd_attach(cfg: Config, args: argparse.Namespace) -> int: +def cmd_attach(cfg: Deployment, args: argparse.Namespace) -> int: if not is_running(cfg): sys.exit(f"{cfg.deployment} is not running (run `up` first)") return _tmux("attach", "-t", f"={cfg.deployment}").returncode -def cmd_logs(cfg: Config, args: argparse.Namespace) -> int: +def cmd_logs(cfg: Deployment, args: argparse.Namespace) -> int: path = log_files(cfg)[args.name] if not path.is_file(): sys.exit(f"no log at {path}") diff --git a/autodev/review.py b/autodev/review.py index 099f5a8..c26cd1e 100644 --- a/autodev/review.py +++ b/autodev/review.py @@ -9,6 +9,7 @@ from __future__ import annotations import re +from collections.abc import Sequence from .models import Feedback @@ -26,7 +27,9 @@ def _login(item: dict) -> str | None: def _is_human_writer(login: str | None, permissions: dict[str, str], bot: str, allow_self: bool) -> bool: """A person with write access, and not the bot itself unless `allow_self` (under a personal token the reviewer and the bot are the same account).""" - return bool(login) and permissions.get(login) in WRITE_PERMISSIONS and (allow_self or login != bot) + if not login: + return False + return permissions.get(login) in WRITE_PERMISSIONS and (allow_self or login != bot) def _is_ours(body: str | None, marker_prefix: str) -> bool: @@ -61,8 +64,8 @@ def approved( a later head the agent happens to produce. GitHub's ISO-8601 UTC timestamps compare correctly as strings.""" latest: dict[str, dict] = {} for review in reviews: # GitHub returns reviews oldest first; a reviewer's latest verdict counts - if review.get("state") in ("APPROVED", "CHANGES_REQUESTED", "DISMISSED") and _login(review): - latest[_login(review)] = review + if review.get("state") in ("APPROVED", "CHANGES_REQUESTED", "DISMISSED") and (login := _login(review)): + latest[login] = review if any( review["state"] == "APPROVED" and review.get("commit_id") == head_sha @@ -134,7 +137,7 @@ def handled_ids(comments: list[dict], marker_prefix: str, bot: str) -> set[str]: return handled -def combine_checks(check_runs: list[dict], statuses: list[dict], required: list[str] = ()) -> str: +def combine_checks(check_runs: list[dict], statuses: list[dict], required: Sequence[str] = ()) -> str: """Fold check runs and commit statuses into one state: failure beats pending beats success. "none" means nothing reported and nothing is required; a missing required check counts as pending.""" @@ -142,7 +145,9 @@ def combine_checks(check_runs: list[dict], statuses: list[dict], required: list[ "pending" if run.get("status") != "completed" else "failure" if run.get("conclusion") in _FAILED else "success" for run in check_runs ] - states += [{"success": "success", "pending": "pending"}.get(status.get("state"), "failure") for status in statuses] + states += [ + {"success": "success", "pending": "pending"}.get(status.get("state") or "", "failure") for status in statuses + ] seen = {run.get("name") for run in check_runs} | {status.get("context") for status in statuses} if not states and not required: return "none" diff --git a/autodev/workflows.py b/autodev/workflows.py index b9a2b9f..b8e34a2 100644 --- a/autodev/workflows.py +++ b/autodev/workflows.py @@ -174,6 +174,7 @@ async def _watch_pr(self) -> str: Rolls the history over with continue_as_new when Temporal suggests it or the poll cap is hit.""" state = self.state + assert state.pr is not None # a round opened the PR before _watch_pr runs for _ in range(MAX_POLLS_PER_EXECUTION): self.phase = "REVIEW" snapshot = await self._act("observe_pr", ObserveInput(state.pr), result_type=PrSnapshot) @@ -195,6 +196,7 @@ async def _watch_pr(self) -> str: state.pr, f"Approved, but merging failed: {merge.message}", f"merge-failed {snapshot.head_sha}" ) elif action == REVISE: + assert isinstance(detail, Feedback) # decide() returns the Feedback to act on for REVISE state.handled.append(detail.id) state.revisions += 1 await self._act("set_stage", StageInput(state.issue, "working")) diff --git a/mia_data/activities.py b/mia_data/activities.py index d205544..0a17da3 100644 --- a/mia_data/activities.py +++ b/mia_data/activities.py @@ -20,7 +20,7 @@ from collections.abc import Awaitable, Callable from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import yaml from temporalio import activity @@ -114,7 +114,7 @@ async def wrapper(*args, **kwargs): f"{type(error).__name__}: {error}", type=type(error).__name__, non_retryable=True ) from error - return wrapper + return cast(Fn, wrapper) def write_atomic(path: Path, data: str | bytes) -> None: @@ -216,6 +216,13 @@ def __init__( # ponytail: in memory, so a worker restart re-sends each PR once (the workflow dedupes by pr_state) self._sent: dict[int, tuple] = {} # poll: PR number -> the (head, state, merge, needs_human) last signalled + @property + def temporal(self) -> Client: + """The Temporal client; only the worker's activities (poll) have one.""" + if self.client is None: + raise RuntimeError("no Temporal client: this activity runs only inside the worker") + return self.client + def all(self) -> list: """The main queue's activities; await_job runs on the -await queue.""" return [ @@ -433,8 +440,8 @@ async def prepare_match(self, issue: int, round_id: str, pins: Pins, attempt: in shutil.copyfile(round_dir / "downloads.json", workspace / "downloads.json") dataset_dir = json.loads((round_dir / "downloads.json").read_text()).get("dataset_dir") for name in ("conversion.json", "manifest.json"): - if isinstance(dataset_dir, str) and (text := read_untrusted(Path(dataset_dir), name, 16 << 20)) is not None: - (workspace / name).write_text(text) + if isinstance(dataset_dir, str) and (data := read_untrusted(Path(dataset_dir), name, 16 << 20)) is not None: + (workspace / name).write_text(data) (workspace / "SPEC.md").write_text(read_untrusted(snapshot_dir, "SPEC.md", 1 << 20) or "") (workspace / "prompt.md").write_text(match_prompt(pins)) return str(workspace) @@ -463,11 +470,13 @@ def _spec(self, kind: str, args: JobArgs, snapshot_dir: Path | None) -> JobSpec: workspace, pins, cfg = Path(args.workspace), args.pins, self.cfg if kind == "match": return match_job(cfg, workspace, pins, "record.yaml") + if kind in ("smoke", "crop") and snapshot_dir is None: + raise ValueError(f"{kind} job needs a snippets snapshot") existing = self._dataset_dir(args.round_id, pins) / "manifest.json" if args.existing else None existing = existing if existing and existing.is_file() else None - if kind == "smoke": + if kind == "smoke" and snapshot_dir: return smoke_job(cfg, workspace, pins, "binding.yaml", snapshot_dir, "record.yaml") - if kind == "crop": + if kind == "crop" and snapshot_dir: return crop_job( cfg, workspace, @@ -483,11 +492,12 @@ def _spec(self, kind: str, args: JobArgs, snapshot_dir: Path | None) -> JobSpec: args.trusted, ) if kind == "verify": - if Path(args.crop_workspace).parent != workspace.parent: + if args.crop_workspace is None: + raise ValueError("verify job needs crop_workspace") + crop_workspace = Path(args.crop_workspace) + if crop_workspace.parent != workspace.parent: raise ValueError(f"crop workspace {args.crop_workspace} is not of this round") - return verify_job( - cfg, workspace, pins, self._crops_dir(Path(args.crop_workspace)), "binding.yaml", existing - ) + return verify_job(cfg, workspace, pins, self._crops_dir(crop_workspace), "binding.yaml", existing) raise ValueError(f"unknown job kind {kind!r}") @activity.defn(name="submit") @@ -562,13 +572,11 @@ async def read_match(self, workspace: str) -> MatchResult: reasons=_texts(binding.get("reasons")), ) - async def _check_binding( - self, text: str | None, round_id: str, pins: Pins, sha: str - ) -> tuple[dict | None, list[str]]: + async def _check_binding(self, text: str | None, round_id: str, pins: Pins, sha: str) -> tuple[dict, list[str]]: """Parse an agent-written binding and check it against the round's record and the snippets at `sha`. - Returns (binding, errors); the binding is None when there are errors.""" + Returns (binding, errors); the binding is empty when there are errors.""" if text is None: - return None, ["the binding is missing or unreadable"] + return {}, ["the binding is missing or unreadable"] try: binding = yaml.safe_load(text) errors = check_binding( @@ -580,7 +588,7 @@ async def _check_binding( ) except (yaml.YAMLError, ValueError, RecursionError) as error: # a malformed URL raises in urlsplit errors = [f"{type(error).__name__}: {error}"] - return binding if not errors else None, errors + return binding if not errors else {}, errors @staticmethod def _store_binding(path: Path, binding: dict) -> tuple[str, str]: @@ -617,9 +625,10 @@ async def read_verify(self, workspace: str, crop_workspace: str | None = None) - if not isinstance(report, dict): return VerifyResult(0, 0, ["verify.json is missing, unreadable or not a JSON object"], []) errors = _texts(report.get("errors")) - n_crops = report.get("n") if type(report.get("n")) is int and report["n"] >= 0 else 0 + n_asked, crops = report.get("n"), report.get("crops") + n_crops = n_asked if type(n_asked) is int and n_asked >= 0 else 0 verified, failed, seen = [], [], set() - for crop in (report.get("crops") if isinstance(report.get("crops"), list) else [])[:1000]: + for crop in (crops if isinstance(crops, list) else [])[:1000]: if not isinstance(crop, dict): continue crop_id, files = crop.get("crop_id"), crop.get("files") @@ -871,7 +880,7 @@ async def poll(self) -> list[str]: ) else: # start_signal: starts the workflow if needed and delivers the label as a signal either way - await self.client.start_workflow( + await self.temporal.start_workflow( "MiaDataDataset", DatasetState(number), id=cfg.workflow_id(number), @@ -924,7 +933,7 @@ async def poll(self) -> list[str]: if self._sent.get(update.pr) == fingerprint: continue # signalled already: a wake-up per change, not per poll try: - await self.client.get_workflow_handle(cfg.workflow_id(int(issue_marker.group(1)))).signal( + await self.temporal.get_workflow_handle(cfg.workflow_id(int(issue_marker.group(1)))).signal( "pr_update", update ) self._sent[update.pr] = fingerprint diff --git a/mia_data/cli.py b/mia_data/cli.py index 07233c8..b578bd6 100644 --- a/mia_data/cli.py +++ b/mia_data/cli.py @@ -259,9 +259,10 @@ async def run_test_pr(activities: Activities, pr: int, binding_file: Path, recor return 1 # the head's status stays as it is await activities.set_crop_status(head, "pending", None, "crop test running") try: - record_commit = ((await catalog.downloaded(gh_catalog)).get(record_id) or {}).get("record_commit") + entry = (await catalog.downloaded(gh_catalog)).get(record_id) or {} + record_commit = str(entry.get("record_commit") or "") note = "" - if not SHA_RE.fullmatch(str(record_commit)): + if not SHA_RE.fullmatch(record_commit): record_commit = await gh_catalog.get_ref_sha("main") note = ( f" `{record_id}` is not in the downloads state: its record was read at the catalog's main, " @@ -319,12 +320,14 @@ async def run_test_pr(activities: Activities, pr: int, binding_file: Path, recor return 0 if passed else 1 -def _github_clients(cfg: Config) -> list[GitHub]: +def _github_clients(cfg: Config) -> tuple[GitHub, GitHub, GitHub]: """Clients for the data, snippets and catalog repos, in the order Activities takes them.""" tokens, github_cfg = token_fn(cfg), cfg.github - return [ - _gh(cfg, repo, tokens) for repo in (github_cfg.data_repo, github_cfg.snippets_repo, github_cfg.catalog_repo) - ] + return ( + _gh(cfg, github_cfg.data_repo, tokens), + _gh(cfg, github_cfg.snippets_repo, tokens), + _gh(cfg, github_cfg.catalog_repo, tokens), + ) async def cmd_test_pr(cfg: Config, args: argparse.Namespace) -> int: @@ -335,7 +338,7 @@ async def cmd_test_pr(cfg: Config, args: argparse.Namespace) -> int: clients = _github_clients(cfg) try: activities = Activities( - cfg, *clients, CsubJobs(cfg.deployment, cfg.paths.state_dir / "jobs"), await bot_login(cfg) + cfg, *clients, jobs=CsubJobs(cfg.deployment, cfg.paths.state_dir / "jobs"), bot=await bot_login(cfg) ) return await run_test_pr(activities, args.pr, Path(args.binding), args.record) finally: @@ -356,7 +359,9 @@ async def cmd_worker(cfg: Config, args: argparse.Namespace) -> int: client = await _client(cfg) clients = _github_clients(cfg) bot = await bot_login(cfg) - activities = Activities(cfg, *clients, CsubJobs(cfg.deployment, cfg.paths.state_dir / "jobs"), bot, client) + activities = Activities( + cfg, *clients, jobs=CsubJobs(cfg.deployment, cfg.paths.state_dir / "jobs"), bot=bot, client=client + ) main_worker = Worker( client, task_queue=cfg.task_queue, @@ -368,6 +373,7 @@ async def cmd_worker(cfg: Config, args: argparse.Namespace) -> int: client, task_queue=cfg.await_queue, activities=[activities.await_job], max_concurrent_activities=20 ) task = asyncio.current_task() + assert task is not None # we are inside the worker coroutine asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, task.cancel) log.info( "worker up as %s: %s ns=%s queues %s, %s", diff --git a/mia_data/publish.py b/mia_data/publish.py index 372d696..92cfbc1 100644 --- a/mia_data/publish.py +++ b/mia_data/publish.py @@ -127,7 +127,7 @@ def reindex(dataset_dir: Path, withdrawn: dict[str, str]) -> int: "crop_id": crop_dir.name, "files": _hashes(crop_dir), "withdrawn": withdrawn_snippet is not None, - "withdrawn_reason": withdrawn.get(withdrawn_snippet), + "withdrawn_reason": withdrawn.get(withdrawn_snippet) if withdrawn_snippet is not None else None, } ) tmp = dataset_dir / f".tmp-manifest-{uuid.uuid4().hex}.json" # per writer: a zombie attempt can't interleave diff --git a/mia_data/workflows.py b/mia_data/workflows.py index f1e3ec8..0027852 100644 --- a/mia_data/workflows.py +++ b/mia_data/workflows.py @@ -59,9 +59,11 @@ def _rollover_due() -> bool: def _parse_time(iso: str | None) -> datetime | None: """Parse an ISO-8601 timestamp as UTC; None if missing or malformed.""" + if iso is None: + return None try: parsed = datetime.fromisoformat(iso) - except (TypeError, ValueError): + except ValueError: return None return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) @@ -136,6 +138,12 @@ async def _act( result_type=result_type, ) + async def _limits(self) -> RoundLimits: + """The round's config values. They are not part of the durable state, so a rollover re-reads them.""" + if self.limits is None: + self.limits = await self._act("limits", result_type=RoundLimits) + return self.limits + async def _guard(self, step: Awaitable[None]) -> None: """Run a step of the round; any failure ends the round in needs-human instead of failing the workflow.""" try: @@ -197,6 +205,7 @@ async def _job(self, kind: str, workspace: str, workspace_kind: str | None = Non The wait is its own activity on a separate queue so a worker restart reattaches to the running job. `workspace_kind` distinguishes several jobs of one kind in a round (crop-); it defaults to `kind`.""" state = self.state + assert state.round_id and state.pins # a round is admitted before any job runs job_id = await self._act( "submit", job_key(state.issue, state.round_id, workspace_kind or kind, 1), @@ -205,7 +214,7 @@ async def _job(self, kind: str, workspace: str, workspace_kind: str | None = Non seconds=600, result_type=str, ) - walltime = timedelta(minutes=self.limits.walltime_min) + walltime = timedelta(minutes=(await self._limits()).walltime_min) return await workflow.execute_activity( "await_job", args=[job_id, workspace], @@ -271,11 +280,13 @@ async def _test(self, sha: str, seed: str, trusted: bool = True, notes: tuple = Returns (the Test record, descriptions of failed jobs, the markdown report for the issue).""" state = self.state crop_workspace, verify_workspace, verify, crop_job, verify_job = await self._crop( - sha, sha, self.limits.test_n, seed, 0, trusted + sha, sha, (await self._limits()).test_n, seed, 0, trusted ) passed, failed_jobs = judge(verify, [crop_job, verify_job]) gallery = await self._gallery(sha, crop_workspace, verify_workspace, verify) if verify.crops else None - test = Test(sha, f"{state.round_id}-{sha[:12]}", passed, workflow.now().isoformat(), gallery and gallery.prefix) + test = Test( + sha, f"{state.round_id}-{sha[:12]}", passed, workflow.now().isoformat(), gallery.prefix if gallery else None + ) lines = [ f"Test crops at `{sha}`: **{'passed' if passed else 'failed'}**, {verify.ok_count} of " f"{verify.n} verified; the crop job fetched {verify.bytes_fetched} bytes.", @@ -357,10 +368,10 @@ async def _produce(self) -> None: """Make and publish the full set of crops. Passing crops stay published even when the round ends in needs-human.""" state = self.state + assert state.pins and state.seed # set at admission; carried in the durable state across rollovers sha = state.pins.snippets_commit await self._stage("cropping") - self.limits = self.limits or await self._act("limits", result_type=RoundLimits) # gone after a rollover - n_crops = self.limits.per_round + n_crops = (await self._limits()).per_round crop_workspace, verify_workspace, verify, crop_job, _ = await self._crop( "production", sha, n_crops, state.seed + "-p", 1 ) @@ -403,7 +414,7 @@ async def _produce(self) -> None: async def _review(self) -> bool: """Wait for an approve newer than the test result (True), or a new go (False). Stale approves are refused.""" - tested_at = _parse_time(self.state.tests[-1].finished_at) + tested_at = _parse_time(self.state.tests[-1].finished_at) or workflow.now() # unparseable: nothing older counts while True: await workflow.wait_condition(lambda: bool(self.approve_events or self.go_events) or _rollover_due()) if not (self.approve_events or self.go_events): @@ -439,7 +450,7 @@ async def _develop(self, match: MatchResult) -> None: async def _follow_pr(self) -> None: """Wake on pr_update until the round ends, refusing labels meanwhile (busy). Rolls over only here, idle.""" state = self.state - self.limits = self.limits or await self._act("limits", result_type=RoundLimits) # gone after a rollover + await self._limits() while True: await workflow.wait_condition( lambda: bool(state.pending or self.go_events or self.approve_events) or _rollover_due() @@ -469,7 +480,7 @@ async def _on_pr(self, pr_number: int) -> bool: return True if any(test.sha == pr.head_sha for test in state.tests): return False # tested: its status stands until a new head - if len(state.tests) >= self.limits.max_pr_revisions: + if len(state.tests) >= (await self._limits()).max_pr_revisions: await self._finish( "needs-human", f"{where}: revision limit, {len(state.tests)} heads tested and none merged" ) @@ -527,6 +538,9 @@ async def _merged(self, pr: PrUpdate) -> None: The merged head's passing test carries over if the snippet tree is unchanged by the merge; otherwise the merge commit gets its own (trusted, status-less) test first. An older head's pass never counts.""" state, merge_sha = self.state, pr.merge_sha + if not merge_sha: + raise RoundFailed(f"snippet PR #{pr.pr} is merged but GitHub reports no merge commit") + assert state.pins and state.seed # set at admission; carried in the durable state across rollovers tree = state.snippet_name and await self._act("tree_sha", merge_sha, state.snippet_name) binding = await self._act("adopt_pr_binding", state.round_id, pr.pr, merge_sha, seconds=600, result_type=dict) if binding["errors"]: diff --git a/pixi.lock b/pixi.lock index e647e2c..45317de 100644 --- a/pixi.lock +++ b/pixi.lock @@ -10,6 +10,7 @@ environments: packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ast-serialize-0.13.0-py311hf77984d_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.1.1-py312h703531f_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-50.0.2-py311hc91d8b8_0.conda @@ -26,10 +27,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.4-hcfc3c73_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/mypy-2.4.0-py312h1b36aeb_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.5-h781a0a9_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/orjson-3.12.0-py312he35bb96_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py312h1b36aeb_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pydantic-core-2.46.5-py312hc767a74_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.14-h5f976f7_3_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-librt-0.16.0-py312h1b36aeb_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py312h8a5da7c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-hd6e31c0_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.17.0-hf19af3b_0.conda @@ -53,7 +58,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.20-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.11-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.5-pyhcf101f3_0.conda @@ -65,6 +72,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/respx-0.23.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/types-pyyaml-6.0.12.20260906-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda @@ -93,6 +101,24 @@ packages: - _openmp_mutex >=4.5 size: 28948 timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ast-serialize-0.13.0-py311hf77984d_0.conda + noarch: python + sha256: 129ee69725b0809602d6567f111f2d56bd155f54c526c48123d93894b83657c2 + md5: e6c9cb22d4404ed44b25c9d9bbb1e203 + depends: + - python >=3.11 + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + - _python_abi3_support 1.* + - cpython >=3.11 + constrains: + - __glibc >=2.17 + license: MIT + purls: + - pkg:pypi/ast-serialize?source=compressed-mapping + run_exports: {} + size: 1154654 + timestamp: 1791848677322 - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda sha256: 1a0d382c515ebf55f8ee1f38c8b81bc95af5c2acc42ad53b66bc5df932032f96 md5: e675fabcf81499adc7edf58124fb1e01 @@ -326,6 +352,27 @@ packages: - libzlib >=1.3.2,<2.0a0 size: 63713 timestamp: 1785362952714 +- conda: https://conda.anaconda.org/conda-forge/linux-64/mypy-2.4.0-py312h1b36aeb_0.conda + sha256: 7b83aa836c42a6da61d5a9431ea5e21c13c67a491d573fd1952986e21d4c7852 + md5: cd9eb4cc02a3fbca824bcf6592de444c + depends: + - ast-serialize >=0.11.2,<1.0.0 + - mypy_extensions >=1.0.0 + - pathspec >=1.0.0 + - python + - python-librt >=0.16.0 + - typing_extensions >=4.14.0 + - psutil >=4.0 + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/mypy?source=compressed-mapping + run_exports: {} + size: 23685894 + timestamp: 1790965612973 - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda sha256: 5d46557214ed184381dafe835b7c94a474a1c3b307a08a250b1ea4779b44ffb3 md5: ee6c0cd80a60961a1f48aa3e0b91f986 @@ -354,6 +401,38 @@ packages: - openssl >=3.6.5,<4.0a0 size: 3195299 timestamp: 1791098052301 +- conda: https://conda.anaconda.org/conda-forge/linux-64/orjson-3.12.0-py312he35bb96_1.conda + sha256: b3504d474b838188ba06558e554a4f69394bc9c7fd48d8df9d2910ae2c33e507 + md5: f3ff8203420ad6ea863d21db096d8426 + depends: + - python + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.12.* *_cp312 + constrains: + - __glibc >=2.17 + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/orjson?source=compressed-mapping + run_exports: {} + size: 298734 + timestamp: 1790021200982 +- conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py312h1b36aeb_3.conda + sha256: 9b6f0247591f062a188cd42580916476c0d939a89d65f3ecefb37e84b5c17d7a + md5: 2f8faed6c111c9ed0c1d217bc1be75f2 + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/psutil?source=compressed-mapping + run_exports: {} + size: 225549 + timestamp: 1789682039049 - conda: https://conda.anaconda.org/conda-forge/linux-64/pydantic-core-2.46.5-py312hc767a74_2.conda sha256: 64ef841c88756f8d1ca9c241c0ab1585ad5a9cb8d0bfe0555c201c2ad0e3c701 md5: f7a033135df967ea983fe4833856d2b1 @@ -406,6 +485,21 @@ packages: - python size: 23044161 timestamp: 1788392496306 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-librt-0.16.0-py312h1b36aeb_0.conda + sha256: 12671324ba50c377e2631c91f060ca42c36f548993178520a477d348cc3aa80d + md5: 43a57694af193ac4e161342a63d817e7 + depends: + - python + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/librt?source=compressed-mapping + run_exports: {} + size: 169332 + timestamp: 1790684476183 - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py312h8a5da7c_1.conda sha256: cb142bfd92f6e55749365ddc244294fa7b64db6d08c45b018ff1c658907bfcbf md5: 15878599a87992e44c059731771591cb @@ -724,6 +818,18 @@ packages: run_exports: {} size: 13387 timestamp: 1760831448842 +- conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda + sha256: 6ed158e4e5dd8f6a10ad9e525631e35cee8557718f83de7a4e3966b1f772c4b1 + md5: e9c622e0d00fa24a6292279af3ab6d06 + depends: + - python >=3.9 + license: MIT + license_family: MIT + purls: + - pkg:pypi/mypy-extensions?source=hash-mapping + run_exports: {} + size: 11766 + timestamp: 1745776666688 - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda sha256: c432626b16768b8dab228bfb706f7060c2d462a21c516d240f68f2f902b5a044 md5: 936687ed80f295a1f5dbcf8bd34c252c @@ -737,6 +843,18 @@ packages: run_exports: {} size: 116363 timestamp: 1785888127370 +- conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda + sha256: 6eaee417d33f298db79bc7185ab1208604c0e6cf51dade34cd513c6f9db9c6f3 + md5: 11adc78451c998c0fd162584abfa3559 + depends: + - python >=3.10 + license: MPL-2.0 + license_family: MOZILLA + purls: + - pkg:pypi/pathspec?source=hash-mapping + run_exports: {} + size: 56559 + timestamp: 1777271601895 - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda sha256: e14aafa63efa0528ca99ba568eaf506eb55a0371d12e6250aaaa61718d2eb62e md5: d7585b6550ad04c8c5e21097ada2888e @@ -892,6 +1010,18 @@ packages: run_exports: {} size: 21561 timestamp: 1774492402955 +- conda: https://conda.anaconda.org/conda-forge/noarch/types-pyyaml-6.0.12.20260906-pyh5ded981_0.conda + sha256: f03e3fd7c700ae031261a1c63ff9936f94b182cfd31b425692432f924b2fc1b6 + md5: a7974f6a351b4a33cf188dd50d72dc63 + depends: + - python >=3.11 + - python + license: Apache-2.0 AND MIT + purls: + - pkg:pypi/types-pyyaml?source=hash-mapping + run_exports: {} + size: 27677 + timestamp: 1788690143833 - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda sha256: b141933ece3518f6d7b75dfb59451e2f26b405a44c18e2518a83e9a02e09315c md5: c680b5747e8c4c8f23dca0bb7042a8fc diff --git a/pyproject.toml b/pyproject.toml index c6126d1..99e00be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,9 @@ pytest = "*" pytest-asyncio = "*" respx = "*" ruff = "*" +mypy = "*" +types-PyYAML = "*" +orjson = "*" # mypy writes its cache with it; without the real package an empty site-packages dir shadows it [tool.pixi.environments] default = { features = ["test"], solve-group = "default" } @@ -58,6 +61,7 @@ default = { features = ["test"], solve-group = "default" } test = "pytest tests/" lint = "ruff check . && ruff format --check ." fmt = "ruff format . && ruff check --fix ." +typecheck = "mypy autodev mia_data" ad = "autodev" md = "mia-data" @@ -68,3 +72,13 @@ extend-exclude = ["notes"] [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B"] ignore = ["E501"] # long lines are reviewed by eye; the renames pushed a few past 120 + +[tool.mypy] +python_version = "3.12" +check_untyped_defs = true +warn_unused_ignores = true +warn_redundant_casts = true + +[[tool.mypy.overrides]] +module = ["csub", "csub.*"] +ignore_missing_imports = true