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/.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 diff --git a/AGENTS.md b/AGENTS.md index e03f0e0..faaed3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,10 +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` 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` 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. @@ -92,8 +97,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 +126,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..aedd5d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,18 +38,27 @@ 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. - `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. +- `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. + `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 655cd37..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) @@ -261,15 +274,22 @@ 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 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 @@ -332,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 56bb896..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 @@ -133,9 +135,13 @@ 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.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 6ab7120..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 @@ -250,9 +260,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 diff --git a/scripts/gen_ask_overloads.py b/scripts/gen_ask_overloads.py index c996a62..eb256c0 100644 --- a/scripts/gen_ask_overloads.py +++ b/scripts/gen_ask_overloads.py @@ -27,9 +27,11 @@ The answers, in the shape of `shape`. 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. + 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/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() diff --git a/src/guideme/_ask_overloads.py b/src/guideme/_ask_overloads.py index 9bec7c7..4c28a87 100644 --- a/src/guideme/_ask_overloads.py +++ b/src/guideme/_ask_overloads.py @@ -338,9 +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, two runtime keys collide, or `state` or the instructions cannot - be serialised to JSON. + 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 @@ -679,9 +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, two runtime keys collide, or `state` or the instructions cannot - be serialised to JSON. + 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/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" 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 5eb827c..1c9e8f5 100644 --- a/src/guideme/question.py +++ b/src/guideme/question.py @@ -429,7 +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 with a duplicate key, is a `ConfigError`. + 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/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() 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_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: 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)