Metadata-Version: 2.5
Name: keble-keepa
Version: 1.7.2
Author-email: zhenhao-ma <bob0103779@gmail.com>
Requires-Python: <3.14,>=3.13
Requires-Dist: aiohttp<4.0.0,>=3
Requires-Dist: keble-db<2.0.0,>=1.9.0
Requires-Dist: keble-exceptions<1.0.0,>=0
Requires-Dist: keble-helpers<2.0.0,>=1.54.0
Requires-Dist: python-dateutil<3.0.0,>=2
Requires-Dist: requests<3.0.0,>=2
Provides-Extra: test
Requires-Dist: pytest-asyncio<1.0.0,>=0; extra == 'test'
Requires-Dist: pytest-recording<1.0.0,>=0; extra == 'test'
Requires-Dist: pytest<9.0.0,>=8; extra == 'test'
Requires-Dist: vcrpy<8.0.0,>=6; extra == 'test'
Description-Content-Type: text/markdown

# keble-keepa

The source line requires `keble-helpers>=1.54.0` so exact Keepa token/call
usage uses the same public contract in development and registry installs.
Provider pricing remains outside this package.

Side effects if changes:

- Amazon provider services and Data Infra transport these usage events.
- Changing the helper floor requires lock and downstream raw API verification.

`keble-keepa` is Keble's Keepa API and cache package. It owns Keepa object
schemas, sync/async API access, Mongo cache CRUD, batch loading, time
conversion helpers, and pytest fixtures/fakes for downstream packages.

## Verified State

Checked on 2026-08-04 against `pyproject.toml`, package exports,
`keble_keepa/`, and tests.

- Source line: `keble-keepa 1.7.2`
- Latest published PyPI release: `1.4.5`
- Python: `>=3.13,<3.14`
- Critical deps: `aiohttp`, `requests`, `keble-db`, `keble-helpers`,
  `keble-exceptions`, `python-dateutil`
- Source roots: `api/`, `crud/`, `schemas/`, `testing/`, `utils/`
- Public exports: Keepa API objects, the async transport boundary
  (`KeepaAsyncTransportProtocol`, `KeepaAioHttpTransport`, `KeepaAsyncRequest`,
  `KeepaAsyncResponse`, `KeepaReadRequestPlan`), batch loader, cache helpers,
  time conversion helpers, exact-response evidence contracts, and the
  `keble_keepa.testing` pytest plugin.

### Release lines

`1.5.x` is deliberately **git-only**: the Keble connector program pins it by
full commit SHA and these releases are not published to PyPI. Install
`1.5.x` from a pinned Git ref, not from an index.

The newest published release remains `1.4.5`
(`pip install "keble-keepa>=1.4.5,<2"`). Both lines require the shared
`keble-db>=1.9.0` persistence contract and the shared upstream-failure plus
exact-money vocabulary in `keble-helpers>=1.54.0`.

### Legacy-code honesty

This is a long-lived package. It still contains **pre-existing** `try/except`,
`getattr`, and loosely typed (`Any`) boundaries in code that predates the
current workspace rules — for example `KeepaApiMemoryCache.aget` and the
`_get`/`_post`/`_aget`/`_apost` query parameters. Those occurrences are
intentionally left alone rather than mass-refactored. New and changed code in
this package must introduce none.

## Keepa Price Integer Boundary

Keepa price histories are normalized internally to fixed hundredths of a major
currency unit, including synthetic `* 100` normalization for JPY. `Product`
converts that provider-specific integer to exact Decimal major units before
constructing canonical `Money`; the shared ISO exponent then stores USD cents,
JPY yen, and other currencies correctly. Never pass a Keepa price integer
directly to `Money.amount`. Downstream calculations use the public
`keepa_price_int_to_major_units` or `money_from_keepa_price_int` boundary rather
than redeclaring Keepa's scale.

Prompt and recent-series presentation uses the same Decimal decoder. Integral
float containers from parsed CSV rows are accepted, while fractional provider
values and malformed zero-decimal rows fail closed instead of being rounded
into a different displayed price.

The separate statistics fallback can contain fractional transport residue for
zero-decimal currencies. It truncates that candidate to integral major units
before multiplying into the same fixed-hundredths boundary; for example, a JPY
statistics candidate of `7952.56` becomes `795200`, representing JPY 7952.

Side effects if changes:

- Keepa Analysis and Product Report derive price, revenue, and budget facts from
  this exact provider-unit boundary.
- Changing the fixed-hundredths interpretation requires coordinated downstream
  schema, calculation, and persisted-data migration.

## Runtime Contract

- Sync Keepa calls use `requests`; async Keepa calls use `aiohttp` and should
  respect standard proxy environment variables when configured by callers.
- Every async Keepa attempt goes through one narrow protocol boundary,
  `KeepaAsyncTransportProtocol.send(request=KeepaAsyncRequest)`. `KeepaApi`
  accepts an optional `async_transport=`; passing `None` (the default)
  constructs the concrete `KeepaAioHttpTransport` from the client's own
  timeout/proxy session config, so default runtime behavior is unchanged.
  Injecting a transport replaces the ONLY physical attempt path — an
  implementation must perform exactly one attempt and must never retry, sleep,
  or classify failures itself.
- Each async read projects its Keepa path and query parameters through a pure
  `KeepaApi.build_*_request_plan(...) -> KeepaReadRequestPlan` classmethod, so
  path/query correctness is testable without any HTTP layer and the sync and
  async capabilities keep one shared cache key space.
- Bestseller boards expose both `request_best_sellers` and genuine-aiohttp
  `arequest_best_sellers`; the async method uses the same typed Redis/Mongo
  cache key and emits usage only after real upstream I/O.
- **Every stable read capability has a genuine aiohttp counterpart** so async
  consumers never block an event loop: `abrowsing_deal`, `acategory_lookup`,
  `acategory_search`, `alightning_deal`, `amost_rated_seller`,
  `aproduct_finder`, `aproduct_search`, `arequest_best_sellers`,
  `arequest_products`, `arequest_seller_information`, and
  `aretrieve_token_status`. Each async method reuses its sync counterpart's
  Keepa path and query projection, so both share one cache key space.
- `retrieve_token_status` / `aretrieve_token_status` are deliberately
  **uncached** in both directions: they report live remaining token capacity, so
  serving a cached balance would let a caller overspend Keepa's real budget.
- Every typed response retains Keepa's `tokensConsumed` field. Async cache
  misses emit that exact provider token count through
  `UsageAccountingRecorderProtocol`; missing or zero reports remain
  `USAGE_ONLY` and therefore billing-unknown rather than being inferred free.
- Cache hits emit no usage event even though cached response metadata is
  retained for traceability.
- Short-lived Redis response serialization uses Pydantic JSON mode so exact
  Decimal refill/capacity values survive successful live calls and cache replay.
- Every physical Keepa request is attempted once. The package does not sleep,
  retry, recursively split failed batches, or silently drop failed ASINs;
  provider exceptions reach Data Infra intact and durable retry belongs to the
  Platform worker.
- Rejections retain HTTP status, request ID, typed Keepa error data, and exact
  capacity evidence. `refillIn` is milliseconds, negative `tokensLeft` remains
  valid evidence, and gross refill is distinct from token-flow reduction.
- Failed async HTTP responses emit provider-reported `tokensConsumed` usage
  before the typed exception is raised; non-JSON failures remain usage-unknown
  rather than being inferred free.
- Cache reads and writes must stay explicit about key, marketplace, and
  freshness behavior. Do not hide broad cache scans behind convenience APIs.
- Downstream tests should use `keble_keepa.testing` fixtures/fakes before
  inventing package-local Keepa mocks. In particular use
  `RecordingKeepaAsyncTransport` (a typed `KeepaAsyncTransportProtocol` double)
  instead of patching `aiohttp`; it records every attempt and raises
  `KeepaTestTransportExhausted` rather than silently repeating a response.
- `keble_keepa.testing` also exports `KEEPA_LIVE_CANARY_CASES`: one reviewed
  minimum-cost live case per Keepa task group (product, category, brand-seller,
  account). The whole matrix costs three billed tokens plus the free capacity
  read. Consumers such as the Keble connector import these typed values instead
  of copying ASINs, terms, or seller ids.

## Product Finder Brand Scope

`ProductFinderQuery.brand` has always existed on the schema, but the batch
loader never exposed it, so callers could only search by title keywords. Since
`1.6.0`, `KeepaApiBatchLoader.asearch_asins_from_keepa(..., brands=[...])`
forwards an optional brand scope to every physical Product Finder request.

Brand is NOT a title keyword:

| | Title (`title`) | Brand (`brand`) |
|---|---|---|
| Matching | per-word; the title must contain EVERY whitespace-separated word | exact match against Keepa's own stored brand string |
| Case | case-insensitive | Keepa stores brands lowercase, so values must be lowercased |
| Partial | not supported | not supported |

`normalize_keepa_brands` owns that single rule. It trims and lowercases, drops
blanks, deduplicates while PRESERVING the caller's declared order (callers
budget per-brand quotas on that order), and collapses an empty/all-blank list to
`None` — Keepa would treat an empty array as "match nothing" rather than
"unfiltered". Normalization happens exactly once, at the
`asearch_asins_from_keepa` boundary; `_keepa_product_finder` never re-normalizes.

```python
asins = await batch_loader.asearch_asins_from_keepa(
    max_page=1,
    page_size=50,
    marketplace=AmazonMarketplace.US,
    keyword="vacuum cleaner",
    brands=["Dyson", "Shark"],   # -> query brand=["dyson","shark"]
    amongo=amongo,
    extended_aredis=extended_aredis,
)
```

Supplying `brands` with NO search terms runs one brand-only pass per page, so a
caller can scope purely by brand without inventing a placeholder keyword.
Supplying neither still performs zero requests, so an empty search never becomes
an accidental unfiltered scan.

This leaf performs NO title fallback when a brand-scoped query returns nothing.
Search planning, per-brand budgets, alias handling, and fallback strategy belong
to the caller (`keble-products`).

Side effects if changes:
- `keble-products`' Keepa explorer plans per-brand candidate quotas on top of
  the order-preserving normalized list and relies on brand-only queries;
- changing the case or dedupe rule silently changes which products a
  brand-scoped query returns, which changes downstream positioning candidates;
- callers that pass no `brands` are byte-for-byte unchanged (`brand` is omitted
  from the query entirely).

## Exact Raw-Response Evidence

Evidence-aware ingestion is additive. Existing methods such as
`acategory_lookup`, `acategory_search`, `arequest_products`, `aproduct_finder`,
and `aload_asins_from_keepa` still return their historical normalized
models/lists through `NOT_REQUIRED` compatibility mode. Data Infra source
ingestion uses the corresponding `*_captured` method with
`KeepaRawCaptureRequirement.REQUIRED` and one invocation-scoped
`KeepaRawResponseCaptureProtocol` implementation.

`NOT_REQUIRED` controls whether missing complete evidence rejects publication;
it does not disable an adapter that a caller explicitly supplies. Compatibility
wrappers pass `None`, while an explicit optional adapter still captures live
responses and enrolls complete revision-2 cache evidence.

The canonical physical-call order is:

```text
validate capture readiness (no attempt accounting)
  -> construct the local Session/request context
  -> mark one physical provider attempt and receive its opaque token
  -> ask recovery for that exact token
  -> recovered: validate operation/request digest, exact bytes, original
     evidence, and selected response metadata; do not enter provider transport
  -> not recovered: immediately enter the async request context / call sync
     Session.send, read response bytes once, then consume the token while
     durably capturing exact bytes plus selected response metadata
  -> validate the original-occurrence envelope is in the invocation journal
  -> parse the same bytes into a typed value/failure outcome
  -> invalid payload: async paths record one USAGE_ONLY unknown-cost event,
     then raise a finite evidence-bearing KeepaInvalidResponseError
  -> valid payload: record precise provider usage once, retaining the original
     recovered occurrence time
  -> write normalized Redis/Mongo cache data plus evidence envelopes
  -> return KeepaCapturedResponse[normalized result]
```

`validate_ready` / `avalidate_ready` only prove that durable evidence capture
can proceed. They never stand in for provider work. The separate
`mark_physical_provider_attempt_scheduled` /
`amark_physical_provider_attempt_scheduled` boundary creates the durable token.
Required mode first proves that the actual endpoint path and GET query or POST
query/body equal the transport material derived from its typed
`KeepaQueryBaseModel`; a missing identity or mismatch fails before
normalized-cache access, readiness, attempt reservation, or provider I/O. The
marker receives a frozen
`KeepaPhysicalRequestIdentity` containing the finite operation, canonical
secret-free request SHA-256, and bounded occurrence index. Adapters use that
identity for restart-stable reservation keys instead of task scheduling order.
The corresponding `recover_keepa_exact_response` /
`arecover_keepa_exact_response` lookup follows immediately. `None` authorizes
the first transport and makes `Session.send` or async context entry the next
side effect. A returned `KeepaRecoveredRawResponse` skips transport and exact
capture, validates its token, operation, canonical request digest, bytes, and
original enrolled evidence, then follows the same parse/account/cache path.
Session construction, request preparation, environment/adapter resolution,
and async request-context construction therefore fail with zero attempts.
Transport/connect failure after the marker counts exactly one attempt even
when no response exists; without measurable response metadata it emits no
usage event. Async request contexts are synchronously closed if marking raises
or is cancelled, so an unentered aiohttp coroutine cannot leak or produce a
false provider transport. This does not prove a durable reservation is absent:
the adapter may have committed before an acknowledgement was lost or the task
was cancelled. Recovery must reconcile that RESERVED/ambiguous record. A prior
reservation without one verified exact object must block or raise; it must
never return `None` and silently replay provider I/O. Revision-2 normalized
cache replay performs neither
readiness nor attempt marking. The prospective 256-occurrence check remains a
separate pre-I/O admission decision and rejects over-capacity fan-out before
either.

Capture applies equally to successful responses, provider rejections, and
malformed JSON. A successful capture is retained in the invocation journal even
when later Pydantic/error parsing fails. Invalid JSON and schema-invalid 2xx or
declared-JSON rejection bodies become `KeepaInvalidResponseError` with the
finite `SUCCESS_PAYLOAD_INVALID` or `ERROR_PAYLOAD_INVALID` reason. The error
contains only bounded HTTP metadata and immutable evidence; it never retains
raw bytes or the Pydantic exception. Async GET/POST record exactly one
`USAGE_ONLY` event with `tokens_consumed=None` before raising, so Data Infra
marks cost evidence incomplete instead of inventing zero or a precise token
count. Sync GET/POST expose the same error but have no Data Infra accounting
seam.

Capture persistence failure stops parsing, usage recording, normalized cache
writes, and result publication. Sync capture remains on the caller thread so an
adapter may safely retain thread-local sessions, transactions, and tracing;
the adapter must convert storage failures to
`KeepaRawCaptureContractError(CAPTURE_PERSISTENCE_FAILED)`. Only pure sync
Pydantic parsing uses a joined single-worker outcome boundary. Async capture is
gathered as a typed outcome and maps ordinary adapter failures to the same
finite capture reason. The package does not reconstruct JSON from a parsed
model and call it raw evidence.

The minimal downstream contract is exported from `keble_keepa`:

- `KeepaRawResponseCaptureProtocol`
- `KeepaPhysicalRequestIdentity` with operation, request SHA-256, and occurrence
  index
- `KeepaRawResponseAttempt` with opaque `attempt_key`
- `KeepaRawResponseContext`
- `KeepaRawResponseMetadata` with only bounded `Retry-After`
- `KeepaRecoveredRawResponse` with the same attempt, original context/evidence,
  selected metadata, and at most 64 MiB of exact bytes
- `KeepaRawResponseEvidence` and `KeepaEvidenceOccurrenceKey`
- `KeepaRawArtifactReceipt`
- `KeepaRawCaptureRequirement`
- `KeepaRawCaptureContractError` and its finite reason enum
- `KeepaInvalidResponseError` and `KeepaInvalidResponseReason`
- `KeepaCapturedResponse`
- `KeepaCapturedCategoryResponse`
- `KeepaCapturedProductItem` and `KeepaCapturedProductBatch`
- `KeepaOperationCode`, `Sha256`, and
  `canonicalize_keepa_raw_evidence`
- `KEEPA_RAW_CAPTURE_SCHEMA_REVISION` (currently `2`)
- `KEEPA_MAX_RAW_EVIDENCE_OCCURRENCES` (currently `256`)
- `KEEPA_MAX_RAW_RESPONSE_BYTES` (currently `64 MiB`, also the receipt limit)

Every live sync request uses `requests` streaming and every async request uses
`aiohttp` chunk iteration. Both readers stop on the first byte beyond the
64 MiB contract before capture, parsing, usage extraction, or cache writes.
`Content-Length` is only an early-rejection hint: absent, malformed, or falsely
small values still pass through the same max-plus-one streamed authority. An
oversized response raises finite
`KeepaInvalidResponseError(RESPONSE_BODY_TOO_LARGE)` without raw bytes or a
normalized result; async calls record one `USAGE_ONLY` event because a paid
response occurred but precise tokens are unknowable. The durable attempt stays
unsettled for host recovery and must not authorize a repeated provider call.

The Amazon host adapter must explicitly declare both failure families. It must
classify `KeepaInvalidResponseError` before the broader
`KeepaApiException` branch so HTTP 2xx malformed data becomes
`INVALID_RESPONSE`, not a generic HTTP-status result. It must also declare
`KeepaRawCaptureContractError`; at minimum,
`CAPTURE_PERSISTENCE_FAILED` and `ATTEMPT_RECOVERY_PENDING` are capture/runtime
settlement failures rather than provider request-validation failures. The host
must preserve the captured usage-only event as `usage_complete=False` and must
not synthesize usage when capture persistence failed.

Evidence-aware entry points are:

- `KeepaApi.acategory_lookup_captured`
- `KeepaApi.acategory_search_captured`
- `KeepaApi.product_finder_captured` / `aproduct_finder_captured`
- `KeepaApi.request_best_sellers_captured` /
  `arequest_best_sellers_captured`
- `KeepaApi.request_products_captured` / `arequest_products_captured`
- `KeepaApi.aget_request_products_cache_parts_captured`
- `KeepaApi.aget_request_product_items_cache_parts_captured`
- `KeepaApiBatchLoader.asearch_asins_from_keepa_captured`
- `KeepaApiBatchLoader.aload_asins_from_keepa_captured`
- `KeepaApiBatchLoader.aload_asin_items_from_keepa_captured`

Example Data Infra call:

```python
captured = await batch_loader.aload_asin_items_from_keepa_captured(
    amongo=amongo,
    extended_aredis=aredis,
    asins=requested_asins,
    marketplace=marketplace,
    raw_response_capture=invocation_capture,
    capture_requirement=KeepaRawCaptureRequirement.REQUIRED,
)
products = [item.product for item in captured.items]
product_evidence = {
    item.asin: item.raw_evidence
    for item in captured.items
}
raw_evidence = captured.raw_evidence
```

Category lookup/search use the same invocation capture directly:

```python
captured_categories = await keepa_api.acategory_lookup_captured(
    category_query,
    extended_aredis,
    amongo=amongo,
    raw_response_capture=invocation_capture,
    capture_requirement=KeepaRawCaptureRequirement.REQUIRED,
    usage_recorder=usage_recorder,
)
categories = captured_categories.response.categories
category_response_evidence = captured_categories.raw_evidence
```

Every mapping returned by one category lookup/search comes from exactly one
physical HTTP body. `KeepaCapturedCategoryResponse` therefore allows at most one
response envelope; a multi-envelope Redis/Mongo value fails with the typed
`EVIDENCE_ASSOCIATION_CONFLICT` before any evidence is enrolled into the current
invocation. An empty category mapping still retains its response envelope because
that body is the evidence for the no-match result.

Data Infra uses the item-associated method for product/listing metrics. Each
ordered `KeepaCapturedProductItem` carries only the original response
envelope(s) that contained that exact normalized product. Cache rows expose
their repeated original batch evidence before aggregation; live chunks apply
their response envelope only to products returned by that chunk. Duplicate
requested ASINs preserve caller order without duplicate paid requests. A Keepa
response that omits a requested product contributes to aggregate evidence but
does not manufacture an item association. Differing normalized snapshots for
one ASIN fail with a typed association conflict instead of choosing an
arbitrary aggregate artifact SHA.

One capture adapter is shared by finder/product fan-out and must make async
attempt marking, capture, evidence reuse, and `invocation_evidence()`
concurrency-safe. Each
`KeepaRawResponseEvidence` contains the original sanitized response context, the
content-addressed artifact receipt, and a bounded opaque
`evidence_occurrence_key` supplied by that adapter. The adapter durably maps the
opaque key to its original paid-attempt/execution provenance. Keepa and
downstream consumers use it only as a lookup/enrollment identity: they do not
decode it, derive a new one from an artifact digest, or mint one during replay.
`reuse_evidence` / `areuse_evidence` must return the original envelope unchanged.

Receipt object keys remain relative safe paths whose terminal segment is
exactly the lower-case SHA-256, without an extension. The same body may appear
under different artifact namespaces or at different capture times. Distinct
occurrence keys/context remain distinct physical evidence even when the store
reuses the same digest/object key. Canonicalization orders original capture
chronology first and collapses only an exact duplicate envelope; byte count is
immutable for one digest/object-key identity. Downstream Data Infra publishes
the bounded provider-owned envelope tuple rather than fabricating a current
execution or reconstructed raw-exchange archive.
One provider result may contain at most
`KEEPA_MAX_RAW_EVIDENCE_OCCURRENCES=256` distinct envelopes after exact
duplicate collapse. Captured finder/product batches validate their prospective
physical-call count, including existing invocation/cache evidence and planned
missing chunks, before paid provider I/O. Occurrence 256 is valid; occurrence
257 fails with `EVIDENCE_OCCURRENCE_LIMIT_EXCEEDED` before readiness, capture,
or usage accounting.
Content types are normalized to the same bounded media-type grammar (invalid or
oversized values become `application/octet-stream`), and provider request IDs
are stripped of control/non-ASCII bytes and bounded to 256 characters before
the capture context is constructed. Captured failure metadata applies those
same rules and retains only a printable, 256-character Retry-After hint, so
hostile headers cannot block body persistence or escape through downstream
errors, logs, and storage.

Required aggregate operations cannot return an evidence-empty success. Empty
product input, a finder plan with no search term, and a finder plan with no page
are rejected before provider I/O. Search terms are copied, deduplicated in
caller order, then capped at Keepa's 50-term limit, preventing duplicate paid
physical calls and caller-list mutation.

### Normalized cache migration and cost

Raw bytes live only in the artifact store. Redis and Mongo contain the parsed
normalized response, capture schema revision, and immutable evidence tuple.
The migration policy is:

- legacy: `capture_schema_revision=None` and no evidence;
- receipt-only legacy: revision `1`, normalized-data-only after the superseded
  receipt tuple is consumed;
- complete current evidence: revision `2` with at least one
  `KeepaRawResponseEvidence`.

Revision/evidence mismatches and unsupported revisions fail closed as migration
misses/errors. `NOT_REQUIRED` callers may read historical flat, revision-`None`,
or revision-1 normalized values, but receipt-only revision 1 returns no evidence
and is never enrolled as current provenance. `REQUIRED` rejects those rows
before normalized-result construction and treats them as financially admitted
provider misses. A valid revision-2 hit replays the original envelope unchanged,
performs no provider I/O or capture, and emits no provider usage. Mixed per-ASIN
batches reuse only complete revision-2 rows and fetch legacy/missing ASINs.
Category lookup/search use their existing 30-second Redis and configured Mongo
cache windows through this same policy; no parallel category cache was added.
After process restart, an empty Redis layer can promote a complete Mongo envelope
unchanged, while a legacy category mapping is a paid physical miss in required
mode and is never relabeled as exact evidence.
The normalized revision-1 shape deliberately survives
`model_validate -> model_dump -> model_validate`; unknown revisions and every
revision/evidence ambiguity still fail validation.

This creates a one-time financial effect when evidence-required ingestion is
enabled: historical cache hit rate cannot be assumed in job forecasts because
legacy rows must be repopulated by paid provider calls. Admission and cost
estimation should budget those calls until complete revision-2 rows replace
the legacy working set.

## Testing

```bash
uv sync --group test
RUN_INTEGRATION=1 RUN_REAL_DB=1 RUN_LOCAL_STACK=1 RUN_DB_STACK=1 \
  uv run pytest -q -m "not live and not container"
uv run pytest -q -m "not live and not slow and not eval and not local_stack and not db_stack and not container"
uv run pytest -q -m eval
npx --yes pyright .
```

The first command is the local-full Keble development proof and runs
Mongo-backed integration tests with isolated cache collections. The second is
the portable-offline fallback for no-DB machines. Selecting `-m eval` is the
eval opt-in; there is no generic `RUN_EVALS` gate.

Use explicit isolated local-full configuration rather than inheriting a Mongo
replica-set address that advertises an unreachable hostname:

```bash
MONGO_DB_URI='mongodb://localhost:27217/?replicaSet=rs0&directConnection=true' \
MONGO_DB_DATABASE='__keble_keepa__pytest__local' \
REDIS_URI='redis://localhost:6479/13' \
RUN_INTEGRATION=1 RUN_REAL_DB=1 RUN_LOCAL_STACK=1 RUN_DB_STACK=1 \
  uv run pytest -q -m 'not live and not container'
```

`.github/workflows/quality.yml` runs the same four gates in CI: the
portable-offline lane, an isolated Mongo/Redis local-full lane with an explicit
post-run namespace-cleanliness check, Pyright, and `uv build` plus a clean
wheel install. It also fails closed with `CI_CREDENTIAL_PREREQUISITE` if a
private Git dependency is ever added without provisioned read access. CI never
spends Keepa tokens: there is no live workflow and every live test is
deselected by marker.

Real Keepa calls belong only in marked/env-gated live tests.
Package-owned test helpers live under `keble_keepa.testing`; do not recreate
root-level `tests/config.py`, root test packages, or legacy `tests/test_*`
source folders.

Mongo/Redis/Keepa live settings load explicit process env first, then
`tests/.env`, then the umbrella `keble.backend/.env` through
`keble_db.testing.resolve_test_env`. Set
`KEBLE_BACKEND_ENV_FILE=/path/to/.env` from unusual worktree or CI layouts.
Do not copy backend secrets into this repo.

Live Keepa canaries should make one real provider call against an isolated cache
collection. Do not add Keepa response-cache replay to live tests for speed;
cache correctness belongs in integration tests with fake/shared fixtures.

Async cache tests receive a process-unique namespaced `ExtendedAsyncRedis` and
delete all owned keys after every test. Keep this isolation when adding new
cache consumers; an un-namespaced wrapper is a test and production leak risk.

## Change Logs

Historical release notes and long AI change history live under `logs/`.

## Product release metadata

`Product.release_date_observation` parses Keepa `YYYY`, `YYYYMM`, and
`YYYYMMDD` values into a typed interval with retained precision. Consumers must
use the interval for recency decisions; they must not invent a month or day for
partial dates or substitute offer-history timestamps. `original_language_code`
normalizes Keepa's finite language names to ISO codes and returns `None` when
the source does not declare a recognized language.

## Side Effect If Changes

- Backend report generation, product discovery, `keble-keepa-analysis`, and
  AMZ product-report workflows depend on this package for Keepa access.
- Cache query/index changes can affect production cache repair and live
  report correctness; verify query shape and cleanup.
