diff --git a/.taskcluster.yml b/.taskcluster.yml index 24e57c8434..c58a7f784c 100644 --- a/.taskcluster.yml +++ b/.taskcluster.yml @@ -149,6 +149,8 @@ tasks: uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 --extra bugzilla --extra firefox --extra claude-sdk pytest --cov=agent_tools --cov-append tests/ && cd ../hackbot-runtime && uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 --extra claude-sdk --extra phabricator pytest --cov=hackbot_runtime --cov-append tests/ && + cd ../lando-client && + uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=lando_client --cov-append tests/ && cd ../.. && bash <(curl -s https://codecov.io/bash)" metadata: diff --git a/libs/hackbot-runtime/hackbot_runtime/actions/__init__.py b/libs/hackbot-runtime/hackbot_runtime/actions/__init__.py index a3d6f0b697..5a12b10a03 100644 --- a/libs/hackbot-runtime/hackbot_runtime/actions/__init__.py +++ b/libs/hackbot-runtime/hackbot_runtime/actions/__init__.py @@ -7,7 +7,7 @@ claude-sdk adapter is ``hackbot_runtime.actions.claude_sdk.actions_server_for``. """ -from hackbot_runtime.actions import bugzilla, phabricator, testrail +from hackbot_runtime.actions import bugzilla, phabricator, testrail, try_server from hackbot_runtime.actions.recorder import ActionHook, ActionsRecorder ACTIONS_SERVER_NAME = "actions" @@ -19,4 +19,5 @@ "bugzilla", "phabricator", "testrail", + "try_server", ] diff --git a/libs/hackbot-runtime/hackbot_runtime/actions/claude_sdk.py b/libs/hackbot-runtime/hackbot_runtime/actions/claude_sdk.py index a617ac6147..af374149ab 100644 --- a/libs/hackbot-runtime/hackbot_runtime/actions/claude_sdk.py +++ b/libs/hackbot-runtime/hackbot_runtime/actions/claude_sdk.py @@ -16,6 +16,7 @@ from hackbot_runtime.actions import bugzilla as _bugzilla from hackbot_runtime.actions import phabricator as _phabricator from hackbot_runtime.actions import testrail as _testrail +from hackbot_runtime.actions import try_server as _try_server from hackbot_runtime.actions.recorder import ActionsRecorder @@ -34,7 +35,7 @@ def actions_server_for( """ if recorder is None: recorder = ActionsRecorder(artifacts_dir=fallback_artifacts_dir) - tools = _bugzilla.TOOLS + _phabricator.TOOLS + _testrail.TOOLS + tools = _bugzilla.TOOLS + _phabricator.TOOLS + _testrail.TOOLS + _try_server.TOOLS if types is not None: wanted = set(types) tools = [t for t in tools if t.dotted in wanted] diff --git a/libs/hackbot-runtime/hackbot_runtime/actions/handlers/__init__.py b/libs/hackbot-runtime/hackbot_runtime/actions/handlers/__init__.py index c30fb5cc0a..fb4dacffb7 100644 --- a/libs/hackbot-runtime/hackbot_runtime/actions/handlers/__init__.py +++ b/libs/hackbot-runtime/hackbot_runtime/actions/handlers/__init__.py @@ -1,11 +1,4 @@ -"""Apply-side handlers for recorded actions. - -``actions/bugzilla.py`` and ``actions/phabricator.py`` (sibling package) let an -agent *record* an intent into ``summary.json``; the handlers here turn a -recorded action back into a real API call once a run has finished. Kept in the -same library so the set of action types an agent can request and the set this -package knows how to apply never drift apart. -""" +"""Apply-side handlers for recorded actions.""" from hackbot_runtime.actions.handlers.base import ( ActionHandler, diff --git a/libs/hackbot-runtime/hackbot_runtime/actions/handlers/registry.py b/libs/hackbot-runtime/hackbot_runtime/actions/handlers/registry.py index f9cd20985f..00517daa2b 100644 --- a/libs/hackbot-runtime/hackbot_runtime/actions/handlers/registry.py +++ b/libs/hackbot-runtime/hackbot_runtime/actions/handlers/registry.py @@ -13,6 +13,7 @@ UpdatePatchHandler, ) from hackbot_runtime.actions.handlers.testrail_handler import SubmitTestPlanHandler +from hackbot_runtime.actions.handlers.try_server_handler import PushHandler # Maps a recorded action's dotted `type` to the handler that applies it. # Adding a new action type later is a one-line addition here — the dispatch @@ -26,6 +27,7 @@ "phabricator.update_patch": UpdatePatchHandler(), "phabricator.add_comment": PhabricatorAddCommentHandler(), "testrail.submit_test_plan": SubmitTestPlanHandler(), + "try_server.push": PushHandler(), } diff --git a/libs/hackbot-runtime/hackbot_runtime/actions/handlers/try_server_handler.py b/libs/hackbot-runtime/hackbot_runtime/actions/handlers/try_server_handler.py new file mode 100644 index 0000000000..9b82f66bb9 --- /dev/null +++ b/libs/hackbot-runtime/hackbot_runtime/actions/handlers/try_server_handler.py @@ -0,0 +1,170 @@ +"""Apply-side try-server action: push an already-built patch series to Lando.""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from email.utils import format_datetime +from functools import lru_cache +from typing import Any + +from lando_client import LandoClient, encode_patch + +from hackbot_runtime.actions.handlers.base import ActionResult, ApplyContext + +log = logging.getLogger(__name__) + +_TRY_PUSH_ARTIFACT_KEY = "changes/try_push.json" + +# Which Try repository the push lands on, and the Treeherder repo it shows up +# under. Firefox's plain "try"; a Thunderbird agent would need +# "try-comm-central" here (and a matching Lando permission). +_TRY_REPO_NAME = "try" + +_TRY_CONFIG_FILENAME = "try_task_config.json" + +_DEFAULT_TITLE = "Hackbot try push" +_COMMIT_BODY = "Pushed via hackbot." + +# Author of the generated try_task_config.json commit. Cosmetic (Lando attributes +# the push to the authenticated user), but it keeps the commit identifiable. +_AUTHOR = "Hackbot Agent " + +# `git format-patch`'s version-info trailer. Lando's parser needs it: it finds +# the end of the diff by scanning back for the "--" barrier and raises +# "Malformed patch" without one. Built by concatenation because the barrier +# carries a trailing space that an editor or linter would strip from a literal. +_PATCH_TRAILER = "-- " + "\n2.51.0\n" + +# One `git format-patch` email adding `try_task_config.json`. The diffstat block +# a real format-patch puts after "---" is omitted: Lando skips everything +# between the commit message and the first "diff " line, and `git apply` derives +# the stat from the diff itself. +_PATCH_TEMPLATE = """\ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: {author} +Date: {date} +Subject: [PATCH] {title} + +{body} +--- +diff --git a/{filename} b/{filename} +new file mode 100644 +--- /dev/null ++++ b/{filename} +@@ -0,0 +1,{line_count} @@ +{added_lines} +""" + + +@lru_cache(maxsize=1) +def _client() -> LandoClient: + return LandoClient() + + +def try_task_config(tasks: list[str]) -> dict: + """The ``try_task_config.json`` contents selecting ``tasks``. + + Version 2 of the format, matching what ``mach try``'s + ``generate_try_task_config`` writes: the labels go in verbatim and + ``optimize_target_tasks`` is off, so the tasks asked for are the tasks that + run (their dependencies can still be optimised away). + + ``TRY_SELECTOR`` is reported as ``fuzzy``, the selector whose pushes this one + is shaped like (an explicit list of task labels). It reaches the tasks as an + environment variable, so an invented value such as "hackbot" would be a + value no in-tree consumer has ever seen; a push should not be the thing that + finds out what happens then. + """ + return { + "version": 2, + "parameters": { + "optimize_target_tasks": False, + "try_task_config": { + "env": {"TRY_SELECTOR": "fuzzy"}, + "tasks": sorted(set(tasks)), + }, + }, + } + + +def try_task_config_patch( + tasks: list[str], title: str | None = None, now: datetime | None = None +) -> bytes: + """A ``git format-patch`` email whose one commit adds ``try_task_config.json``. + + Appended to the agent's own patches as the tip commit of the try push, the + way ``mach try`` commits the same file on top of the working tree. + """ + content = ( + json.dumps( + try_task_config(tasks), indent=4, separators=(",", ": "), sort_keys=True + ) + + "\n" + ) + added_lines = content.splitlines() + patch = _PATCH_TEMPLATE.format( + author=_AUTHOR, + date=format_datetime(now or datetime.now(timezone.utc)), + title=_commit_title(title), + body=_COMMIT_BODY, + filename=_TRY_CONFIG_FILENAME, + line_count=len(added_lines), + added_lines="\n".join(f"+{line}" for line in added_lines), + ) + return (patch + _PATCH_TRAILER).encode() + + +def _commit_title(title: str | None) -> str: + """A single-line commit subject for the try commit. + + The agent's title is free text, so collapse it to one line: a newline in it + would end the ``Subject`` header early and push the rest of the title into + the patch body (or, worse, be read as another header). + """ + single_line = " ".join((title or "").split()) + return single_line or _DEFAULT_TITLE + + +class PushHandler: + """Applies ``try_server.push``: the run's commits become a try push.""" + + async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult: + tasks = params.get("tasks") or [] + if not tasks: + return ActionResult.failed("A try push needs at least one task label") + + try: + raw = await ctx.download_artifact(_TRY_PUSH_ARTIFACT_KEY) + submission = json.loads(raw) + except Exception as exc: + log.exception("Failed to load try push artifact for run %s", ctx.run_id) + return ActionResult.failed(f"No try push artifact for this run: {exc}") + + try: + client = _client() + job_id = await client.submit_try_patches( + [ + *submission["patches"], + encode_patch(try_task_config_patch(tasks, params.get("title"))), + ], + submission["base_commit"], + base_commit_vcs=submission["base_commit_vcs"], + patch_format=submission["patch_format"], + repo_name=_TRY_REPO_NAME, + ) + except Exception as exc: + log.exception("Failed to push run %s to try", ctx.run_id) + return ActionResult.failed(str(exc)) + + return ActionResult.ok( + { + "job_id": job_id, + # `url` is the field a `{{actions..url}}` placeholder reads, + # so it is the one a human would want from a try push. + "url": client.treeherder_url(job_id, _TRY_REPO_NAME), + "lando_url": client.job_url(job_id), + "tasks": sorted(set(tasks)), + } + ) diff --git a/libs/hackbot-runtime/hackbot_runtime/actions/try_server.py b/libs/hackbot-runtime/hackbot_runtime/actions/try_server.py new file mode 100644 index 0000000000..1f32b0cbcf --- /dev/null +++ b/libs/hackbot-runtime/hackbot_runtime/actions/try_server.py @@ -0,0 +1,95 @@ +"""Try-server-domain recordable actions.""" + +from __future__ import annotations + +from typing import Annotated + +from agent_tools.registry import ToolError, tool, tools_in +from pydantic import Field + +from hackbot_runtime.actions.recorder import ActionsRecorder + +TRY_PUSH_ACTION_TYPE = "try_server.push" + +# Anything gated on "this run pushes to try" — today the patch-series artifact +# built in ``context.publish_changes`` — keys off this set, for symmetry with +# ``phabricator.PATCH_ACTION_TYPES``. +TRY_ACTION_TYPES = frozenset({TRY_PUSH_ACTION_TYPE}) + + +@tool +async def push( + recorder: ActionsRecorder, + tasks: Annotated[ + list[str], + Field( + description=( + "Treeherder task labels to run, e.g. ['build-linux64/opt', " + "'test-linux2404-64/opt-mochitest-browser-chrome-1']. Only the " + "tasks that actually exercise your change: every extra label " + "spends build machine time. Must not be empty — there is no " + "'run everything' shorthand." + ) + ), + ], + reasoning: Annotated[ + str, Field(description="Why you are pushing to try (for audit log).") + ], + title: Annotated[ + str | None, + Field( + default=None, + description=( + "Single-line description of the push, used as the commit message " + "shown on Treeherder (e.g. 'Bug 123 - verify the fix on Linux')." + ), + ), + ] = None, + ref: Annotated[ + str | None, + Field( + default=None, + description=( + "Optional label for this action so a later action (e.g. a " + "bugzilla.add_comment in the same run) can reference its " + "result once applied, via {{actions..url}} in that " + "action's text." + ), + ), + ] = None, +) -> str: + """Run your changes on the Firefox try server. + + Use this to have CI verify a change you cannot verify locally — a platform + you cannot build, or a test suite you cannot run. It does not deliver a fix: + to submit code for review, use ``submit_patch`` (a try push and a revision + are independent, so a run may reasonably do both). + + You do not supply a patch file, and you do not need to touch + ``try_task_config.json``: your final code changes in the working directory + are pushed as-is and the task selection is built from ``tasks``, so make and + verify all your edits first, then call this once you are done. Calling it + records the push as a proposed action for review; nothing is pushed during + the run, so you will not see the results — a human reads them on Treeherder. + + Set `ref` if you want to reference the push's Treeherder URL from another + action in the same run, written as `{{actions..url}}` (for example, + inside a bug comment). + """ + cleaned = [task.strip() for task in tasks if task and task.strip()] + if not cleaned: + raise ToolError( + "A try push needs at least one Treeherder task label in `tasks`; " + "an empty selection would run nothing." + ) + + recorder.record( + TRY_PUSH_ACTION_TYPE, + {"tasks": cleaned, "title": title}, + reasoning=reasoning, + ref=ref, + ) + return f"Recorded {TRY_PUSH_ACTION_TYPE} (#{len(recorder.actions) - 1})." + + +TOOLS = tools_in(__name__) diff --git a/libs/hackbot-runtime/hackbot_runtime/changes.py b/libs/hackbot-runtime/hackbot_runtime/changes.py index 70f2a7953f..279a81bb6f 100644 --- a/libs/hackbot-runtime/hackbot_runtime/changes.py +++ b/libs/hackbot-runtime/hackbot_runtime/changes.py @@ -13,9 +13,11 @@ from __future__ import annotations +import base64 import contextlib import logging import os +import re import subprocess import tempfile from collections.abc import Iterator @@ -24,6 +26,8 @@ log = logging.getLogger("hackbot_runtime.changes") +_FULL_SHA_RE = re.compile(r"[0-9a-f]{40}") + # Author stamped on the synthetic commit that wraps any uncommitted remainder. _WIP_NAME = "Hackbot Agent" _WIP_EMAIL = "hackbot@mozilla.tld" @@ -279,6 +283,38 @@ def build_phabricator_diff(repo: Path, base: str, repo_url: str) -> dict | None: } +def build_try_push(repo: Path, base: str) -> dict | None: + """Build the artifact for pushing the agent's changes to the try server.""" + if not _FULL_SHA_RE.fullmatch(base): + log.warning( + "Cannot build a try push from base commit %r: Lando needs a full " + "40-character published commit hash", + base, + ) + return None + + # Normally already done by `collect`; repeated here (it is a no-op on a + # clean tree) so this does not silently drop the agent's uncommitted work if + # it is ever called on its own. + _wrap_uncommitted(repo) + + revisions = _git(repo, "rev-list", "--reverse", f"{base}..HEAD").split() + if not revisions: + return None + + return { + "base_commit": base, + "base_commit_vcs": "git", + "patch_format": "git-format-patch", + "patches": [ + base64.b64encode( + _git_bytes(repo, "format-patch", "--binary", "--stdout", "-1", revision) + ).decode("ascii") + for revision in revisions + ], + } + + def collect(repo: Path, base: str, repo_url: str) -> ChangeSet | None: """Collect changes in ``repo`` since ``base`` as a patch plus metadata. diff --git a/libs/hackbot-runtime/hackbot_runtime/context.py b/libs/hackbot-runtime/hackbot_runtime/context.py index 044f79e503..e954e7daba 100644 --- a/libs/hackbot-runtime/hackbot_runtime/context.py +++ b/libs/hackbot-runtime/hackbot_runtime/context.py @@ -27,6 +27,7 @@ from hackbot_runtime import artifacts, changes from hackbot_runtime.actions.phabricator import PATCH_ACTION_TYPES from hackbot_runtime.actions.recorder import ActionsRecorder +from hackbot_runtime.actions.try_server import TRY_ACTION_TYPES from hackbot_runtime.config import HackbotConfig, load_config from hackbot_runtime.providers import AnthropicAuth from hackbot_runtime.source import ensure_source_repo @@ -225,20 +226,9 @@ def publish_changes( patch_key: str = "changes/changes.patch", meta_key: str = "changes/changes.json", phabricator_diff_key: str = "changes/phabricator_diff.json", + try_push_key: str = "changes/try_push.json", ) -> str | None: - """Collect the agent's source-tree changes and publish them as artifacts. - - Produces an mbox patch (applied with ``git am``) that preserves any - local commits and wraps the uncommitted remainder, plus a JSON summary. - Returns the patch key, or ``None`` when the agent never prepared a source - checkout or made no changes at all. - - If the agent recorded a Phabricator patch action, also builds - and publishes the Phabricator submission payload here — while the - checkout the agent already has is still around — so the downstream - apply step never needs its own checkout (see - ``changes.build_phabricator_diff``). - """ + """Collect the agent's source-tree changes and publish them as artifacts.""" if self._source_base is None: return None change_set = changes.collect( @@ -255,14 +245,18 @@ def publish_changes( ) self.publish_json(meta_key, change_set.metadata) - wants_phabricator = any( - action["type"] in PATCH_ACTION_TYPES for action in self.actions.actions - ) - if wants_phabricator: + recorded_types = {action["type"] for action in self.actions.actions} + + if recorded_types & PATCH_ACTION_TYPES: diff_payload = changes.build_phabricator_diff( self.repo_path, self._source_base, self._config.source.repo_url ) if diff_payload is not None: self.publish_json(phabricator_diff_key, diff_payload) + if recorded_types & TRY_ACTION_TYPES: + try_payload = changes.build_try_push(self.repo_path, self._source_base) + if try_payload is not None: + self.publish_json(try_push_key, try_payload) + return patch_key diff --git a/libs/hackbot-runtime/pyproject.toml b/libs/hackbot-runtime/pyproject.toml index 2add49b1df..386e6bc465 100644 --- a/libs/hackbot-runtime/pyproject.toml +++ b/libs/hackbot-runtime/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "google-auth>=2.0.0", "async-lru>=2.0.0", "agent-tools", + "lando-client", "phabricator-client", "testrail-client", "weave>=0.53.4" @@ -24,6 +25,7 @@ phabricator = ["MozPhab==2.15.3"] [tool.uv.sources] agent-tools = { workspace = true } +lando-client = { workspace = true } phabricator-client = { workspace = true } testrail-client = { workspace = true } diff --git a/libs/hackbot-runtime/tests/test_changes.py b/libs/hackbot-runtime/tests/test_changes.py index b9624be372..4a49f82029 100644 --- a/libs/hackbot-runtime/tests/test_changes.py +++ b/libs/hackbot-runtime/tests/test_changes.py @@ -1,17 +1,20 @@ -"""Tests for building the Phabricator diff payload from a real git repo. +"""Tests for building submission payloads from a real git repo. `collect()` (the pre-existing git-am patch collector) has no test coverage -either way and is out of scope here — this covers the new -`_synthetic_commit`/`build_phabricator_diff`, which run against the agent's -already-checked-out repo (see hackbot_runtime.context.publish_changes). +either way and is out of scope here — this covers +`_synthetic_commit`/`build_phabricator_diff` and `build_try_push`, which run +against the agent's already-checked-out repo (see +hackbot_runtime.context.publish_changes). """ +import base64 import builtins from hackbot_runtime.changes import ( _git, _synthetic_commit, build_phabricator_diff, + build_try_push, ) @@ -153,3 +156,54 @@ def fake_import(name, *args, **kwargs): payload = build_phabricator_diff(tmp_path, base, "https://example.com/repo.git") assert payload is None + + +# --- build_try_push ------------------------------------------------------ # + + +def _decoded_patches(payload): + return [base64.b64decode(patch).decode() for patch in payload["patches"]] + + +def test_build_try_push_one_patch_per_commit(tmp_path): + base = _init_repo(tmp_path) + _commit_change(tmp_path, "line1\nline2 modified\nline3\n", message="first fix") + _commit_change(tmp_path, "line1\nline2 modified\nline3 too\n", message="second fix") + + payload = build_try_push(tmp_path, base) + + assert payload["base_commit"] == base + assert payload["base_commit_vcs"] == "git" + assert payload["patch_format"] == "git-format-patch" + patches = _decoded_patches(payload) + assert len(patches) == 2 + # Oldest first, and each patch is a standalone format-patch email (Lando + # parses every array entry on its own). + assert "Subject: [PATCH] first fix" in patches[0] + assert "Subject: [PATCH] second fix" in patches[1] + assert "second fix" not in patches[0] + + +def test_build_try_push_includes_uncommitted_work(tmp_path): + base = _init_repo(tmp_path) + (tmp_path / "file.txt").write_text("line1\nuncommitted\nline3\n") + + payload = build_try_push(tmp_path, base) + + assert len(payload["patches"]) == 1 + assert "+uncommitted" in _decoded_patches(payload)[0] + + +def test_build_try_push_no_changes_returns_none(tmp_path): + base = _init_repo(tmp_path) + + assert build_try_push(tmp_path, base) is None + + +def test_build_try_push_rejects_abbreviated_base(tmp_path): + base = _init_repo(tmp_path) + _commit_change(tmp_path, "line1\nline2 modified\nline3\n") + + # Lando needs a full published hash; a short one would fail server-side with + # a far less obvious error. + assert build_try_push(tmp_path, base[:12]) is None diff --git a/libs/hackbot-runtime/tests/test_context.py b/libs/hackbot-runtime/tests/test_context.py index a6b87ace4a..6b25185952 100644 --- a/libs/hackbot-runtime/tests/test_context.py +++ b/libs/hackbot-runtime/tests/test_context.py @@ -190,6 +190,43 @@ def test_publish_changes_builds_phabricator_diff_when_action_recorded( assert submission["local_commits"]["node"]["author"] == "A" +def test_publish_changes_builds_try_push_when_action_recorded(tmp_path, monkeypatch): + hb = _hb_with_source(tmp_path, monkeypatch) + monkeypatch.setattr( + "hackbot_runtime.context.changes.build_try_push", + lambda repo, base: {"base_commit": base, "patches": ["cGF0Y2g="]}, + ) + hb.actions.record( + "try_server.push", {"tasks": ["build-linux64/opt"]}, reasoning="r" + ) + + hb.publish_changes() + + payload = json.loads( + ( + tmp_path / "artifacts" / "local-test" / "changes" / "try_push.json" + ).read_text() + ) + assert payload["base_commit"] == "basecommit" + + +def test_publish_changes_skips_try_push_without_action(tmp_path, monkeypatch): + hb = _hb_with_source(tmp_path, monkeypatch) + called = [] + monkeypatch.setattr( + "hackbot_runtime.context.changes.build_try_push", + lambda *a, **k: called.append(a) or {}, + ) + hb.actions.record("bugzilla.add_comment", {"bug_id": 1}, reasoning="r") + + hb.publish_changes() + + assert called == [] + assert not ( + tmp_path / "artifacts" / "local-test" / "changes" / "try_push.json" + ).exists() + + def test_publish_changes_skips_phabricator_diff_without_action(tmp_path, monkeypatch): hb = _hb_with_source(tmp_path, monkeypatch) called = [] diff --git a/libs/hackbot-runtime/tests/test_try_server_actions.py b/libs/hackbot-runtime/tests/test_try_server_actions.py new file mode 100644 index 0000000000..2b535d9bc8 --- /dev/null +++ b/libs/hackbot-runtime/tests/test_try_server_actions.py @@ -0,0 +1,63 @@ +"""Tests for the try-server recording tool (push).""" + +import pytest +from agent_tools.registry import ToolError +from hackbot_runtime.actions import ActionsRecorder, try_server + + +async def test_push_records_tasks_and_title(): + rec = ActionsRecorder() + await try_server.push( + rec, + tasks=["build-linux64/opt"], + reasoning="r", + title="Bug 1 - verify on Linux", + ) + action = rec.actions[0] + assert action["type"] == "try_server.push" + assert action["params"] == { + "tasks": ["build-linux64/opt"], + "title": "Bug 1 - verify on Linux", + } + assert "ref" not in action + + +async def test_push_ref_is_recorded(): + rec = ActionsRecorder() + await try_server.push(rec, tasks=["build-linux64/opt"], reasoning="r", ref="try") + assert rec.actions[0]["ref"] == "try" + + +async def test_push_strips_blank_task_labels(): + rec = ActionsRecorder() + await try_server.push(rec, tasks=[" build-linux64/opt ", "", " "], reasoning="r") + assert rec.actions[0]["params"]["tasks"] == ["build-linux64/opt"] + + +@pytest.mark.parametrize("tasks", [[], ["", " "]]) +async def test_push_refuses_an_empty_selection(tasks): + """A push with no tasks would run nothing, so it never gets recorded.""" + rec = ActionsRecorder() + with pytest.raises(ToolError): + await try_server.push(rec, tasks=tasks, reasoning="r") + assert rec.actions == [] + + +async def test_push_requires_tasks(): + rec = ActionsRecorder() + with pytest.raises(TypeError): + await try_server.push(rec, reasoning="r") + + +def test_agent_facing_schema(): + push = next(t for t in try_server.TOOLS if t.name == "push") + + # No patch/repo/base arguments: the push is built from the run's own + # changes, so the agent only chooses which tasks run. + assert set(push.input_schema["required"]) == {"tasks", "reasoning"} + assert set(push.input_schema["properties"]) == { + "tasks", + "reasoning", + "title", + "ref", + } diff --git a/libs/hackbot-runtime/tests/test_try_server_handler.py b/libs/hackbot-runtime/tests/test_try_server_handler.py new file mode 100644 index 0000000000..61a2da6bb7 --- /dev/null +++ b/libs/hackbot-runtime/tests/test_try_server_handler.py @@ -0,0 +1,226 @@ +"""Tests for the apply-side try-server action handler. + +Mocks the Lando submission so these exercise the handler's own logic — loading +the agent-built patch series, generating the `try_task_config.json` commit, +result shaping. The generated commit is checked against real `git am`, since +"whatever `git format-patch` produces" is exactly what Lando's parser expects, +and a patch that `git am` rejects would fail server-side where it is much harder +to diagnose. +""" + +import json +from base64 import b64decode + +import pytest +from hackbot_runtime.actions.handlers import ApplyContext, try_server_handler +from hackbot_runtime.actions.handlers.registry import get_handler +from hackbot_runtime.actions.try_server import TRY_ACTION_TYPES +from hackbot_runtime.changes import _git +from lando_client import LandoClient + +_SUBMISSION = { + "base_commit": "a" * 40, + "base_commit_vcs": "git", + "patch_format": "git-format-patch", + "patches": ["cGF0Y2gtb25l"], # b64("patch-one") +} + + +@pytest.fixture(autouse=True) +def _lando_env(monkeypatch): + """Provide a dummy Lando token; the submission itself is always mocked.""" + monkeypatch.setenv("LANDO_ACCESS_TOKEN", "token") + try_server_handler._client.cache_clear() + yield + try_server_handler._client.cache_clear() + + +def _ctx(submission=None, missing=False): + async def download(key): + assert key == "changes/try_push.json" + if missing: + raise FileNotFoundError(key) + return json.dumps(submission or _SUBMISSION).encode() + + return ApplyContext(run_id="run-1", download_artifact=download) + + +@pytest.fixture +def submitted(monkeypatch): + """Capture what the handler would send to Lando, returning job id 4321.""" + calls = [] + + async def fake_submit(self, patches, base_commit, **kwargs): + calls.append({"patches": patches, "base_commit": base_commit, **kwargs}) + return 4321 + + monkeypatch.setattr(LandoClient, "submit_try_patches", fake_submit) + return calls + + +def test_handler_is_registered(): + assert isinstance(get_handler("try_server.push"), try_server_handler.PushHandler) + # Every type the recording side can emit is registered. + assert all(get_handler(t) is not None for t in TRY_ACTION_TYPES) + + +# --- try_task_config ----------------------------------------------------- # + + +def test_try_task_config_selects_the_requested_tasks(): + config = try_server_handler.try_task_config( + ["source-test-mozlint-eslint", "build-linux64/opt"] + ) + + assert config["version"] == 2 + parameters = config["parameters"] + # Off, so the tasks asked for are the tasks that run. + assert parameters["optimize_target_tasks"] is False + assert parameters["try_task_config"]["tasks"] == [ + "build-linux64/opt", + "source-test-mozlint-eslint", + ] + + +def test_try_task_config_deduplicates_tasks(): + config = try_server_handler.try_task_config(["build-linux64/opt"] * 3) + + assert config["parameters"]["try_task_config"]["tasks"] == ["build-linux64/opt"] + + +# --- try_task_config_patch ---------------------------------------------- # + + +def _apply_with_git_am(repo, patch: bytes): + """`git am` the patch onto a fresh repo, returning the resulting commit log.""" + _git(repo, "init", "-q") + _git(repo, "config", "user.email", "test@test.com") + _git(repo, "config", "user.name", "Test") + (repo / "README").write_text("hello\n") + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "base") + + patch_file = repo / "config.patch" + patch_file.write_bytes(patch) + _git(repo, "am", str(patch_file)) + return _git(repo, "log", "-1", "--format=%an <%ae>%n%B").strip() + + +def test_try_task_config_patch_applies_as_a_real_git_patch(tmp_path): + patch = try_server_handler.try_task_config_patch( + ["build-linux64/opt"], "Bug 1 - verify the fix" + ) + + log = _apply_with_git_am(tmp_path, patch) + + assert log.startswith("Hackbot Agent ") + assert "Bug 1 - verify the fix" in log + written = json.loads((tmp_path / "try_task_config.json").read_text()) + assert written == try_server_handler.try_task_config(["build-linux64/opt"]) + + +def test_try_task_config_patch_falls_back_to_a_default_title(tmp_path): + patch = try_server_handler.try_task_config_patch(["build-linux64/opt"]) + + assert "Subject: [PATCH] Hackbot try push" in patch.decode() + assert (tmp_path / "try_task_config.json").exists() is False + + +def test_try_task_config_patch_collapses_a_multiline_title(tmp_path): + """A newline in the title would end the Subject header early.""" + patch = try_server_handler.try_task_config_patch( + ["build-linux64/opt"], "Bug 1 - a fix\nDate: bogus\n\nnot the body" + ) + + log = _apply_with_git_am(tmp_path, patch) + + assert log.splitlines()[1] == "Bug 1 - a fix Date: bogus not the body" + + +def test_try_task_config_patch_has_the_version_info_trailer(): + """Lando finds the end of the diff by scanning back for the `--` barrier.""" + lines = try_server_handler.try_task_config_patch(["build-linux64/opt"]).decode() + + assert lines.rstrip().splitlines()[-2] == "-- " + + +# --- PushHandler --------------------------------------------------------- # + + +async def test_apply_appends_the_config_commit_to_the_agents_patches(submitted): + result = await try_server_handler.PushHandler().apply( + {"tasks": ["build-linux64/opt"], "title": "Bug 1 - verify"}, _ctx() + ) + + assert result.status == "applied" + call = submitted[0] + assert call["base_commit"] == "a" * 40 + assert call["base_commit_vcs"] == "git" + assert call["patch_format"] == "git-format-patch" + assert call["repo_name"] == "try" + # The agent's own commits come first, with the task selection as the tip. + assert len(call["patches"]) == 2 + assert b64decode(call["patches"][0]) == b"patch-one" + tip = b64decode(call["patches"][1]).decode() + assert "try_task_config.json" in tip + assert "build-linux64/opt" in tip + + +async def test_apply_returns_treeherder_and_lando_urls(submitted): + result = await try_server_handler.PushHandler().apply( + {"tasks": ["build-linux64/opt"]}, _ctx() + ) + + assert result.result["job_id"] == 4321 + assert result.result["tasks"] == ["build-linux64/opt"] + # `url` is what a `{{actions..url}}` placeholder resolves to, so it has + # to be the one a human wants: the Treeherder view of the push. + assert result.result["url"] == ( + "https://treeherder.mozilla.org/jobs?repo=try" + "&landoInstance=lando-prod-2025&landoCommitID=4321" + ) + assert result.result["lando_url"] == "https://lando.moz.tools/landings/4321" + + +async def test_apply_fails_without_a_patch_artifact(submitted): + result = await try_server_handler.PushHandler().apply( + {"tasks": ["build-linux64/opt"]}, _ctx(missing=True) + ) + + assert result.status == "failed" + assert "No try push artifact" in result.error + assert submitted == [] + + +async def test_apply_fails_without_tasks(submitted): + result = await try_server_handler.PushHandler().apply({"tasks": []}, _ctx()) + + assert result.status == "failed" + assert submitted == [] + + +async def test_apply_reports_a_missing_lando_token(monkeypatch): + """No token configured fails this one action, rather than the whole service.""" + monkeypatch.delenv("LANDO_ACCESS_TOKEN", raising=False) + try_server_handler._client.cache_clear() + + result = await try_server_handler.PushHandler().apply( + {"tasks": ["build-linux64/opt"]}, _ctx() + ) + + assert result.status == "failed" + assert "access_token" in result.error + + +async def test_apply_reports_a_lando_rejection(monkeypatch): + async def fake_submit(self, *args, **kwargs): + raise RuntimeError("Lando returned HTTP 400: Repo try does not exist.") + + monkeypatch.setattr(LandoClient, "submit_try_patches", fake_submit) + + result = await try_server_handler.PushHandler().apply( + {"tasks": ["build-linux64/opt"]}, _ctx() + ) + + assert result.status == "failed" + assert "Repo try does not exist." in result.error diff --git a/libs/lando-client/lando_client/__init__.py b/libs/lando-client/lando_client/__init__.py new file mode 100644 index 0000000000..efbfef86b0 --- /dev/null +++ b/libs/lando-client/lando_client/__init__.py @@ -0,0 +1,17 @@ +from lando_client.client import ( + PATCH_FORMAT_GIT, + PATCH_FORMAT_HG, + LandoAPIError, + LandoClient, + encode_patch, +) +from lando_client.config import LandoSettings + +__all__ = [ + "LandoAPIError", + "LandoClient", + "LandoSettings", + "PATCH_FORMAT_GIT", + "PATCH_FORMAT_HG", + "encode_patch", +] diff --git a/libs/lando-client/lando_client/client.py b/libs/lando-client/lando_client/client.py new file mode 100644 index 0000000000..927300ff6a --- /dev/null +++ b/libs/lando-client/lando_client/client.py @@ -0,0 +1,121 @@ +"""Small shared Lando API client.""" + +from __future__ import annotations + +import base64 + +import httpx + +from lando_client.config import LandoSettings + +TREEHERDER_URL = "https://treeherder.mozilla.org" + +# The patch formats Lando accepts (`PatchFormat` in lando.main.scm.helpers). +PATCH_FORMAT_GIT = "git-format-patch" +PATCH_FORMAT_HG = "hgexport" + + +class LandoAPIError(Exception): + """Lando rejected a request (or answered with something unusable).""" + + +def encode_patch(patch: bytes) -> str: + """Base64-encode one patch for the ``patches`` array.""" + return base64.b64encode(patch).decode("ascii") + + +class LandoClient: + def __init__(self, settings: LandoSettings | None = None) -> None: + self.settings = settings or LandoSettings.from_env() + + @property + def base_url(self) -> str: + return self.settings.url.rstrip("/") + + @property + def try_patches_url(self) -> str: + return f"{self.base_url}/api/try/patches" + + def job_url(self, job_id: int) -> str: + """Lando's own page for a landing job (its live status).""" + return f"{self.base_url}/landings/{job_id}" + + def treeherder_url(self, job_id: int, repo_name: str = "try") -> str: + """Treeherder's view of a Lando try job. + + Keyed by ``landoCommitID`` rather than a revision because the push has + no revision yet: Lando applies the patches asynchronously, so this URL + is valid (if initially empty) the moment the job is created. + """ + return ( + f"{TREEHERDER_URL}/jobs?repo={repo_name}" + f"&landoInstance={self.settings.instance_id}" + f"&landoCommitID={job_id}" + ) + + async def submit_try_patches( + self, + patches: list[str], + base_commit: str, + *, + base_commit_vcs: str = "git", + patch_format: str = PATCH_FORMAT_GIT, + repo_name: str = "try", + ) -> int: + """Submit a base64-encoded patch series to a Try repo; return the job id. + + ``patches`` are applied in order on top of ``base_commit``, which must be + a full 40-character hash of a *published* commit (Lando maps it to the + try repo's own SCM when ``base_commit_vcs`` differs, so a firefox git + sha is fine for the Mercurial ``try``). + """ + payload = { + "repo_name": repo_name, + "base_commit": base_commit, + "base_commit_vcs": base_commit_vcs, + "patch_format": patch_format, + "patches": patches, + } + async with httpx.AsyncClient(timeout=self.settings.timeout_seconds) as client: + response = await client.post( + self.try_patches_url, + json=payload, + headers={ + "Authorization": f"Bearer {self.settings.access_token}", + "User-Agent": self.settings.user_agent, + }, + ) + + if response.status_code >= 400: + raise LandoAPIError(_error_detail(response)) + + try: + return int(response.json()["id"]) + except (ValueError, KeyError, TypeError) as exc: + raise LandoAPIError( + f"Lando accepted the push ({response.status_code}) but returned no " + "job id" + ) from exc + + +def _error_detail(response: httpx.Response) -> str: + """A readable message for a failed Lando response. + + Lando reports errors as RFC 7807 problem details (``title``/``detail``), + but a proxy or a 5xx can still answer with HTML, so fall back to the status + line rather than raising a ``JSONDecodeError`` over the real failure. + """ + try: + problem = response.json() + except ValueError: + problem = None + + if isinstance(problem, dict): + parts = [str(problem[key]) for key in ("title", "detail") if problem.get(key)] + if parts: + return f"Lando returned HTTP {response.status_code}: {': '.join(parts)}" + + return ( + f"Lando returned HTTP {response.status_code} ({response.reason_phrase}) " + f"for {response.request.url}" + ) diff --git a/libs/lando-client/lando_client/config.py b/libs/lando-client/lando_client/config.py new file mode 100644 index 0000000000..9aaece5bc7 --- /dev/null +++ b/libs/lando-client/lando_client/config.py @@ -0,0 +1,63 @@ +"""Configuration for :class:`LandoClient`.""" + +from __future__ import annotations + +from urllib.parse import urlsplit + +from pydantic import BaseModel, Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Lando deployment names, keyed by the host they serve. This pairing is not +# something Lando exposes over its API: `instance_id` is a *client-side* label, +# declared in mozilla-central's `.lando.ini` (one `instance_id`/`api_domain` +# pair per section) and mapped back to a host by Treeherder itself. +INSTANCE_IDS_BY_HOST = { + "lando.moz.tools": "lando-prod-2025", + "lando-dev.allizom.org": "lando-dev-2025", + "api.lando.services.mozilla.com": "lando-prod", + "api.dev.lando.nonprod.cloudops.mozgcp.net": "lando-dev", +} + + +class LandoSettings(BaseModel): + """Where Lando lives and how to authenticate against it. + + ``access_token`` is an OIDC bearer token for the account that owns the + pushes: Lando's Try endpoint authenticates the *user* (it has no API-key + mode), and the try repository's permissions are checked against that user. + """ + + access_token: str = Field(min_length=1) + url: str = "https://lando.moz.tools" + instance_id: str | None = None + timeout_seconds: int = 60 + user_agent: str = "Lando-User/Hackbot" + + @model_validator(mode="after") + def _resolve_instance_id(self) -> LandoSettings: + host = urlsplit(self.url).hostname + resolved = INSTANCE_IDS_BY_HOST.get(host) + if not resolved and not self.instance_id: + known_hosts = ", ".join(sorted(INSTANCE_IDS_BY_HOST)) + raise ValueError( + f"Unknown Lando host {host!r}: cannot tell which deployment " + "Treeherder should link to. Set LANDO_INSTANCE_ID explicitly " + f"(known hosts: {known_hosts})." + ) + if resolved and self.instance_id and self.instance_id != resolved: + raise ValueError( + f"LANDO_INSTANCE_ID {self.instance_id!r} does not match the " + f"known deployment for {host!r} ({resolved!r})." + ) + if resolved and not self.instance_id: + self.instance_id = resolved + + return self + + @classmethod + def from_env(cls) -> LandoSettings: + return _LandoEnvSettings() + + +class _LandoEnvSettings(LandoSettings, BaseSettings): + model_config = SettingsConfigDict(env_prefix="LANDO_", extra="ignore") diff --git a/libs/lando-client/pyproject.toml b/libs/lando-client/pyproject.toml new file mode 100644 index 0000000000..ef78a93feb --- /dev/null +++ b/libs/lando-client/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "lando-client" +version = "0.1.0" +description = "Small shared Lando API client (httpx-based)" +requires-python = ">=3.12" +dependencies = [ + "httpx>=0.26.0", + "pydantic-settings>=2.1.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["lando_client"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/libs/lando-client/tests/test_client.py b/libs/lando-client/tests/test_client.py new file mode 100644 index 0000000000..ef9a710135 --- /dev/null +++ b/libs/lando-client/tests/test_client.py @@ -0,0 +1,167 @@ +"""Tests for the shared Lando client.""" + +import re +from base64 import b64decode + +import httpx +import pytest +from lando_client import ( + LandoAPIError, + LandoClient, + LandoSettings, + encode_patch, +) +from lando_client import client as client_module +from pydantic import ValidationError + + +def _client(**kwargs) -> LandoClient: + return LandoClient(LandoSettings(access_token="token", **kwargs)) + + +def _capture_post(monkeypatch, response: httpx.Response) -> dict: + """Stub httpx.AsyncClient to answer with `response`; capture the call.""" + captured: dict = {} + + class _FakeAsyncClient: + def __init__(self, timeout=None): + captured["timeout"] = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, url, json=None, headers=None): + captured["url"] = url + captured["json"] = json + captured["headers"] = headers + response.request = httpx.Request("POST", url) + return response + + monkeypatch.setattr(client_module.httpx, "AsyncClient", _FakeAsyncClient) + return captured + + +def test_access_token_is_required(): + with pytest.raises(ValidationError): + LandoSettings(access_token="") + + +def test_urls_are_built_from_the_configured_host(): + client = _client(url="https://lando-dev.allizom.org/") + + # A trailing slash in config must not double up in the URLs. + assert client.try_patches_url == "https://lando-dev.allizom.org/api/try/patches" + assert client.job_url(7) == "https://lando-dev.allizom.org/landings/7" + # Pointing at another deployment moves the Treeherder link with it, with no + # second setting to keep in step. + assert client.treeherder_url(7) == ( + "https://treeherder.mozilla.org/jobs?repo=try" + "&landoInstance=lando-dev-2025&landoCommitID=7" + ) + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("https://lando.moz.tools", "lando-prod-2025"), + ("https://lando-dev.allizom.org", "lando-dev-2025"), + ("https://api.lando.services.mozilla.com", "lando-prod"), + ("https://api.dev.lando.nonprod.cloudops.mozgcp.net", "lando-dev"), + ], +) +def test_instance_id_is_derived_from_the_host(url, expected): + """Matches Treeherder's own instance -> host map (ui/helpers/url.js).""" + assert _client(url=url).settings.instance_id == expected + + +def test_instance_id_defaults_to_prod_without_any_configuration(): + assert LandoSettings(access_token="t").instance_id == "lando-prod-2025" + + +def test_explicit_instance_id_wins_for_an_unknown_host(): + settings = LandoSettings( + access_token="t", url="https://lando.example.test", instance_id="lando-local" + ) + assert settings.instance_id == "lando-local" + + +def test_unknown_host_without_an_instance_id_is_rejected(): + """A wrong id points Treeherder at the wrong Lando, so never guess one.""" + with pytest.raises(ValidationError, match="Unknown Lando host"): + LandoSettings(access_token="t", url="https://lando.example.test") + + +def test_treeherder_url_follows_the_repo(): + assert "repo=try-comm-central" in _client().treeherder_url(7, "try-comm-central") + + +def test_encode_patch_is_plain_base64(): + # Lando's schema rejects anything but `^[A-Za-z0-9+/]+={0,2}$`, so a long + # patch must not come back wrapped across lines. + patch = b"From abc\nSubject: [PATCH] a fix\n" * 50 + encoded = encode_patch(patch) + + assert re.fullmatch(r"[A-Za-z0-9+/]+={0,2}", encoded) + assert b64decode(encoded) == patch + + +async def test_submit_try_patches_posts_the_series_and_returns_the_job_id(monkeypatch): + captured = _capture_post(monkeypatch, httpx.Response(201, json={"id": 4321})) + + job_id = await _client().submit_try_patches(["cGF0Y2g="], "a" * 40) + + assert job_id == 4321 + assert captured["url"] == "https://lando.moz.tools/api/try/patches" + assert captured["headers"]["Authorization"] == "Bearer token" + assert captured["json"] == { + "repo_name": "try", + "base_commit": "a" * 40, + "base_commit_vcs": "git", + "patch_format": "git-format-patch", + "patches": ["cGF0Y2g="], + } + + +async def test_submit_try_patches_raises_with_lando_problem_detail(monkeypatch): + _capture_post( + monkeypatch, + httpx.Response( + 400, + json={ + "title": "Not a Try repository", + "detail": "Repo autoland is not a Try repository.", + "status": 400, + "type": "about:blank", + }, + ), + ) + + with pytest.raises(LandoAPIError) as excinfo: + await _client().submit_try_patches(["cGF0Y2g="], "a" * 40) + + assert "Not a Try repository" in str(excinfo.value) + assert "Repo autoland is not a Try repository." in str(excinfo.value) + + +async def test_submit_try_patches_raises_on_a_non_json_error(monkeypatch): + """A 5xx from a proxy answers with HTML, not a problem detail.""" + _capture_post( + monkeypatch, httpx.Response(503, text="Service Unavailable") + ) + + with pytest.raises(LandoAPIError) as excinfo: + await _client().submit_try_patches(["cGF0Y2g="], "a" * 40) + + assert "HTTP 503" in str(excinfo.value) + + +async def test_submit_try_patches_raises_when_no_job_id_comes_back(monkeypatch): + _capture_post(monkeypatch, httpx.Response(201, json={})) + + with pytest.raises(LandoAPIError) as excinfo: + await _client().submit_try_patches(["cGF0Y2g="], "a" * 40) + + assert "no job id" in str(excinfo.value) diff --git a/uv.lock b/uv.lock index c35e2add91..e611004669 100644 --- a/uv.lock +++ b/uv.lock @@ -31,6 +31,7 @@ members = [ "hackbot-api", "hackbot-pulse-listener", "hackbot-runtime", + "lando-client", "phabricator-client", "reviewhelper-api", "testrail-client", @@ -2710,6 +2711,7 @@ dependencies = [ { name = "async-lru" }, { name = "google-auth" }, { name = "httpx" }, + { name = "lando-client" }, { name = "phabricator-client" }, { name = "pydantic-settings" }, { name = "requests" }, @@ -2734,6 +2736,7 @@ requires-dist = [ { name = "claude-agent-sdk", marker = "extra == 'claude-sdk'", specifier = ">=0.1.30" }, { name = "google-auth", specifier = ">=2.0.0" }, { name = "httpx", specifier = ">=0.26.0" }, + { name = "lando-client", editable = "libs/lando-client" }, { name = "mozphab", marker = "extra == 'phabricator'", specifier = "==2.15.3" }, { name = "phabricator-client", editable = "libs/phabricator-client" }, { name = "pydantic-settings", specifier = ">=2.1.0" }, @@ -3475,6 +3478,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, ] +[[package]] +name = "lando-client" +version = "0.1.0" +source = { editable = "libs/lando-client" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic-settings" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.26.0" }, + { name = "pydantic-settings", specifier = ">=2.1.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, +] +provides-extras = ["dev"] + [[package]] name = "langchain" version = "1.2.18" @@ -4545,9 +4572,9 @@ resolution-markers = [ "python_full_version < '3.13' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "llvmlite", version = "0.45.1", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy" }, - { name = "setuptools" }, + { name = "llvmlite", version = "0.45.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "setuptools", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b0/2a/975f49e156dae4edd3ab5afc60e2b3d65add014db2ddbbc23b9bb89882a4/numba-0.47.0.tar.gz", hash = "sha256:c0703df0a0ea2e29fbef7937d9849cc4734253066cb5820c5d6e0851876e3b0a", size = 1935290, upload-time = "2020-01-03T17:03:47.391Z" } @@ -4567,8 +4594,8 @@ resolution-markers = [ "(python_full_version < '3.13' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] dependencies = [ - { name = "llvmlite", version = "0.47.0", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy" }, + { name = "llvmlite", version = "0.47.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "numpy", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } wheels = [ @@ -4998,7 +5025,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -6597,8 +6624,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "(platform_machine != 'x86_64' and sys_platform == 'darwin') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "jeepney", marker = "(platform_machine != 'x86_64' and sys_platform == 'darwin') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [