diff --git a/README.md b/README.md index b94f802..331e9be 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,103 @@ The [Contributor Guide](https://github.com/kernelci/kci-dev/blob/main/CONTRIBUTI For latest informations check out the documentation [here](https://kernelci.github.io/kci-dev/) +## Using kci-dev as a Python library + +Python applications can import `kci-dev` directly instead of shelling out to the +`kci-dev` command. This is useful for services such as mail clients, patchwork +integrations, or websites that want to test kernel email patches and then submit +or inspect KernelCI data. + +### Create a client + +```python +from kcidev import KernelCIClient + +client = KernelCIClient( + kcidb_rest_url="https://kcidb.kernelci.org/submit", + kcidb_token="", +) +``` + +The client also accepts the same config dictionary layout used by the CLI: + +```python +from kcidev import KernelCIClient +from kcidev.libs.common import load_toml + +cfg = load_toml(".kci-dev.toml", "submit") +client = KernelCIClient(cfg=cfg, instance="staging") +``` + +If explicit credentials are not provided, KCIDB submission can also use the +`KCIDB_REST` environment variable supported by the CLI. + +### Build and submit KCIDB build results + +```python +from kcidev import KernelCIClient + +client = KernelCIClient(kcidb_rest_url="https://example.test/submit", kcidb_token="secret") + +payload = client.build_kcidb_build_submission( + origin="my-mail-ci", + giturl="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", + branch="master", + commit="0123456789abcdef0123456789abcdef01234567", + tree_name="mainline", + arch="x86_64", + config_name="defconfig", + compiler="gcc-14", + status="PASS", + log_url="https://ci.example.test/logs/0123456789abcdef", + comment="Build triggered from an email patch series", +) + +result = client.submit_kcidb(payload) +``` + +For applications that already have a checked-out git tree, `git_folder` can be +used instead of manually passing `giturl`, `branch`, and `commit`: + +```python +payload = client.build_kcidb_build_submission( + origin="my-mail-ci", + git_folder="/srv/builds/linux", + arch="arm64", + config_name="defconfig", + status="FAIL", +) +``` + +Use `client.submit_build(...)` to build and submit the payload in a single call. + +### Query KernelCI dashboard data + +The library exposes Python methods for common dashboard requests and returns the +JSON-compatible Python objects returned by the API: + +```python +summary = client.get_summary( + origin="maestro", + giturl="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", + branch="master", + commit="0123456789abcdef0123456789abcdef01234567", +) + +builds = client.get_builds( + origin="maestro", + giturl="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", + branch="master", + commit="0123456789abcdef0123456789abcdef01234567", + arch="x86_64", +) +``` + +Additional helper functions remain importable from `kcidev.libs.*` for advanced +use cases, but new applications should prefer `KernelCIClient` for a stable, +Click-free library interface. Library methods raise `kcidev.KciDevError` for +recoverable kci-dev failures instead of aborting the process like the CLI. + ## License [LGPL-2.1](https://github.com/kernelci/kci-dev/blob/main/LICENSE) diff --git a/kcidev/__init__.py b/kcidev/__init__.py index e69de29..8a8da07 100644 --- a/kcidev/__init__.py +++ b/kcidev/__init__.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""kci-dev public package API.""" + +from kcidev.api import KciDevError, KernelCIClient +from kcidev.libs.common import kcidev_version + +__all__ = ["KciDevError", "KernelCIClient", "kcidev_version"] diff --git a/kcidev/api.py b/kcidev/api.py new file mode 100644 index 0000000..cb4438d --- /dev/null +++ b/kcidev/api.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""Public Python API for using kci-dev as a library. + +The CLI remains the primary user interface, but applications can import this +module to build and submit KCIDB payloads or query KernelCI dashboard data +without invoking Click commands or shelling out to ``kci-dev``. +""" + +from datetime import datetime, timezone + +import click + +from kcidev.libs.dashboard import ( + dashboard_fetch_boot_issues, + dashboard_fetch_boots, + dashboard_fetch_build, + dashboard_fetch_build_issues, + dashboard_fetch_builds, + dashboard_fetch_commits_history, + dashboard_fetch_hardware_boots, + dashboard_fetch_hardware_builds, + dashboard_fetch_hardware_list, + dashboard_fetch_hardware_summary, + dashboard_fetch_hardware_tests, + dashboard_fetch_issue, + dashboard_fetch_issue_builds, + dashboard_fetch_issue_list, + dashboard_fetch_issue_tests, + dashboard_fetch_issues_extra, + dashboard_fetch_summary, + dashboard_fetch_test, + dashboard_fetch_tests, + dashboard_fetch_tree_list, + dashboard_fetch_tree_report, +) +from kcidev.libs.git_repo import get_folder_repository +from kcidev.libs.kcidb import ( + build_build_payload, + build_checkout_payload, + build_submission_payload, + generate_build_id, + generate_checkout_id, + resolve_kcidb_config, + submit_to_kcidb, +) + + +class KciDevError(RuntimeError): + """Raised by the library API when an operation cannot be completed.""" + + +def _as_library_error(action, func, *args, **kwargs): + """Run an existing CLI-oriented helper and expose failures as exceptions.""" + try: + return func(*args, **kwargs) + except click.ClickException as exc: + raise KciDevError(f"{action}: {exc.message}") from exc + except click.Abort as exc: + raise KciDevError(action) from exc + + +class KernelCIClient: + """Client for interacting with KernelCI services from Python code. + + Args: + cfg: Optional kci-dev configuration dictionary, for example the result + of :func:`kcidev.libs.common.load_toml`. + instance: Optional instance name in ``cfg``. + kcidb_rest_url: Optional KCIDB submit endpoint override. + kcidb_token: Optional KCIDB bearer token override. + """ + + def __init__(self, cfg=None, instance=None, kcidb_rest_url=None, kcidb_token=None): + self.cfg = cfg + self.instance = instance + self.kcidb_rest_url = kcidb_rest_url + self.kcidb_token = kcidb_token + + def resolve_kcidb_config(self): + """Return the configured ``(rest_url, token)`` for KCIDB submission.""" + return _as_library_error( + "Unable to resolve KCIDB credentials", + resolve_kcidb_config, + self.cfg, + self.instance, + self.kcidb_rest_url, + self.kcidb_token, + ) + + def build_kcidb_build_submission( + self, + *, + origin, + giturl=None, + branch=None, + commit=None, + tree_name=None, + patchset_hash=None, + arch=None, + config_name=None, + compiler=None, + status=None, + start_time=None, + duration=None, + log_url=None, + config_url=None, + comment=None, + command=None, + git_folder=None, + ): + """Create a KCIDB checkout/build submission payload. + + This mirrors ``kci-dev submit build`` payload creation and can either use + explicit ``giturl``/``branch``/``commit`` values or auto-detect them from + ``git_folder``. + """ + if git_folder: + detected_url, detected_branch, detected_commit = _as_library_error( + "Unable to inspect git folder", + get_folder_repository, + git_folder, + branch, + ) + giturl = giturl or detected_url + branch = branch or detected_branch + commit = commit or detected_commit + + if not origin: + raise KciDevError("origin is required") + if not giturl or not commit: + raise KciDevError("giturl and commit are required") + + branch = branch or "" + patchset_hash = patchset_hash or "" + start_time = start_time or datetime.now(timezone.utc).isoformat() + + checkout_id = generate_checkout_id( + origin, giturl, branch, commit, patchset_hash + ) + build_id = generate_build_id( + origin, + checkout_id, + arch or "", + config_name or "", + compiler or "", + start_time, + ) + checkout = build_checkout_payload( + origin, + checkout_id, + tree_name=tree_name, + git_repository_url=giturl, + git_repository_branch=branch if branch else None, + git_commit_hash=commit, + patchset_hash=patchset_hash if patchset_hash else None, + ) + build = build_build_payload( + origin, + build_id, + checkout_id, + start_time=start_time, + duration=duration, + architecture=arch, + compiler=compiler, + config_name=config_name, + config_url=config_url, + log_url=log_url, + comment=comment, + command=command, + status=status, + ) + return build_submission_payload([checkout], [build]) + + def submit_kcidb(self, payload, timeout=60): + """Submit a KCIDB payload and return the API response.""" + rest_url, token = self.resolve_kcidb_config() + return _as_library_error( + "KCIDB submission failed", + submit_to_kcidb, + rest_url, + token, + payload, + timeout, + ) + + def submit_build(self, **kwargs): + """Build and submit a KCIDB build payload in one call.""" + return self.submit_kcidb(self.build_kcidb_build_submission(**kwargs)) + + def get_summary(self, origin, giturl, branch, commit, arch=None): + return _as_library_error( + "Dashboard summary request failed", + dashboard_fetch_summary, + origin, + giturl, + branch, + commit, + arch, + True, + ) + + def get_builds( + self, + origin, + giturl, + branch, + commit, + arch=None, + tree=None, + start_date=None, + end_date=None, + ): + return _as_library_error( + "Dashboard builds request failed", + dashboard_fetch_builds, + origin, + giturl, + branch, + commit, + arch, + tree, + start_date, + end_date, + True, + ) + + def get_boots( + self, + origin, + giturl, + branch, + commit, + arch=None, + tree=None, + start_date=None, + end_date=None, + boot_origin=None, + ): + return _as_library_error( + "Dashboard boots request failed", + dashboard_fetch_boots, + origin, + giturl, + branch, + commit, + arch, + tree, + start_date, + end_date, + True, + boot_origin, + ) + + def get_tests( + self, + origin, + giturl, + branch, + commit, + arch=None, + tree=None, + start_date=None, + end_date=None, + ): + return _as_library_error( + "Dashboard tests request failed", + dashboard_fetch_tests, + origin, + giturl, + branch, + commit, + arch, + tree, + start_date, + end_date, + True, + ) + + def get_commits_history(self, origin, giturl, branch, commit): + return _as_library_error( + "Dashboard history request failed", + dashboard_fetch_commits_history, + origin, + giturl, + branch, + commit, + True, + ) + + def get_build(self, build_id): + return _as_library_error( + "Dashboard build request failed", dashboard_fetch_build, build_id, True + ) + + def get_test(self, test_id): + return _as_library_error( + "Dashboard test request failed", dashboard_fetch_test, test_id, True + ) + + def get_tree_list(self, origin, days=7): + return _as_library_error( + "Dashboard tree list request failed", + dashboard_fetch_tree_list, + origin, + True, + days, + ) + + def get_hardware_list(self, origin): + return _as_library_error( + "Dashboard hardware list request failed", + dashboard_fetch_hardware_list, + origin, + True, + ) + + def get_hardware_summary(self, name, origin): + return _as_library_error( + "Dashboard hardware summary request failed", + dashboard_fetch_hardware_summary, + name, + origin, + True, + ) + + def get_hardware_boots(self, name, origin): + return _as_library_error( + "Dashboard hardware boots request failed", + dashboard_fetch_hardware_boots, + name, + origin, + True, + ) + + def get_hardware_builds(self, name, origin): + return _as_library_error( + "Dashboard hardware builds request failed", + dashboard_fetch_hardware_builds, + name, + origin, + True, + ) + + def get_hardware_tests(self, name, origin): + return _as_library_error( + "Dashboard hardware tests request failed", + dashboard_fetch_hardware_tests, + name, + origin, + True, + ) + + def get_build_issues(self, build_id): + return _as_library_error( + "Dashboard build issues request failed", + dashboard_fetch_build_issues, + build_id, + True, + True, + ) + + def get_boot_issues(self, test_id): + return _as_library_error( + "Dashboard boot issues request failed", + dashboard_fetch_boot_issues, + test_id, + True, + True, + ) + + def get_issue_list(self, origin=None, days=7): + return _as_library_error( + "Dashboard issue list request failed", + dashboard_fetch_issue_list, + origin, + days, + True, + ) + + def get_issue(self, issue_id): + return _as_library_error( + "Dashboard issue request failed", dashboard_fetch_issue, issue_id, True + ) + + def get_issue_builds(self, issue_id, origin=None): + return _as_library_error( + "Dashboard issue builds request failed", + dashboard_fetch_issue_builds, + origin, + issue_id, + True, + ) + + def get_issue_tests(self, issue_id, origin=None): + return _as_library_error( + "Dashboard issue tests request failed", + dashboard_fetch_issue_tests, + origin, + issue_id, + True, + ) + + def get_issues_extra(self, issues): + return _as_library_error( + "Dashboard issues extra request failed", + dashboard_fetch_issues_extra, + issues, + True, + ) + + def get_tree_report( + self, + origin, + git_branch, + git_url, + test_path=None, + history_size=10, + max_age_in_hours=None, + min_age_in_hours=None, + ): + return _as_library_error( + "Dashboard tree report request failed", + dashboard_fetch_tree_report, + origin, + git_branch, + git_url, + True, + test_path or [], + history_size, + max_age_in_hours, + min_age_in_hours, + ) diff --git a/tests/test_kcidb.py b/tests/test_kcidb.py index 1ab871e..9c4b729 100644 --- a/tests/test_kcidb.py +++ b/tests/test_kcidb.py @@ -241,3 +241,41 @@ def test_cli_overrides_env(self, monkeypatch): ) assert url == "https://cli-host/submit" assert token == "cli-token" + + +class TestKernelCIClientApi: + def test_import_public_client(self): + from kcidev import KciDevError, KernelCIClient + + client = KernelCIClient() + assert callable(client.build_kcidb_build_submission) + assert issubclass(KciDevError, RuntimeError) + + def test_build_submission_payload_matches_cli_helpers(self): + from kcidev import KernelCIClient + + client = KernelCIClient() + payload = client.build_kcidb_build_submission( + origin="myci", + giturl="https://repo.git", + branch="main", + commit="abc123", + arch="x86_64", + config_name="defconfig", + compiler="gcc-14", + status="PASS", + start_time="2024-01-01T00:00:00+00:00", + ) + + assert payload["version"] == {"major": 5, "minor": 3} + assert payload["checkouts"][0]["origin"] == "myci" + assert payload["checkouts"][0]["git_repository_url"] == "https://repo.git" + assert payload["builds"][0]["checkout_id"] == payload["checkouts"][0]["id"] + assert payload["builds"][0]["status"] == "PASS" + + def test_library_validation_raises_library_error(self): + from kcidev import KciDevError, KernelCIClient + + client = KernelCIClient() + with pytest.raises(KciDevError): + client.build_kcidb_build_submission(origin="myci", commit="abc123") diff --git a/tox.ini b/tox.ini index 9d92751..01c3566 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,6 @@ [tox] env_list = py312,py313 +skip_missing_interpreters = true [testenv] allowlist_externals = poetry