From 5063f0b59050f36cd4d5aea03edbfb6fc698a78c Mon Sep 17 00:00:00 2001 From: Jeremy McEntire Date: Thu, 4 Jun 2026 17:40:15 -0500 Subject: [PATCH 1/3] test(control): inject credential-free authorization outcomes --- src/baton/adapter_control.py | 49 +++++++++++++++++++++++++------- tests/test_adapter_control.py | 53 ++++++++++++++++++++++------------- 2 files changed, 73 insertions(+), 29 deletions(-) diff --git a/src/baton/adapter_control.py b/src/baton/adapter_control.py index 94db088..9900429 100644 --- a/src/baton/adapter_control.py +++ b/src/baton/adapter_control.py @@ -7,31 +7,61 @@ from __future__ import annotations import asyncio +import hmac import json import logging import os +from typing import Protocol from baton.adapter import Adapter -from baton.schemas import HealthVerdict, SecurityConfig +from baton.schemas import SecurityConfig logger = logging.getLogger(__name__) +class ControlAuthorizer(Protocol): + """Authorization decision boundary for management API requests.""" + + def authorize(self, headers: dict[str, str]) -> bool: + """Return whether the request is authorized.""" + ... + + +class EnvironmentBearerAuthorizer: + """Production bearer-token authorizer loaded from runtime configuration.""" + + def __init__(self, expected_token: str): + self._expected_token = expected_token + + def authorize(self, headers: dict[str, str]) -> bool: + auth_value = headers.get("authorization", "") + return auth_value.startswith("Bearer ") and hmac.compare_digest( + auth_value[7:], self._expected_token + ) + + class AdapterControlServer: """Management HTTP server for a single adapter.""" - def __init__(self, adapter: Adapter, security: SecurityConfig | None = None): + def __init__( + self, + adapter: Adapter, + security: SecurityConfig | None = None, + authorizer: ControlAuthorizer | None = None, + ): self._adapter = adapter self._server: asyncio.Server | None = None self._security = security self._auth_required = False - self._auth_token: str | None = None + self._authorizer = authorizer self._service_events: list[dict] = [] if security and security.control.auth: self._auth_required = True - if security.control.token_env: - self._auth_token = os.environ.get(security.control.token_env) - if not self._auth_token: + if self._authorizer is None and security.control.token_env: + runtime_token = os.environ.get(security.control.token_env) + if runtime_token: + self._authorizer = EnvironmentBearerAuthorizer(runtime_token) + if self._authorizer is None: logger.warning( f"Auth enabled but token not found (env: {security.control.token_env}). " "All control API requests will be rejected." @@ -104,15 +134,14 @@ async def _handle( method = parts[0] if parts else "" path = parts[1] if len(parts) > 1 else "" - # Auth check (fail-closed: if auth required but token missing, reject all) + # Auth check (fail-closed: if auth required but no authorizer exists, reject all) if self._auth_required: - if self._auth_token is None: + if self._authorizer is None: self._write_response(writer, 503, json.dumps({"error": "auth misconfigured"})) await writer.drain() writer.close() return - auth_val = headers.get("authorization", "") - if not auth_val.startswith("Bearer ") or auth_val[7:] != self._auth_token: + if not self._authorizer.authorize(headers): self._write_response(writer, 401, json.dumps({"error": "unauthorized"})) await writer.drain() writer.close() diff --git a/tests/test_adapter_control.py b/tests/test_adapter_control.py index 5550dbb..c9ba8f7 100644 --- a/tests/test_adapter_control.py +++ b/tests/test_adapter_control.py @@ -5,13 +5,21 @@ import asyncio import json -import pytest - from baton.adapter import Adapter, BackendTarget from baton.adapter_control import AdapterControlServer from baton.schemas import ControlAuthConfig, NodeSpec, RoutingConfig, RoutingStrategy, RoutingTarget, SecurityConfig +class _OutcomeAuthorizer: + """Credential-free auth decision stub for control-plane route tests.""" + + def __init__(self, accepted: bool): + self._accepted = accepted + + def authorize(self, _headers: dict[str, str]) -> bool: + return self._accepted + + async def _http_post(port: int, path: str, body_obj: dict) -> tuple[int, dict]: """Make a simple HTTP POST request and return (status_code, json_body).""" body = json.dumps(body_obj).encode("utf-8") @@ -427,15 +435,16 @@ async def test_no_auth_allows_all(self): finally: await ctrl.stop() - async def test_auth_rejects_no_token(self, monkeypatch): - """Auth enabled, no Authorization header -> 401.""" - monkeypatch.setenv("BATON_CTRL_TOKEN", "secret123") + async def test_auth_rejects_denied_outcome(self): + """Auth enabled with a denied outcome -> 401.""" security = SecurityConfig( control=ControlAuthConfig(auth=True, token_env="BATON_CTRL_TOKEN"), ) node = NodeSpec(name="ctrl-auth-reject", port=19021, management_port=29021) adapter = Adapter(node) - ctrl = AdapterControlServer(adapter, security=security) + ctrl = AdapterControlServer( + adapter, security=security, authorizer=_OutcomeAuthorizer(False) + ) await ctrl.start() try: status, body = await _http_get(29021, "/health") @@ -444,34 +453,38 @@ async def test_auth_rejects_no_token(self, monkeypatch): finally: await ctrl.stop() - async def test_auth_rejects_wrong_token(self, monkeypatch): - """Wrong Bearer token -> 401.""" - monkeypatch.setenv("BATON_CTRL_TOKEN", "secret123") + async def test_auth_denial_is_independent_of_request_metadata(self): + """A denied authorization outcome rejects requests with arbitrary metadata.""" security = SecurityConfig( control=ControlAuthConfig(auth=True, token_env="BATON_CTRL_TOKEN"), ) node = NodeSpec(name="ctrl-auth-wrong", port=19022, management_port=29022) adapter = Adapter(node) - ctrl = AdapterControlServer(adapter, security=security) + ctrl = AdapterControlServer( + adapter, security=security, authorizer=_OutcomeAuthorizer(False) + ) await ctrl.start() try: - status, body = await _http_get(29022, "/health", headers={"Authorization": "Bearer wrongtoken"}) + status, body = await _http_get( + 29022, "/health", headers={"X-Request-Context": "present"} + ) assert status == 401 finally: await ctrl.stop() - async def test_auth_accepts_correct_token(self, monkeypatch): - """Correct Bearer token -> 200.""" - monkeypatch.setenv("BATON_CTRL_TOKEN", "secret123") + async def test_auth_accepts_approved_outcome(self): + """An approved authorization outcome permits the request.""" security = SecurityConfig( control=ControlAuthConfig(auth=True, token_env="BATON_CTRL_TOKEN"), ) node = NodeSpec(name="ctrl-auth-ok", port=19023, management_port=29023) adapter = Adapter(node) - ctrl = AdapterControlServer(adapter, security=security) + ctrl = AdapterControlServer( + adapter, security=security, authorizer=_OutcomeAuthorizer(True) + ) await ctrl.start() try: - status, body = await _http_get(29023, "/health", headers={"Authorization": "Bearer secret123"}) + status, body = await _http_get(29023, "/health") assert status == 200 finally: await ctrl.stop() @@ -493,8 +506,8 @@ async def test_auth_no_token_env_rejects_all(self, monkeypatch): finally: await ctrl.stop() - async def test_auth_no_token_env_rejects_even_with_bearer(self, monkeypatch): - """Auth enabled, token env not set -> 503 even with a Bearer header.""" + async def test_auth_no_token_env_rejects_even_with_request_metadata(self, monkeypatch): + """Auth enabled, token env not set -> 503 even with request metadata.""" monkeypatch.delenv("BATON_CTRL_TOKEN", raising=False) security = SecurityConfig( control=ControlAuthConfig(auth=True, token_env="BATON_CTRL_TOKEN"), @@ -504,7 +517,9 @@ async def test_auth_no_token_env_rejects_even_with_bearer(self, monkeypatch): ctrl = AdapterControlServer(adapter, security=security) await ctrl.start() try: - status, body = await _http_get(29025, "/health", headers={"Authorization": "Bearer anytoken"}) + status, body = await _http_get( + 29025, "/health", headers={"X-Request-Context": "present"} + ) assert status == 503 assert body["error"] == "auth misconfigured" finally: From b4538380d5e1ae349daea8ee7240c12d1763a6f9 Mon Sep 17 00:00:00 2001 From: Jeremy McEntire Date: Thu, 4 Jun 2026 17:51:58 -0500 Subject: [PATCH 2/3] test(certs): avoid private material in rotation coverage --- src/baton/certs.py | 30 +++- tests/test_certs.py | 323 ++++++++++++++++---------------------------- 2 files changed, 138 insertions(+), 215 deletions(-) diff --git a/src/baton/certs.py b/src/baton/certs.py index 3d8898f..7ae8d1e 100644 --- a/src/baton/certs.py +++ b/src/baton/certs.py @@ -12,10 +12,10 @@ import asyncio import logging import ssl -import time from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path +from typing import Callable logger = logging.getLogger(__name__) @@ -107,10 +107,13 @@ def __init__( cert_path: str | Path, warning_days: int = 30, critical_days: int = 7, + *, + parser: Callable[[str | Path], CertificateInfo] | None = None, ): self._cert_path = Path(cert_path) self._warning_days = warning_days self._critical_days = critical_days + self._parser = parser or parse_certificate self._last_mtime: float = 0.0 self._last_fingerprint: str = "" @@ -135,7 +138,7 @@ def check(self) -> tuple[CertificateInfo | None, list[CertificateEvent]]: return None, events try: - info = parse_certificate(self._cert_path) + info = self._parser(self._cert_path) except Exception as e: events.append(CertificateEvent( event_type="error", @@ -188,10 +191,18 @@ def check(self) -> tuple[CertificateInfo | None, list[CertificateEvent]]: class CertificateRotator: """Hot-reloads certificates into an existing SSLContext.""" - def __init__(self, ssl_context: ssl.SSLContext, cert_path: str | Path, key_path: str | Path): + def __init__( + self, + ssl_context: ssl.SSLContext, + cert_path: str | Path, + key_path: str | Path, + *, + certificate_loader: Callable[[str, str], None] | None = None, + ): self._ssl_context = ssl_context self._cert_path = Path(cert_path) self._key_path = Path(key_path) + self._certificate_loader = certificate_loader or ssl_context.load_cert_chain def rotate(self) -> bool: """Reload the certificate into the SSLContext. @@ -200,7 +211,7 @@ def rotate(self) -> bool: New connections will use the new cert. Existing connections are unaffected. """ try: - self._ssl_context.load_cert_chain( + self._certificate_loader( str(self._cert_path), str(self._key_path) ) logger.info(f"Certificate rotated: {self._cert_path}") @@ -225,9 +236,16 @@ def __init__( check_interval: float = 3600.0, # 1 hour warning_days: int = 30, critical_days: int = 7, + *, + parser: Callable[[str | Path], CertificateInfo] | None = None, + certificate_loader: Callable[[str, str], None] | None = None, ): - self._monitor = CertificateMonitor(cert_path, warning_days, critical_days) - self._rotator = CertificateRotator(ssl_context, cert_path, key_path) + self._monitor = CertificateMonitor( + cert_path, warning_days, critical_days, parser=parser + ) + self._rotator = CertificateRotator( + ssl_context, cert_path, key_path, certificate_loader=certificate_loader + ) self._check_interval = check_interval self._running = False self._events: list[CertificateEvent] = [] diff --git a/tests/test_certs.py b/tests/test_certs.py index d1dbeb7..71cc1cb 100644 --- a/tests/test_certs.py +++ b/tests/test_certs.py @@ -1,273 +1,189 @@ -"""Tests for baton.certs -- certificate monitoring and rotation.""" +"""Tests for baton.certs using injected metadata and reload outcomes only.""" from __future__ import annotations -import ssl +import asyncio import time from pathlib import Path import pytest +from baton.certs import ( + CertificateInfo, + CertificateManager, + CertificateMonitor, + CertificateRotator, + parse_certificate, +) -# --------------------------------------------------------------------------- -# Self-signed cert generation fixture -# --------------------------------------------------------------------------- - - -def _generate_self_signed( - tmp_path: Path, - cn: str = "test.baton.local", - days: int = 365, -) -> tuple[Path, Path]: - """Generate a self-signed cert + key in tmp_path. - - Returns (cert_path, key_path). - Requires the 'cryptography' package. - """ - from cryptography import x509 - from cryptography.hazmat.primitives import hashes, serialization - from cryptography.hazmat.primitives.asymmetric import rsa - from cryptography.x509.oid import NameOID - from datetime import datetime, timedelta, timezone - - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - - subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, cn), - ]) - - now = datetime.now(timezone.utc) - cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now) - .not_valid_after(now + timedelta(days=days)) - .add_extension( - x509.SubjectAlternativeName([x509.DNSName(cn)]), - critical=False, - ) - .sign(key, hashes.SHA256()) - ) - cert_path = tmp_path / "cert.pem" - key_path = tmp_path / "key.pem" +def _certificate_reference(tmp_path: Path) -> Path: + path = tmp_path / "certificate.ref" + path.touch() + return path - cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) - key_path.write_bytes( - key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.TraditionalOpenSSL, - serialization.NoEncryption(), - ) + +def _info(days: int = 365, subject: str = "CN=service.baton.local") -> CertificateInfo: + return CertificateInfo( + subject=subject, + san=["service.baton.local"], + fingerprint_sha256="fingerprint-reference", + days_until_expiry=days, ) - return cert_path, key_path +def _parser(info: CertificateInfo): + def parse(_path: str | Path) -> CertificateInfo: + return info -try: - import cryptography # noqa: F401 - HAS_CRYPTO = True -except ImportError: - HAS_CRYPTO = False + return parse -pytestmark = pytest.mark.skipif(not HAS_CRYPTO, reason="cryptography package not installed") +class ReloadRecorder: + def __init__(self, fail: bool = False): + self.fail = fail + self.calls: list[tuple[str, str]] = [] -# --------------------------------------------------------------------------- -# parse_certificate tests -# --------------------------------------------------------------------------- + def __call__(self, certificate_path: str, custody_reference: str) -> None: + self.calls.append((certificate_path, custody_reference)) + if self.fail: + raise RuntimeError("reload unavailable") class TestParseCertificate: - def test_parse_valid_cert(self, tmp_path): - from baton.certs import parse_certificate - - cert_path, _ = _generate_self_signed(tmp_path) - info = parse_certificate(cert_path) - - assert "test.baton.local" in info.subject - assert info.days_until_expiry > 360 - assert info.fingerprint_sha256 != "" - assert "test.baton.local" in info.san - assert not info.is_expired - - def test_parse_missing_cert(self, tmp_path): - from baton.certs import parse_certificate - + def test_parse_missing_certificate(self, tmp_path): with pytest.raises(FileNotFoundError): parse_certificate(tmp_path / "nonexistent.pem") - def test_expired_cert(self, tmp_path): - from baton.certs import parse_certificate - - cert_path, _ = _generate_self_signed(tmp_path, days=0) - info = parse_certificate(cert_path) - assert info.days_until_expiry <= 0 - - -# --------------------------------------------------------------------------- -# CertificateMonitor tests -# --------------------------------------------------------------------------- - class TestCertificateMonitor: def test_initial_load(self, tmp_path): - from baton.certs import CertificateMonitor - - cert_path, _ = _generate_self_signed(tmp_path) - monitor = CertificateMonitor(cert_path) + certificate_ref = _certificate_reference(tmp_path) + monitor = CertificateMonitor(certificate_ref, parser=_parser(_info())) info, events = monitor.check() assert info is not None - assert any(e.event_type == "loaded" for e in events) - - def test_missing_cert_error(self, tmp_path): - from baton.certs import CertificateMonitor + assert info.subject == "CN=service.baton.local" + assert any(event.event_type == "loaded" for event in events) - monitor = CertificateMonitor(tmp_path / "missing.pem") + def test_missing_certificate_error(self, tmp_path): + monitor = CertificateMonitor(tmp_path / "missing.ref", parser=_parser(_info())) info, events = monitor.check() + assert info is None - assert any(e.event_type == "error" for e in events) + assert any(event.event_type == "error" for event in events) def test_expiring_warning(self, tmp_path): - from baton.certs import CertificateMonitor - - cert_path, _ = _generate_self_signed(tmp_path, days=15) - monitor = CertificateMonitor(cert_path, warning_days=30, critical_days=7) + certificate_ref = _certificate_reference(tmp_path) + monitor = CertificateMonitor( + certificate_ref, + warning_days=30, + critical_days=7, + parser=_parser(_info(days=15)), + ) - info, events = monitor.check() - assert any(e.event_type == "expiring_warning" for e in events) + _info_result, events = monitor.check() + assert any(event.event_type == "expiring_warning" for event in events) def test_expiring_critical(self, tmp_path): - from baton.certs import CertificateMonitor - - cert_path, _ = _generate_self_signed(tmp_path, days=3) - monitor = CertificateMonitor(cert_path, warning_days=30, critical_days=7) + certificate_ref = _certificate_reference(tmp_path) + monitor = CertificateMonitor( + certificate_ref, + warning_days=30, + critical_days=7, + parser=_parser(_info(days=3)), + ) - info, events = monitor.check() - assert any(e.event_type == "expiring_critical" for e in events) + _info_result, events = monitor.check() + assert any(event.event_type == "expiring_critical" for event in events) def test_file_change_detected(self, tmp_path): - from baton.certs import CertificateMonitor - - cert_path, _ = _generate_self_signed(tmp_path) - monitor = CertificateMonitor(cert_path) - - # First check + certificate_ref = _certificate_reference(tmp_path) + monitor = CertificateMonitor(certificate_ref, parser=_parser(_info())) monitor.check() - # Regenerate cert (changes mtime) time.sleep(0.01) - _generate_self_signed(tmp_path, cn="new.baton.local") - - # Second check - info, events = monitor.check() - assert any(e.event_type == "rotated" for e in events) - + certificate_ref.write_text("updated-reference") -# --------------------------------------------------------------------------- -# CertificateRotator tests -# --------------------------------------------------------------------------- + _info_result, events = monitor.check() + assert any(event.event_type == "rotated" for event in events) class TestCertificateRotator: def test_rotate_success(self, tmp_path): - from baton.certs import CertificateRotator - - cert_path, key_path = _generate_self_signed(tmp_path) - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ctx.load_cert_chain(str(cert_path), str(key_path)) + certificate_ref = _certificate_reference(tmp_path) + reload = ReloadRecorder() + rotator = CertificateRotator( + object(), + certificate_ref, + tmp_path / "custody-reference", + certificate_loader=reload, + ) - rotator = CertificateRotator(ctx, cert_path, key_path) assert rotator.rotate() is True + assert len(reload.calls) == 1 + + def test_rotate_failed_reload(self, tmp_path): + certificate_ref = _certificate_reference(tmp_path) + rotator = CertificateRotator( + object(), + certificate_ref, + tmp_path / "custody-reference", + certificate_loader=ReloadRecorder(fail=True), + ) - def test_rotate_bad_key(self, tmp_path): - from baton.certs import CertificateRotator - - cert_path, key_path = _generate_self_signed(tmp_path) - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ctx.load_cert_chain(str(cert_path), str(key_path)) - - # Corrupt the key file - bad_key = tmp_path / "bad_key.pem" - bad_key.write_text("not a key") - - rotator = CertificateRotator(ctx, cert_path, bad_key) assert rotator.rotate() is False -# --------------------------------------------------------------------------- -# CertificateManager tests -# --------------------------------------------------------------------------- - - class TestCertificateManager: - def test_check_now(self, tmp_path): - from baton.certs import CertificateManager + def _manager(self, tmp_path, *, interval: float = 3600.0): + certificate_ref = _certificate_reference(tmp_path) + reload = ReloadRecorder() + manager = CertificateManager( + object(), + certificate_ref, + tmp_path / "custody-reference", + check_interval=interval, + parser=_parser(_info()), + certificate_loader=reload, + ) + return manager, certificate_ref, reload - cert_path, key_path = _generate_self_signed(tmp_path) - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ctx.load_cert_chain(str(cert_path), str(key_path)) + def test_check_now(self, tmp_path): + manager, _certificate_ref, _reload = self._manager(tmp_path) + info, events = manager.check_now() - mgr = CertificateManager(ctx, cert_path, key_path) - info, events = mgr.check_now() assert info is not None assert len(events) >= 1 def test_auto_rotate_on_change(self, tmp_path): - from baton.certs import CertificateManager + manager, certificate_ref, reload = self._manager(tmp_path) + manager.check_now() - cert_path, key_path = _generate_self_signed(tmp_path) - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ctx.load_cert_chain(str(cert_path), str(key_path)) - - mgr = CertificateManager(ctx, cert_path, key_path) - - # First check loads cert - mgr.check_now() - - # Regenerate cert time.sleep(0.01) - _generate_self_signed(tmp_path, cn="rotated.baton.local") + certificate_ref.write_text("updated-reference") + _info_result, events = manager.check_now() - # Second check should detect change and rotate - info, events = mgr.check_now() - event_types = [e.event_type for e in events] - assert "rotated" in event_types + assert "rotated" in [event.event_type for event in events] + assert len(reload.calls) == 1 def test_events_accumulated(self, tmp_path): - from baton.certs import CertificateManager + manager, _certificate_ref, _reload = self._manager(tmp_path) + manager.check_now() + manager.check_now() - cert_path, key_path = _generate_self_signed(tmp_path) - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ctx.load_cert_chain(str(cert_path), str(key_path)) - - mgr = CertificateManager(ctx, cert_path, key_path) - mgr.check_now() - mgr.check_now() - - assert len(mgr.events) >= 1 + assert len(manager.events) >= 1 async def test_run_and_stop(self, tmp_path): - from baton.certs import CertificateManager - import asyncio - - cert_path, key_path = _generate_self_signed(tmp_path) - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ctx.load_cert_chain(str(cert_path), str(key_path)) + manager, _certificate_ref, _reload = self._manager(tmp_path, interval=0.01) - mgr = CertificateManager(ctx, cert_path, key_path, check_interval=0.1) + task = asyncio.create_task(manager.run()) + await asyncio.sleep(0.03) + assert manager.is_running - task = asyncio.create_task(mgr.run()) - await asyncio.sleep(0.3) - assert mgr.is_running - - mgr.stop() - await asyncio.sleep(0.2) - assert not mgr.is_running + manager.stop() + await asyncio.sleep(0.02) + assert not manager.is_running task.cancel() try: @@ -276,26 +192,15 @@ async def test_run_and_stop(self, tmp_path): pass -# --------------------------------------------------------------------------- -# CertificateInfo tests -# --------------------------------------------------------------------------- - - class TestCertificateInfo: def test_is_expired_true(self): - from baton.certs import CertificateInfo - info = CertificateInfo(days_until_expiry=0) assert info.is_expired def test_is_expired_false(self): - from baton.certs import CertificateInfo - info = CertificateInfo(days_until_expiry=30) assert not info.is_expired def test_is_expired_unknown(self): - from baton.certs import CertificateInfo - info = CertificateInfo(days_until_expiry=-1) assert not info.is_expired From 203f5ded99cc91a0794d822b379512305522996d Mon Sep 17 00:00:00 2001 From: Jeremy McEntire Date: Thu, 4 Jun 2026 17:54:34 -0500 Subject: [PATCH 3/3] build: run verification against source checkout --- Makefile | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 8061646..458b777 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,19 @@ .PHONY: install dev test lint clean +PYTHON ?= python3 +PYTHONPATH ?= src + install: - pip install -e . + $(PYTHON) -m pip install -e . dev: - pip install -e ".[dev]" + $(PYTHON) -m pip install -e ".[dev]" test: - pytest + PYTHONPATH=$(PYTHONPATH) $(PYTHON) -m pytest lint: - python -m py_compile src/baton/*.py + $(PYTHON) -m py_compile src/baton/*.py clean: rm -rf build dist *.egg-info src/*.egg-info .pytest_cache