Skip to content
Merged
49 changes: 4 additions & 45 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 18 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
25 changes: 17 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
45 changes: 34 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
|---|---|---|
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:

```
Expand Down
16 changes: 11 additions & 5 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading