From 03cdccac7c1df9adb4d7a245fbbbe8d7b8905932 Mon Sep 17 00:00:00 2001 From: Pedro Cunha Date: Mon, 21 Sep 2026 20:33:31 -0300 Subject: [PATCH 1/8] Resolve the private logs API entirely inside its guard AGENTS.md and the 0.1.0 changelog entry both promise that a release moving opentelemetry._logs costs the logs signal and nothing else: import guideme still works and traces are untouched. The guard was narrower than that promise. It covered the deferred import; SeverityNumber.INFO, SeverityNumber .WARN and both get_logger calls sat outside it, at module scope through LOGS = resolve_logs(). A module that still imports and has lost a member therefore killed import guideme with an AttributeError, traces included. Logs._emit had the matching hole. It wrapped the private LogRecord constructor and logger.emit in one except Exception: pass, while its docstring scoped the swallow to a sink that raises. A LogRecord that no longer takes event_name then produced no record, no span event under events("log"), and no error: the records that silently go nowhere the refusal in GuideBuilder.events exists to prevent. Every read of the private API now happens inside the one guard, which catches ImportError, AttributeError and TypeError, and resolve_logs builds one throwaway record with the five keywords _emit uses, so a constructor change is found where absence is already representable. _emit builds its record before the try, leaving the swallow over logger.emit alone. --- src/guideme/telemetry.py | 61 +++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/src/guideme/telemetry.py b/src/guideme/telemetry.py index 11fa9f5..845e1d5 100644 --- a/src/guideme/telemetry.py +++ b/src/guideme/telemetry.py @@ -139,17 +139,21 @@ def _emit( must still return it, and a span that says the ask succeeded must not be contradicted by a logging failure. The failure belongs to the application's own logging pipeline, which is where it is visible. + + The record is built outside that guard so the swallow covers only what the + docstring scopes it to. A `LogRecord` this package cannot construct is not a + sink failure, it is the private API having moved, and it is caught once in + `resolve_logs` where absence is representable and says so loudly. """ + record = self.build( + event_name=name, + severity_text=severity.name, + severity_number=severity, + body=body, + attributes=attributes, + ) try: # noqa: SIM105 -- an explicit `except Exception` is what AGENTS.md audits for - logger.emit( - self.build( - event_name=name, - severity_text=severity.name, - severity_number=severity, - body=body, - attributes=attributes, - ) - ) + logger.emit(record) except Exception: # noqa: BLE001, S110 -- a sink's failure is not the ask's # pylint: disable=broad-exception-caught pass @@ -160,8 +164,16 @@ def resolve_logs() -> Logs | None: The logs API is private. `opentelemetry-api` keeps it at `opentelemetry._logs` with no public alias, and this package depends on a range wide enough for a future release to move it. Imported at module scope, such a release would break `import guideme` outright - for every user, traces included; imported here, it costs the logs signal alone. Only - `ImportError` is caught, so anything else that package raises is still a failure. + for every user, traces included; imported here, it costs the logs signal alone. + + Everything this package needs from that API is reached inside the one guard, because + the promise is about the signal and not about the import: a release that keeps the + module and moves what is in it must cost no more than a release that deletes it. + Three failures are expected and each is the same fact, that this `opentelemetry-api` + provides no logs API guideme can use. `ImportError` is the module or a name being + gone; `AttributeError` is a severity member renamed; `TypeError` is `get_logger` or + `LogRecord` taking different arguments. Anything else that package raises is still a + failure and still propagates. """ # pylint: disable=import-outside-toplevel; the one import here is deferred on purpose try: @@ -170,16 +182,25 @@ def resolve_logs() -> Logs | None: SeverityNumber, get_logger, ) - except ImportError: + + info = SeverityNumber.INFO + warn = SeverityNumber.WARN + # `get_logger` takes the version as a string, where `get_tracer` takes an optional one. + answers = get_logger("guideme", _VERSION or "") + retries = get_logger("guideme.api", _VERSION or "") + # One throwaway record, built with exactly the five keywords `Logs._emit` uses, so a + # constructor that no longer takes them is found here rather than at the first emit. + # `_emit` swallows a sink that raises; it must never be what hides this. + _ = LogRecord( + event_name=ANSWER_EVENT, + severity_text=info.name, + severity_number=info, + body="", + attributes={}, + ) + except (ImportError, AttributeError, TypeError): return None - # `get_logger` takes the version as a string, where `get_tracer` takes an optional one. - return Logs( - answers=get_logger("guideme", _VERSION or ""), - retries=get_logger("guideme.api", _VERSION or ""), - build=LogRecord, - info=SeverityNumber.INFO, - warn=SeverityNumber.WARN, - ) + return Logs(answers=answers, retries=retries, build=LogRecord, info=info, warn=warn) LOGS = resolve_logs() From bd0b56677ba908e2baecb9deb2819e6fdc1437d9 Mon Sep 17 00:00:00 2001 From: Pedro Cunha Date: Mon, 21 Sep 2026 20:33:40 -0300 Subject: [PATCH 2/8] Cover the logs drift, an unknown mode and a cross-origin hop Three gaps, all filled as parameters of tests that already existed, so the 43-function ceiling holds. A LogRecord whose constructor moved is the drift ImportError cannot see, and it is what used to make an ask emit nothing and say nothing. The new case replaces LogRecord on the module, because resolve_logs reads the name off it every call, and asserts the signal goes absent so events("log") refuses. guide.py refuses an events(...) value outside span, log and both, and three documents promise it, but nothing tested it. Events is a Literal, so the case casts to reach the runtime guard, which is where a caller with no type checker stands. api/client.py rests on httpx dropping the authorization header when a redirect changes origin, and only the same-origin case was covered. A second HTTPServer on its own port makes the hop cross origins, and the case asserts the key reaches none of the second server's headers. --- tests/conftest.py | 42 +++++++++++++++++++++- tests/test_wire.py | 87 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 113 insertions(+), 16 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b9edcc8..06ca0fc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ from hypothesis import HealthCheck, settings from jsonschema import Draft202012Validator from opentelemetry import trace -from opentelemetry._logs import set_logger_provider +from opentelemetry._logs import LogRecord, set_logger_provider from opentelemetry.sdk._logs import LoggerProvider, ReadableLogRecord from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter, SimpleLogRecordProcessor from opentelemetry.sdk.trace import Event, ReadableSpan, TracerProvider @@ -406,6 +406,46 @@ def without_the_logs_api(monkeypatch: pytest.MonkeyPatch) -> None: assert telemetry.LOGS is None +@final +class DriftedLogRecord(LogRecord): + """A `LogRecord` from a release that stopped taking `event_name`. + + The module still imports and every name is still there; only the constructor moved. + That is the shape of drift `ImportError` cannot see. + """ + + def __init__(self, **fields: object) -> None: + if "event_name" in fields: + message = "LogRecord.__init__() got an unexpected keyword argument 'event_name'" + raise TypeError(message) + super().__init__() + + +def with_a_drifted_log_record(monkeypatch: pytest.MonkeyPatch) -> None: + """Make guideme see an `opentelemetry-api` whose `LogRecord` it cannot build, for one test. + + `resolve_logs` imports the name off the module every call, so replacing the attribute + is what a release moving the constructor would look like from inside this package. The + signal has to go absent here: a record guideme cannot construct reaches no exporter, + and `Logs._emit` must not be the thing that discovers it. + """ + monkeypatch.setattr(f"{telemetry.LOGS_MODULE}.LogRecord", DriftedLogRecord) + monkeypatch.setattr(telemetry, "LOGS", telemetry.resolve_logs()) + assert telemetry.LOGS is None + + +@pytest.fixture +def other_httpserver() -> Iterator[HTTPServer]: + """A second local server, on its own port, so a redirect can leave the first origin.""" + server = HTTPServer(host="127.0.0.1", port=0) + server.start() + try: + yield server + finally: + server.clear() + server.stop() + + @final @dataclass(frozen=True, slots=True) class Recorded: diff --git a/tests/test_wire.py b/tests/test_wire.py index fc32cec..3898d92 100644 --- a/tests/test_wire.py +++ b/tests/test_wire.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta -from typing import final +from typing import cast, final import pytest from hypothesis import given @@ -44,7 +44,7 @@ from guideme.ask import Plan, encode from guideme.guide import KEY_VAR, ModelInfo from guideme.question import Question, choose_among, noul, score_levels -from guideme.telemetry import ASK_SPAN +from guideme.telemetry import ASK_SPAN, Events from .conftest import ( FIXTURES, @@ -68,6 +68,7 @@ noul_reply, reply, validator, + with_a_drifted_log_record, without_the_logs_api, ) @@ -570,6 +571,22 @@ def _events_both_without_the_logs_api(monkeypatch: pytest.MonkeyPatch) -> None: _ = GuideBuilder().api_key(ApiKey("k")).events("both") +def _events_log_with_a_drifted_log_record(monkeypatch: pytest.MonkeyPatch) -> None: + # The logs API is there and its `LogRecord` no longer takes `event_name`. Every record + # guideme would build is unbuildable, so the signal is absent and saying so is the only + # honest answer; the alternative is an ask that emits nothing and never mentions it. + with_a_drifted_log_record(monkeypatch) + _ = GuideBuilder().api_key(ApiKey("k")).events("log") + + +def _events_given_an_unknown_mode(_monkeypatch: pytest.MonkeyPatch) -> None: + # `Events` is a Literal, so a checker refuses this at the call. The cast is how the + # test reaches the runtime guard behind it, which is what a caller with no type + # checker meets, and the only thing standing between them and a silent no-op. + unknown: Events = cast("Events", "spans") + _ = GuideBuilder().api_key(ApiKey("k")).events(unknown) + + @pytest.mark.parametrize( "build", [ @@ -582,6 +599,8 @@ def _events_both_without_the_logs_api(monkeypatch: pytest.MonkeyPatch) -> None: _levels_given_as_one_string, _events_log_without_the_logs_api, _events_both_without_the_logs_api, + _events_log_with_a_drifted_log_record, + _events_given_an_unknown_mode, ], ids=[ "credentialed_base_url", @@ -593,6 +612,8 @@ def _events_both_without_the_logs_api(monkeypatch: pytest.MonkeyPatch) -> None: "levels_given_as_one_string", "events_log_without_the_logs_api", "events_both_without_the_logs_api", + "events_log_with_a_drifted_log_record", + "events_given_an_unknown_mode", ], ) def test_a_configuration_mistake_is_refused_before_a_guide_exists( @@ -607,28 +628,60 @@ def test_a_configuration_mistake_is_refused_before_a_guide_exists( ELSEWHERE = "/v1/systemone-elsewhere" -"""Where the redirect case points. Same origin, so `httpx` keeps the bearer on the hop.""" +"""Where a redirect case points, on whichever origin that case sends it to.""" + + +@final +@dataclass(frozen=True, slots=True) +class Landed: + """Where the POST that carried the questions ended up, and what it should have carried.""" + server: HTTPServer + """Whose log holds the final request: the first origin, or the one it was sent on to.""" -def _served_directly(httpserver: HTTPServer, body: str) -> None: + first: int + """Requests the first origin served, so a redirect that never happened fails here.""" + + bearer: bool + """Whether the key should have survived the hop.""" + + +type Arrange = Callable[[HTTPServer, HTTPServer, str], Landed] +"""How one case sets up the two origins, returning where the questions landed.""" + + +def _served_directly(httpserver: HTTPServer, _other: HTTPServer, body: str) -> Landed: expect_post(httpserver).respond_with_data(body, content_type=JSON) + return Landed(server=httpserver, first=1, bearer=True) -def _served_after_a_redirect(httpserver: HTTPServer, body: str) -> None: +def _served_after_a_redirect(httpserver: HTTPServer, _other: HTTPServer, body: str) -> Landed: expect_post(httpserver).respond_with_data("", status=307, headers={"location": ELSEWHERE}) httpserver.expect_request(ELSEWHERE, method="POST").respond_with_data(body, content_type=JSON) + return Landed(server=httpserver, first=2, bearer=True) + + +def _redirected_off_the_origin(httpserver: HTTPServer, other: HTTPServer, body: str) -> Landed: + # The second server is on its own port, so the hop changes the origin and httpx drops + # the authorization header rather than handing the key to a host guideme was not + # configured for. Its one carve-out is a direct same-host http -> https upgrade, which + # keeps the header deliberately; a different port is not that case. + away = other.url_for(ELSEWHERE) + expect_post(httpserver).respond_with_data("", status=307, headers={"location": away}) + other.expect_request(ELSEWHERE, method="POST").respond_with_data(body, content_type=JSON) + return Landed(server=other, first=1, bearer=False) @pytest.mark.parametrize( - ("arrange", "served"), - [(_served_directly, 1), (_served_after_a_redirect, 2)], - ids=["direct", "after_a_307"], + "arrange", + [_served_directly, _served_after_a_redirect, _redirected_off_the_origin], + ids=["direct", "after_a_307", "after_a_307_off_the_origin"], ) def test_the_request_carries_the_bearer_token_and_matches_the_schema( httpserver: HTTPServer, + other_httpserver: HTTPServer, runner: Runner, - arrange: Callable[[HTTPServer, str], None], - served: int, + arrange: Arrange, ) -> None: answers: dict[str, Json] = { "q0": {"type": "noul", "noul": 0.95}, @@ -646,17 +699,21 @@ def test_the_request_carries_the_bearer_token_and_matches_the_schema( "confidence": 0.92, }, } - arrange(httpserver, reply(answers)) + landed = arrange(httpserver, other_httpserver, reply(answers)) batch = ( noul("Urgent?").criteria("needs a person now", "can wait"), choose_among("Which team?", {"billing": "Money", "sales": None}), score_levels("How cross?", ["Calm", "Cross"]), ) assert runner.ask(batch, TICKET) == (True, Key("billing"), Rank(1)) - assert len(httpserver.log) == served - - request, _ = httpserver.log[-1] - assert request.headers["authorization"] == f"Bearer {TEST_KEY}" + assert len(httpserver.log) == landed.first + + request, _ = landed.server.log[-1] + if landed.bearer: + assert request.headers["authorization"] == f"Bearer {TEST_KEY}" + else: + assert "authorization" not in request.headers + assert not [value for value in request.headers.values() if TEST_KEY in value] assert request.headers["content-type"] == JSON body = as_object(narrow(request.get_json())) check_request(body) From 22cd692665033dd73c7262deba88daa1f083ae6f Mon Sep 17 00:00:00 2001 From: Pedro Cunha Date: Mon, 21 Sep 2026 20:33:49 -0300 Subject: [PATCH 3/8] Say where a duplicate option key is actually refused ask's docstring listed "two runtime keys collide" under the ConfigError it raises. It does not raise it: validate(ChoiceSpec) refuses a repeated key while choose_among builds the question, long before anything is asked. A caller reading the generated overloads would look for the failure in the wrong place. The clause leaves ASK_DOC, which now says a rubric this call could not ask is refused where the question is built, and choose_among says the same from the other side. --- scripts/gen_ask_overloads.py | 5 +++-- src/guideme/_ask_overloads.py | 10 ++++++---- src/guideme/question.py | 3 ++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/scripts/gen_ask_overloads.py b/scripts/gen_ask_overloads.py index c996a62..5bde77c 100644 --- a/scripts/gen_ask_overloads.py +++ b/scripts/gen_ask_overloads.py @@ -28,8 +28,9 @@ Raises: ConfigError: `shape` holds something that is not a question, the batch is - empty, two runtime keys collide, or `state` or the instructions cannot - be serialised to JSON. + empty, or `state` or the instructions cannot be serialised to JSON. A + rubric this call could not ask is refused where the question is built, + not here. UnsureError: the policy read an answer as unsure and neither `.otherwise(...)` nor a `fallback(...)` member caught it. ProtocolError: the response breaks the contract: an undecodable body, the diff --git a/src/guideme/_ask_overloads.py b/src/guideme/_ask_overloads.py index 9bec7c7..8323e9b 100644 --- a/src/guideme/_ask_overloads.py +++ b/src/guideme/_ask_overloads.py @@ -339,8 +339,9 @@ def ask(self, shape: object, state: Json) -> object: Raises: ConfigError: `shape` holds something that is not a question, the batch is - empty, two runtime keys collide, or `state` or the instructions cannot - be serialised to JSON. + empty, or `state` or the instructions cannot be serialised to JSON. A + rubric this call could not ask is refused where the question is built, + not here. UnsureError: the policy read an answer as unsure and neither `.otherwise(...)` nor a `fallback(...)` member caught it. ProtocolError: the response breaks the contract: an undecodable body, the @@ -680,8 +681,9 @@ def ask(self, shape: object, state: Json) -> Awaitable[object]: Raises: ConfigError: `shape` holds something that is not a question, the batch is - empty, two runtime keys collide, or `state` or the instructions cannot - be serialised to JSON. + empty, or `state` or the instructions cannot be serialised to JSON. A + rubric this call could not ask is refused where the question is built, + not here. UnsureError: the policy read an answer as unsure and neither `.otherwise(...)` nor a `fallback(...)` member caught it. ProtocolError: the response breaks the contract: an undecodable body, the diff --git a/src/guideme/question.py b/src/guideme/question.py index 5eb827c..25f0a40 100644 --- a/src/guideme/question.py +++ b/src/guideme/question.py @@ -429,7 +429,8 @@ def choose_among(instructions: Json, options: Mapping[str, str | None]) -> Choic """Pick one of the runtime options `(key, rubric)`. Plain output: `Key`. Takes 1 to 255 options, the same range a `Choice` enum takes. A rubric - outside it, or one with a duplicate key, is a `ConfigError`. + outside it, or one where two keys collide, is a `ConfigError` raised here, + where the options are written, never later at `ask`. """ return _choice( instructions, From c85f5efdaa1dcc6ffbb2c858a16fe97a93ec5d04 Mon Sep 17 00:00:00 2001 From: Pedro Cunha Date: Mon, 21 Sep 2026 20:33:50 -0300 Subject: [PATCH 4/8] Name the second trigger of ConfigError from events() The class docstring listed an events(...) value outside the three modes and stopped there. events("log") and events("both") also raise it where the installed opentelemetry-api provides no logs API, which is the case a reader hitting it is least likely to guess. The list is open-ended and keeps that shape. --- src/guideme/errors.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/guideme/errors.py b/src/guideme/errors.py index 90db2db..e0889b0 100644 --- a/src/guideme/errors.py +++ b/src/guideme/errors.py @@ -157,8 +157,10 @@ class ConfigError(GuidemeError): batch, unserialisable state or instructions, a duplicated rubric, a rubric outside 1..=255 choice options or 2..=10 score levels, `score_levels` handed a `str`, a non-positive timeout, negative retries or backoff, an `events(...)` - outside `"span"`, `"log"` and `"both"`, a `base_url` carrying credentials, - and anything else the package can refuse before it opens a socket. + outside `"span"`, `"log"` and `"both"`, `events("log")` or `events("both")` + where the installed `opentelemetry-api` provides no logs API, a `base_url` + carrying credentials, and anything else the package can refuse before it + opens a socket. """ kind = "config" From dc9bc37157b34855c3bb709ac51dce9a274b9a87 Mon Sep 17 00:00:00 2001 From: Pedro Cunha Date: Mon, 21 Sep 2026 20:33:59 -0300 Subject: [PATCH 5/8] Settle the surface tiers and correct what the code contradicts Four documents disagreed with the code or with each other. design.md told callers to import Question from guideme.question while AGENTS.md and the README said the supported surface was __all__ plus guideme.api and guideme.policy, and everything else private. Question is what noul, choose, choose_among, score, score_levels and all three .detail() methods return, and a dict annotation cannot be written without it, so the module joins the second tier rather than the type joining __all__: the tier already carries the same major-bump promise, and the 33-name export list stays as the goal fixed it. The changelog said ApiKey has no __str__. scalars.py defines one returning ApiKey(***). That entry ships inside the sdist and is permanent once uploaded, so it now says both repr and str redact. observability.md called opentelemetry._logs the public logs API, which is the opposite of AGENTS.md and of the premise the deferred import rests on. It now says what is useful: no public alias, so a release inside the accepted range may move it with no notice. AGENTS.md and the changelog also record what the guard and the swallow now cover, which the preceding commits changed. --- AGENTS.md | 22 +++++++++++++++------- CHANGELOG.md | 21 ++++++++++++++------- README.md | 14 ++++++++++---- docs/design.md | 8 +++++--- docs/observability.md | 10 +++++++--- 5 files changed, 51 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e03f0e0..595f290 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,10 +9,12 @@ A Python package that makes a TypeSafe Jev judgment usable as control flow: a ye published to PyPI under `MIT OR Apache-2.0`. The public surface has two tiers: - the 33 names in `__all__` in `src/guideme/__init__.py`, imported from `guideme` itself; -- `guideme.api` and `guideme.policy`, imported by their own path and not re-exported at the - top level. `guideme.api` is the wire mirror and `guideme.api.client` holds `Client` and - `AsyncClient`; `guideme.policy` holds `resolve`. The README's **Lower layers** section is - where they are documented for callers. +- `guideme.api`, `guideme.policy` and `guideme.question`, imported by their own path and not + re-exported at the top level. `guideme.api` is the wire mirror and `guideme.api.client` + holds `Client` and `AsyncClient`; `guideme.policy` holds `resolve`; `guideme.question` + holds `Question`, which is what every constructor in the first tier returns and the only + way to write the type of a stored question down. The README's **Lower layers** section is + where all three are documented for callers. Everything else in the package is private, whatever its name looks like. @@ -92,8 +94,10 @@ the rest are checked in review. OTLP log record whose sink raises is swallowed. The provider, the processor and the exporter are the application's, an ask that reached its answer must return it, and a span saying the ask succeeded must not be contradicted by a failure to store a log. That failure is visible - where it belongs, in the application's own logging pipeline. Nothing else in `src` swallows - anything. + where it belongs, in the application's own logging pipeline. The guard covers that call and + nothing else: the record is built before it, so a `LogRecord` this package cannot construct + is caught in `resolve_logs` and makes the signal absent, loudly, instead of being swallowed + once per answer. Nothing else in `src` swallows anything. - **A rubric's text is unique, and a rubric is the caller's to get wrong once.** Two members of a `Choice` or a `Levels` sharing a value are aliases in Python, not two options, so a repeat is a `ConfigError` on the class statement; so is `fallback(…)` on a `Levels`, which has @@ -119,7 +123,11 @@ the rest are checked in review. distribution, so `telemetry.resolve_logs` imports it defensively and its absence is a value. A release that moves it must cost the logs signal and nothing else: `import guideme` still works, the traces signal is untouched, and `events("span")` still routes every answer and - retry. Asking for the signal that is not there, `events("log")` or `events("both")`, is a + retry. Every read of that API lives inside the one guard — the import, both severity + members, both `get_logger` calls and a throwaway `LogRecord` built with the five keywords + `Logs._emit` uses — and `ImportError`, `AttributeError` and `TypeError` all mean the same + thing there. A guard narrower than the promise is the promise being false: a module that + still imports and has lost a member would otherwise kill `import guideme` at module scope. Asking for the signal that is not there, `events("log")` or `events("both")`, is a `ConfigError` naming the version and both ways out. The default `"both"` is not such an ask, so it degrades to the span alone, which is all an application without the logs API could have exported anyway. `latest-deps` in CI resolves the range unpinned so a move is seen early; it diff --git a/CHANGELOG.md b/CHANGELOG.md index fee9ae9..4346917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,18 +38,25 @@ changes to it. only under `record_state(True)`; its length always is. The logs API is private in `opentelemetry-api`, so it is resolved defensively: a release without it costs the logs signal and nothing else, and `events("log")` or `events("both")` is then a `ConfigError` - where the guide is configured rather than records that silently go nowhere. A sink that - raises is swallowed, so a logging failure never reaches the caller or the ask span. + where the guide is configured rather than records that silently go nowhere. That holds for + a release that renames a severity or changes what a log record takes, not only for one that + removes the module: everything guideme reads from the private API is resolved once, behind + the one guard. A sink that raises is swallowed, so a logging failure never reaches the + caller or the ask span; a record guideme cannot build is not a sink failure and is not + swallowed. - One error tree under `GuidemeError`, each class carrying the `.kind` string every guideme SDK reports: `AuthError`, `InvalidError`, `RateLimitedError`, `OverloadedError`, `TransportError`, `UnexpectedStatusError`, `ProtocolError`, `UnsureError` and `ConfigError`. A `ConfigError` is raised where the mistake is written, before a socket is opened. -- `ApiKey`, which carries the key and never prints it: its `repr` is `ApiKey(***)`, it has no - `__str__` and no serialisation, and it is scrubbed from anything the package reports. +- `ApiKey`, which carries the key and never prints it: `repr` and `str` are both `ApiKey(***)`, + it has no serialisation and refuses to pickle, and it is scrubbed from anything the package + reports. `Probability`, `Confidence`, `Key`, `Rank` and `Model` are the other validated scalars. -- `guideme.api` as a second supported tier: the wire mirror of `POST /v1/systemone` and - `GET /v1/models`, with `Client` and `AsyncClient` in `guideme.api.client`, and - `guideme.policy.resolve` as the pure decision function. +- `guideme.api`, `guideme.policy` and `guideme.question` as a second supported tier: the wire + mirror of `POST /v1/systemone` and `GET /v1/models`, with `Client` and `AsyncClient` in + `guideme.api.client`; `guideme.policy.resolve` as the pure decision function; and + `guideme.question.Question`, the type every question constructor returns, for annotating a + question you store or pass on. - `GuideBuilder.from_env()` applies `TYPESAFE_API_KEY`, `TYPESAFE_BASE_URL` and `GUIDEME_MODEL` onto a builder, so a setting with no environment variable can be chained after them; `Guide.from_env()` and `AsyncGuide.from_env()` are that step plus `build()`. diff --git a/README.md b/README.md index 655cd37..d67ae07 100644 --- a/README.md +++ b/README.md @@ -261,15 +261,21 @@ Retries on 429 and 529 use exponential backoff with jitter, capped at 30 s, and ## Lower layers Everything above is re-exported from the `guideme` package, and `guideme.__all__` is that list. -The two modules below are a second supported tier: you import them by their own path, they are -not re-exported at the top level, and they are under the same rule as the first tier — nothing -in them is removed or renamed without a major version and a `CHANGELOG.md` entry. Anything else -in the package is private, whatever its name looks like. +The three modules below are a second supported tier: you import them by their own path, they +are not re-exported at the top level, and they are under the same rule as the first tier — +nothing in them is removed or renamed without a major version and a `CHANGELOG.md` entry. +Anything else in the package is private, whatever its name looks like. - `guideme.api` is the exact wire mirror of `POST /v1/systemone` and `GET /v1/models`. `guideme.api.client` holds `Client` and `AsyncClient` for callers who want to build requests themselves. They live one level down rather than on `guideme.api` because re-exporting them would make `api` and `api.client` import each other, and the gate fails an import cycle. +- `guideme.question.Question` is the type `noul`, `choose`, `choose_among`, `score`, + `score_levels` and every `.detail()` return. Inference covers most uses, so import it when + you need to annotate a question you are storing or passing on: a `dict` is invariant, so a + `dict[str, NoulQuestion]` is not a `dict[str, Question[bool]]` and the annotation has to be + written. It is out of `__all__` because the top-level surface is a fixed list, not because + the type is private. - The scalars are validated once and never re-checked: `Probability` and `Confidence` hold the unit-interval numbers on `Verdict`, `Ranked` and `Scored`, `Key` and `Rank` are what a runtime rubric answers with, `Model` names the model to ask, and `ApiKey` carries the key without ever diff --git a/docs/design.md b/docs/design.md index 56bb896..43b1964 100644 --- a/docs/design.md +++ b/docs/design.md @@ -133,9 +133,11 @@ Each module survives the test. `Guide.from_env()`; those are what validate the policy and the origin before a socket opens. - **`with_policy` shares the pool.** The copy holds the same client, so `close()` on either the original or the copy closes the connection pool for both. -- **`Question` is not exported.** The public surface is exactly `__all__`, and a question's - type is whatever its constructor returns, so callers annotate by inference. Import it from - `guideme.question` when you need to write the type of a stored question down. +- **`Question` is supported, and not in `__all__`.** The top-level surface is a fixed list and + a question's type is whatever its constructor returns, so callers annotate by inference. + `guideme.question` is the second tier, beside `guideme.api` and `guideme.policy`, and + carries the same promise: import `Question` from there when you need to write the type of a + stored question down. - **Two `Question`s.** `guideme.question.Question` is the user-facing value; `guideme.api`'s question model is the wire shape it becomes. - **State is JSON-shaped.** Anything `json.dumps` accepts without a default hook. A dataclass diff --git a/docs/observability.md b/docs/observability.md index 6ab7120..cd5590e 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -250,9 +250,13 @@ lazily, so either works whether it runs before or after `import guideme`. Call ` before the process exits, or the last batch never leaves. Install only the tracer provider and the log records go nowhere at no cost, which is why `events("both")` is the default. -The logs API lives in `opentelemetry._logs` in OpenTelemetry 1.44.0. The underscore is the -package's, not a private detail of this one: the module is the public logs API and has no -alias without it. +The logs API lives in `opentelemetry._logs` in OpenTelemetry 1.44.0, and that underscore is +the whole reason the import is deferred and guarded. There is no public alias, so a release +of `opentelemetry-api` inside the range guideme accepts may move it, rename what is in it, or +change what `LogRecord` takes, with none of the notice a public name would come with. guideme +therefore resolves everything it needs from that module once, inside one guard: if any of it +is gone, the logs signal is absent, `import guideme` still works, traces are untouched, and +`events("log")` and `events("both")` say so rather than emitting nothing. To see the same data on the console instead, swap the exporter for `opentelemetry.sdk.trace.export.ConsoleSpanExporter`. `examples/otlp` is a runnable version From 1bc797027418221027b182e7862c0e1e804ede4a Mon Sep 17 00:00:00 2001 From: Pedro Cunha Date: Mon, 21 Sep 2026 20:34:09 -0300 Subject: [PATCH 6/8] Lint the lock comparison like the rest of scripts The program that proves latest-deps re-resolved lived in a heredoc inside ci.yml, where ruff, pyright strict and pylint never saw it. A defect in it would only ever surface as a job failing in CI, and the job it belongs to is the one that is deliberately not a required check. It moves to scripts/resolved_vs_lock.py unchanged in behaviour: it reads the committed lock out of git, because uv sync --upgrade has already rewritten the copy on disk, and it counts the packages the lock carries for another platform rather than looking them up. Strict typing cost two casts, both carrying the comment that proves them. --- .github/workflows/ci.yml | 49 ++--------------- scripts/resolved_vs_lock.py | 104 ++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 45 deletions(-) create mode 100644 scripts/resolved_vs_lock.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35dd9d9..0848743 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,52 +186,11 @@ jobs: # every runtime dependency is pinned at a floor that is also its newest # release; the day one moves, this step says so in one line, and a reader six # months from now can tell the job did its job either way. + # + # It is a file under scripts/, not a heredoc, so ruff, pyright strict and + # pylint check it like everything else this repository runs. - name: What it resolved, against what the lock pins - run: | - uv run --no-sync python - <<'PY' - import importlib.metadata as metadata - import subprocess - import tomllib - - RUNTIME = ("httpx", "pydantic", "opentelemetry-api") - - committed = subprocess.run( - ["git", "show", "HEAD:uv.lock"], capture_output=True, text=True, check=True - ).stdout - pinned = {p["name"]: p["version"] for p in tomllib.loads(committed)["package"]} - - - def installed(name): - try: - return metadata.version(name) - except metadata.PackageNotFoundError: - return None - - - for name in RUNTIME: - got = installed(name) or "(not installed)" - was = pinned.get(name, "(not in the lock)") - note = "same as the lock" if got == was else f"NEWER than the lock's {was}" - print(f"{name:<20} resolved {got:<14} {note}") - - moved = [] - elsewhere = 0 - for name, was in pinned.items(): - if name == "guideme": - continue - got = installed(name) - if got is None: - elsewhere += 1 - elif got != was: - moved.append(name) - - print() - if moved: - print(f"{len(moved)} resolved newer than the lock: {', '.join(sorted(moved))}") - else: - print("nothing in the lock has a newer release today; the range and the lock agree") - print(f"({elsewhere} locked for another platform, not installed here)") - PY + run: uv run --no-sync python scripts/resolved_vs_lock.py - name: Everything it resolved run: uv pip list diff --git a/scripts/resolved_vs_lock.py b/scripts/resolved_vs_lock.py new file mode 100644 index 0000000..6345b81 --- /dev/null +++ b/scripts/resolved_vs_lock.py @@ -0,0 +1,104 @@ +"""Say whether `latest-deps` re-resolved, by printing what it got beside what the lock pins. + +uv prints "Resolved N packages" whether it re-resolved or reused the lock, and today every +runtime dependency is pinned at a floor that is also its newest release, so a correct run and +a job silently reading `uv.lock` produce identical output. This is what tells them apart. + +The committed lock is read out of git, because `uv sync --upgrade` has already rewritten the +copy on disk by the time this runs. `actions/checkout` at `fetch-depth: 1` still has HEAD, so +this needs no deeper fetch. + +Run from the repository root, inside the environment the upgrade resolved. +""" + +import subprocess +import tomllib +from importlib import metadata +from typing import cast + +RUNTIME = ("httpx", "pydantic", "opentelemetry-api") +"""The runtime dependencies, named one by one: a move in any of them is the thing to see.""" + +LOCK = "HEAD:uv.lock" +"""The lock as committed. `--upgrade` rewrote the working copy, so git holds the only original.""" + + +def committed_lock() -> str: + """The text of `uv.lock` at HEAD.""" + # A fixed argument list and no shell; nothing here comes from outside this file. + finished = subprocess.run( # noqa: S603 -- a fixed argument list, no shell, no input + ["git", "show", LOCK], # noqa: S607 -- `git` off PATH, as every other CI step calls it + capture_output=True, + text=True, + check=True, + ) + return finished.stdout + + +def pinned(lock_text: str) -> dict[str, str]: + """Every package the committed lock pins, by name. A lock it cannot read is a failure.""" + # `tomllib.loads` is typed `dict[str, Any]`. `dict[str, object]` is the same type with + # the `Any` taken out, which is always sound, and every value read out of it below is + # narrowed by an isinstance before it is used. + parsed = cast("dict[str, object]", tomllib.loads(lock_text)) + packages = parsed.get("package") + if not isinstance(packages, list): + message = f"{LOCK} has no [[package]] array" + raise SystemExit(message) + found: dict[str, str] = {} + # Same again: the isinstance proved a list, and its elements are whatever TOML holds. + for entry in cast("list[object]", packages): + if not isinstance(entry, dict): + message = f"{LOCK} has a [[package]] entry that is not a table" + raise SystemExit(message) + record = cast("dict[str, object]", entry) + name = record.get("name") + pin = record.get("version") + if not isinstance(name, str) or not isinstance(pin, str): + message = f"{LOCK} has a [[package]] entry with no string name and version" + raise SystemExit(message) + found[name] = pin + return found + + +def installed(name: str) -> str | None: + """The version in this environment, or `None` where the lock carries it for another platform.""" + try: + return metadata.version(name) + except metadata.PackageNotFoundError: + return None + + +def main() -> None: + """Print the runtime dependencies one by one, then a count of everything else that moved.""" + locked = pinned(committed_lock()) + + for name in RUNTIME: + got = installed(name) or "(not installed)" + was = locked.get(name, "(not in the lock)") + note = "same as the lock" if got == was else f"NEWER than the lock's {was}" + print(f"{name:<20} resolved {got:<14} {note}") + + moved: list[str] = [] + # Packages the lock carries for another platform raise here rather than resolving, so + # they are counted and never looked up: a number nobody can mistake for drift. + elsewhere = 0 + for name, was in locked.items(): + if name == "guideme": + continue + got_or_none = installed(name) + if got_or_none is None: + elsewhere += 1 + elif got_or_none != was: + moved.append(name) + + print() + if moved: + print(f"{len(moved)} resolved newer than the lock: {', '.join(sorted(moved))}") + else: + print("nothing in the lock has a newer release today; the range and the lock agree") + print(f"({elsewhere} locked for another platform, not installed here)") + + +if __name__ == "__main__": + main() From 1a03290f556effc8f9341aa4eb0dcb968e09dbb7 Mon Sep 17 00:00:00 2001 From: Pedro Cunha Date: Mon, 21 Sep 2026 20:34:11 -0300 Subject: [PATCH 7/8] Let the release workflow be dispatched without publishing release.yml has never executed, and the only event that starts it puts a tag on the repository that the ruleset refuses to move or delete. A defect in its first run would burn v0.1.0, and a published version can be yanked but never replaced. workflow_dispatch now runs build, which is the whole gate plus uv build, and stops. publish is gated on github.event_name == 'push', a fact about how the run started rather than an input, a branch or a variable anyone can set, so no dispatched run can reach PyPI. The tag-versus-pyproject check is skipped on a dispatch, where GITHUB_REF_NAME is a branch and there is nothing to publish. --- .github/workflows/release.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 706c611..57c3316 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,20 @@ name: Release -# A tag is the only trigger. Pushing v1.2.3 publishes 1.2.3 to PyPI, so the tag -# goes on a commit whose gate is already green, and the tag ruleset refuses to -# let it move afterwards. +# A tag is the only trigger that publishes. Pushing v1.2.3 publishes 1.2.3 to +# PyPI, so the tag goes on a commit whose gate is already green, and the tag +# ruleset refuses to let it move afterwards. +# +# workflow_dispatch is a dry run, and it exists because the tag is immutable: a +# workflow that has never executed gets its first try on the one event nobody +# can take back. A dispatched run does `build`, which is the gate and `uv build`, +# and stops there. It CANNOT publish: `publish` is gated on +# `github.event_name == 'push'`, which is a fact about how the run started, not +# an input, a branch or a variable anyone can set, and a re-run keeps the event +# it was created with. on: push: tags: ["v*"] + workflow_dispatch: permissions: contents: read @@ -30,8 +39,10 @@ jobs: # The tag names the version that reaches PyPI, and a published version is # permanent, so a tag that disagrees with pyproject.toml stops here rather # than publishing a number nobody meant. Checked before the gate, which is - # the slow part. + # the slow part. On a dispatched run GITHUB_REF_NAME is a branch and there + # is nothing to publish, so there is nothing for it to check. - name: the tag matches the project version + if: github.event_name == 'push' run: | version="v$(uv version --short --no-sync)" if [ "${version}" != "${GITHUB_REF_NAME}" ]; then @@ -62,9 +73,14 @@ jobs: # upload token, so the repository holds no PyPI credential of any kind. PyPI # checks the owner, the repository, this workflow's file name and the # environment, so all four are part of the contract; AGENTS.md has the values. + # + # A tag push is the only event that reaches this job. A dispatched run finishes + # at `build` with the archives uploaded as a workflow artifact and nothing sent + # anywhere, which is the whole point of being able to dispatch it. publish: name: publish needs: build + if: github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 10 environment: pypi From 2d9a9cd0a5000862670d3442e7e5ff4d638a513d Mon Sep 17 00:00:00 2001 From: Pedro Cunha Date: Mon, 21 Sep 2026 20:40:58 -0300 Subject: [PATCH 8/8] docs: say what the code does about logs, spans and tiers The documentation review found the last two pull requests had moved behaviour without moving its record. The events mode lives on the client now, not on _Config. A ConfigError is raised before the ask span opens, so config can never be an error.type on it, and an alert keyed on that would never fire. The logs signal can be refused or degrade when the installed opentelemetry-api has no logs API, which was stated only in the contributor guide and one docstring, never where a user meets it. Two sentences in the changelog were false and the changelog ships inside the sdist: ApiKey does define __str__, and four of the five scalars are NewType brands the wire validates rather than validated scalars. The second tier now promises one name from guideme.question rather than the module. Naming the module would publish validate, Spec and the concrete question classes to let a caller annotate one type. Tightening the surface test to look for each exported name inside code rather than anywhere in the prose immediately found ModelInfo and fallback documented only in passing, which is what it is for. --- AGENTS.md | 15 +++++++++------ CHANGELOG.md | 14 ++++++++------ CONTRIBUTING.md | 5 +++-- README.md | 33 +++++++++++++++++++++++++-------- docs/design.md | 14 +++++++++----- docs/observability.md | 14 ++++++++++++-- scripts/gen_ask_overloads.py | 9 +++++---- src/guideme/_ask_overloads.py | 18 ++++++++++-------- src/guideme/guide.py | 30 ++++++++++++++++++++++++++++-- src/guideme/question.py | 5 +++-- tests/test_surface.py | 9 ++++++++- 11 files changed, 120 insertions(+), 46 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 595f290..faaed3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,12 +9,15 @@ A Python package that makes a TypeSafe Jev judgment usable as control flow: a ye published to PyPI under `MIT OR Apache-2.0`. The public surface has two tiers: - the 33 names in `__all__` in `src/guideme/__init__.py`, imported from `guideme` itself; -- `guideme.api`, `guideme.policy` and `guideme.question`, imported by their own path and not - re-exported at the top level. `guideme.api` is the wire mirror and `guideme.api.client` - holds `Client` and `AsyncClient`; `guideme.policy` holds `resolve`; `guideme.question` - holds `Question`, which is what every constructor in the first tier returns and the only - way to write the type of a stored question down. The README's **Lower layers** section is - where all three are documented for callers. +- `guideme.api` and `guideme.policy` as whole modules, imported by their own path and not + re-exported at the top level: `guideme.api` is the wire mirror and `guideme.api.client` + holds `Client` and `AsyncClient`, and `guideme.policy` holds `resolve`. +- one name from a third module, `guideme.question.Question`: what every constructor in the + first tier returns, and the only way to write the type of a stored question down. The + promise covers that name and nothing else in `guideme.question` — `validate`, `Spec` and + the concrete question classes stay private, because a tier is a promise and this is the + narrowest one that lets a caller annotate. The README's **Lower layers** section documents + all of it. Everything else in the package is private, whatever its name looks like. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4346917..aedd5d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,12 +51,14 @@ changes to it. - `ApiKey`, which carries the key and never prints it: `repr` and `str` are both `ApiKey(***)`, it has no serialisation and refuses to pickle, and it is scrubbed from anything the package reports. - `Probability`, `Confidence`, `Key`, `Rank` and `Model` are the other validated scalars. -- `guideme.api`, `guideme.policy` and `guideme.question` as a second supported tier: the wire - mirror of `POST /v1/systemone` and `GET /v1/models`, with `Client` and `AsyncClient` in - `guideme.api.client`; `guideme.policy.resolve` as the pure decision function; and - `guideme.question.Question`, the type every question constructor returns, for annotating a - question you store or pass on. + `Model` is validated on construction; `Probability`, `Confidence`, `Key` and `Rank` are + `NewType` brands that the wire mints and validates, so the guarantee is where they come + from rather than a check at every call. +- A second supported tier, imported by its own path: `guideme.api`, the wire mirror of + `POST /v1/systemone` and `GET /v1/models`, with `Client` and `AsyncClient` in + `guideme.api.client`; `guideme.policy`, holding `resolve`, the pure decision function; and + the single name `guideme.question.Question`, the type every question constructor returns, + for annotating a question you store or pass on. The rest of `guideme.question` is private. - `GuideBuilder.from_env()` applies `TYPESAFE_API_KEY`, `TYPESAFE_BASE_URL` and `GUIDEME_MODEL` onto a builder, so a setting with no environment variable can be chained after them; `Guide.from_env()` and `AsyncGuide.from_env()` are that step plus `build()`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e3da8e5..a4e6856 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,9 @@ # Contributing guideme makes a TypeSafe Jev judgment usable as Python control flow. It is one distribution, -`guideme`. Its public surface is what `src/guideme/__init__.py` exports, plus `guideme.api` -and `guideme.policy` as a second supported tier that callers import by their own path. +`guideme`. Its public surface is what `src/guideme/__init__.py` exports, plus a second +supported tier that callers import by their own path: the modules `guideme.api` and +`guideme.policy`, and the single name `guideme.question.Question`. Everything else in the package is private. `AGENTS.md` and the README's **Lower layers** section say what each tier promises. diff --git a/README.md b/README.md index d67ae07..71a79d3 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,10 @@ short. `fallback(…)` marks a `Choice` member and only a `Choice` member; a `Le ordered, so the level to fall back to when a score is unsure is `.otherwise(level)` on the question. -That example is `tests/typing/readme.py`, which the gate type-checks with an `assert_type` -after every `ask`, so what is on this page cannot drift from what the package infers. +That example is `tests/typing/readme.py`, which the gate type-checks with an `assert_type` on +every inferred answer type, so what is on this page cannot drift from what the package infers. +The three asks above are held to it by their use sites instead: the `match` is exhaustive and +the `>=` is between two levels of one scale. ## Install @@ -108,7 +110,9 @@ through `dataclasses.asdict`, a pydantic model through `.model_dump()`. ## Policy Thresholds decide how a probability or a confidence becomes an answer. They form a patch that -merges from the question, over the guide, over the package defaults. +merges from the question, over the guide, over the package defaults. A `Policy` is that patch, +with every field optional; settling one against the defaults gives a `Thresholds`, which is +what `guideme.policy.resolve` takes and what every golden vector is written against. | Layer | How to set | Wins over | |---|---|---| @@ -197,14 +201,21 @@ the synchronous guide and `.build_async()` the asynchronous one. Both guides also answer `models()`, which returns a `tuple[ModelInfo, ...]`: the models the account may use, each with its `name`, `description` and `release_date`. It is one call to -`GET /v1/models` and gets no ask span of its own. +`GET /v1/models` and gets no ask span of its own. Unlike `ask`, it is not retried: a `429` or a +`529` raises on the first attempt, so the two rows below that mention retries do not apply to it. ## Observability guideme emits OpenTelemetry spans, span events and OTLP log records through `opentelemetry-api` and installs nothing: no tracer provider, no logger provider, no exporter, -no logging handler. Install a provider and the data appears. The smallest one that leaves the -process: +no logging handler. Install a provider and the data appears. The SDK and an exporter are not +dependencies of this package, so install them alongside it: + +```sh +pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-grpc +``` + +The smallest provider that leaves the process: ```python from opentelemetry import trace @@ -230,7 +241,9 @@ Every answer and every retry is also an OTLP log record, at `INFO` and at `WARN` trace id and the span id of the span it came from, so a logs backend links one straight back to the decision it explains. Install a `LoggerProvider` too and they arrive; install neither and they cost nothing. `events(...)` on the builder picks which signal carries an event when you -export both; **Choosing a signal** in the observability document has the table and the default. +export both; **Choosing a signal** in the observability document has the table, the default, and +what happens on an `opentelemetry-api` that has no logs API: asking for one is refused, and the +default falls back to the span event alone. Because the shapes are standard, any OTLP backend reads them as is. [`docs/observability.md`](https://github.com/pedro-pscunha/guideme-python/blob/main/docs/observability.md) @@ -275,7 +288,8 @@ Anything else in the package is private, whatever its name looks like. you need to annotate a question you are storing or passing on: a `dict` is invariant, so a `dict[str, NoulQuestion]` is not a `dict[str, Question[bool]]` and the annotation has to be written. It is out of `__all__` because the top-level surface is a fixed list, not because - the type is private. + the type is private. The promise covers that one name: everything else in `guideme.question` + is private. - The scalars are validated once and never re-checked: `Probability` and `Confidence` hold the unit-interval numbers on `Verdict`, `Ranked` and `Scored`, `Key` and `Rank` are what a runtime rubric answers with, `Model` names the model to ask, and `ApiKey` carries the key without ever @@ -338,6 +352,9 @@ for the policy laws, a real local HTTP server for the wire and retry contract, s tracing assertions, pyright files that must fail, and a drift guard that re-resolves every golden vector. +From a source distribution rather than a clone, `mise.toml` and `uv.lock` are not present, so +the suite runs with `uv run --group dev pytest`. + Two opt-in tests hit the real API and are deselected by default: ``` diff --git a/docs/design.md b/docs/design.md index 43b1964..4385cf2 100644 --- a/docs/design.md +++ b/docs/design.md @@ -102,8 +102,10 @@ Each module survives the test. span exporter to store each one once. There is no subscriber here, so the library makes both calls and `events(...)` is the filter the application would otherwise have written. `both` is the default because a record costs nothing until a `LoggerProvider` exists, which keeps the - library's unconfigured behaviour the same as Rust's. The mode rides on `_Config`, so a - `with_policy` copy keeps it and `Guide` and `AsyncGuide` cannot differ. + library's unconfigured behaviour the same as Rust's. The mode rides on the client, which is + what emits the retries, and the guide reads `client.events` back for the answers, so it has + one owner. A `with_policy` copy shares the client and therefore the mode, and `Guide` and + `AsyncGuide` cannot differ. ## Sharp edges @@ -135,9 +137,11 @@ Each module survives the test. original or the copy closes the connection pool for both. - **`Question` is supported, and not in `__all__`.** The top-level surface is a fixed list and a question's type is whatever its constructor returns, so callers annotate by inference. - `guideme.question` is the second tier, beside `guideme.api` and `guideme.policy`, and - carries the same promise: import `Question` from there when you need to write the type of a - stored question down. + `guideme.question.Question` is in the second tier, beside `guideme.api` and + `guideme.policy`, and carries the same promise: import it from there when you need to write + the type of a stored question down. The promise is that one name. The rest of the module, + `validate`, `Spec` and the concrete question classes, is private, because naming the whole + module would publish all of it to let a caller annotate one thing. - **Two `Question`s.** `guideme.question.Question` is the user-facing value; `guideme.api`'s question model is the wire shape it becomes. - **State is JSON-shaped.** Anything `json.dumps` accepts without a default hook. A dataclass diff --git a/docs/observability.md b/docs/observability.md index cd5590e..065015c 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -157,6 +157,12 @@ provider installed the record goes to OpenTelemetry's no-op logger. Set `"span"` once both pipelines are running and you would rather store and read each event once. Anything else is a `ConfigError` where the builder is written. +`"log"` and `"both"` need the OpenTelemetry logs API, and this package accepts a range of +`opentelemetry-api` wide enough not to guarantee it. Where it is missing, asking for either is +a `ConfigError` naming the installed version and both ways out. The default `"both"` is not an +ask, so it does not raise: it degrades to the span event alone, and an application without the +logs API could not have installed a logger provider to receive records anyway. + ### Errors guideme records no exception event and emits no log record at `ERROR`. A failure is raised @@ -167,8 +173,12 @@ Both spans are started with `record_exception=False` and `set_status_on_exceptio that nothing is recorded behind guideme's back. `error.type` values on the ask span: `auth`, `invalid`, `rate_limited`, `overloaded`, -`transport`, `unexpected_status`, `protocol`, `unsure`, `config`. On an HTTP span it is the -status code as text when a response arrived, otherwise one of those names. +`transport`, `unexpected_status`, `protocol`, `unsure`. On an HTTP span it is the status code +as text when a response arrived, otherwise one of those names. + +`config` is the one kind that never reaches a span. The request is planned before the span is +opened, so a `ConfigError` is raised while there is nothing to mark, and an alert keyed on +`error.type=config` over `guideme.ask` would never fire. The status description is the error's message, except for `invalid` and `unexpected_status`: those errors carry the verbatim response body, which could echo the diff --git a/scripts/gen_ask_overloads.py b/scripts/gen_ask_overloads.py index 5bde77c..eb256c0 100644 --- a/scripts/gen_ask_overloads.py +++ b/scripts/gen_ask_overloads.py @@ -27,10 +27,11 @@ The answers, in the shape of `shape`. Raises: - ConfigError: `shape` holds something that is not a question, the batch is - empty, or `state` or the instructions cannot be serialised to JSON. A - rubric this call could not ask is refused where the question is built, - not here. + ConfigError: `shape` holds something that is not a question, a question's + own thresholds are outside `0..=1` or leave `no_below` above + `yes_above`, the batch is empty, or `state` or the instructions cannot + be serialised to JSON. A rubric this call could not ask is refused + where the question is built, not here. UnsureError: the policy read an answer as unsure and neither `.otherwise(...)` nor a `fallback(...)` member caught it. ProtocolError: the response breaks the contract: an undecodable body, the diff --git a/src/guideme/_ask_overloads.py b/src/guideme/_ask_overloads.py index 8323e9b..4c28a87 100644 --- a/src/guideme/_ask_overloads.py +++ b/src/guideme/_ask_overloads.py @@ -338,10 +338,11 @@ def ask(self, shape: object, state: Json) -> object: The answers, in the shape of `shape`. Raises: - ConfigError: `shape` holds something that is not a question, the batch is - empty, or `state` or the instructions cannot be serialised to JSON. A - rubric this call could not ask is refused where the question is built, - not here. + ConfigError: `shape` holds something that is not a question, a question's + own thresholds are outside `0..=1` or leave `no_below` above + `yes_above`, the batch is empty, or `state` or the instructions cannot + be serialised to JSON. A rubric this call could not ask is refused + where the question is built, not here. UnsureError: the policy read an answer as unsure and neither `.otherwise(...)` nor a `fallback(...)` member caught it. ProtocolError: the response breaks the contract: an undecodable body, the @@ -680,10 +681,11 @@ def ask(self, shape: object, state: Json) -> Awaitable[object]: The answers, in the shape of `shape`. Raises: - ConfigError: `shape` holds something that is not a question, the batch is - empty, or `state` or the instructions cannot be serialised to JSON. A - rubric this call could not ask is refused where the question is built, - not here. + ConfigError: `shape` holds something that is not a question, a question's + own thresholds are outside `0..=1` or leave `no_below` above + `yes_above`, the batch is empty, or `state` or the instructions cannot + be serialised to JSON. A rubric this call could not ask is refused + where the question is built, not here. UnsureError: the policy read an answer as unsure and neither `.otherwise(...)` nor a `fallback(...)` member caught it. ProtocolError: the response breaks the contract: an undecodable body, the diff --git a/src/guideme/guide.py b/src/guideme/guide.py index 36195a9..a638852 100644 --- a/src/guideme/guide.py +++ b/src/guideme/guide.py @@ -215,7 +215,20 @@ def with_policy(self, policy: Policy) -> "Guide": return Guide(self._client, _merged(self._config, policy)) def models(self) -> tuple[ModelInfo, ...]: - """The models this account may use. No ask span of its own.""" + """The models this account may use. No ask span of its own. + + One `GET /v1/models`, never retried: unlike `ask`, a `429` or a `529` raises on the + first attempt rather than after a backoff. + + Raises: + AuthError: the key was missing or rejected. + InvalidError: the API rejected the request (`422`). + RateLimitedError: the API answered `429`. + OverloadedError: the API answered `529`. + TransportError: the request never completed. + UnexpectedStatusError: any other status. + ProtocolError: the body did not match the contract. + """ return _described(self._client.models()) def close(self) -> None: @@ -266,7 +279,20 @@ def with_policy(self, policy: Policy) -> "AsyncGuide": return AsyncGuide(self._client, _merged(self._config, policy)) async def models(self) -> tuple[ModelInfo, ...]: - """The models this account may use. No ask span of its own.""" + """The models this account may use. No ask span of its own. + + One `GET /v1/models`, never retried: unlike `ask`, a `429` or a `529` raises on the + first attempt rather than after a backoff. + + Raises: + AuthError: the key was missing or rejected. + InvalidError: the API rejected the request (`422`). + RateLimitedError: the API answered `429`. + OverloadedError: the API answered `529`. + TransportError: the request never completed. + UnexpectedStatusError: any other status. + ProtocolError: the body did not match the contract. + """ return _described(await self._client.models()) async def close(self) -> None: diff --git a/src/guideme/question.py b/src/guideme/question.py index 25f0a40..1c9e8f5 100644 --- a/src/guideme/question.py +++ b/src/guideme/question.py @@ -429,8 +429,9 @@ def choose_among(instructions: Json, options: Mapping[str, str | None]) -> Choic """Pick one of the runtime options `(key, rubric)`. Plain output: `Key`. Takes 1 to 255 options, the same range a `Choice` enum takes. A rubric - outside it, or one where two keys collide, is a `ConfigError` raised here, - where the options are written, never later at `ask`. + outside it is a `ConfigError` raised here, where the options are written, + never later at `ask`. Keys cannot collide: a `Mapping` has already made + them unique. """ return _choice( instructions, diff --git a/tests/test_surface.py b/tests/test_surface.py index d8a4bd2..dd5624e 100644 --- a/tests/test_surface.py +++ b/tests/test_surface.py @@ -1,4 +1,5 @@ import ast +import re from pathlib import Path import guideme @@ -67,8 +68,14 @@ def test_public_surface_is_exactly_the_documented_list() -> None: assert set(guideme.__all__) == DOCUMENTED assert list(guideme.__all__) == sorted(guideme.__all__) assert all(hasattr(guideme, name) for name in guideme.__all__) + # Inside code, not bare prose: `Thresholds` passed this check for a release while + # appearing only as the English word starting a sentence, which documents nothing. A + # name may sit inside a wider span, as `ModelInfo` does in `tuple[ModelInfo, ...]`, + # so the test looks in every fenced block and every inline span rather than for the + # name alone between backticks. prose = README.read_text(encoding="utf-8") - assert not [name for name in guideme.__all__ if name not in prose] + code = "\n".join(re.findall(r"`{1,3}[^`]+`{1,3}", prose, re.DOTALL)) + assert not [name for name in guideme.__all__ if name not in code] def test_httpx_and_pydantic_stay_behind_the_api_package() -> None: