libex-core 0.20.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- libex_core/CHANGELOG.md +379 -0
- libex_core/__init__.py +41 -0
- libex_core/__main__.py +10 -0
- libex_core/asin.py +38 -0
- libex_core/audible/__init__.py +12 -0
- libex_core/audible/_concurrency.py +265 -0
- libex_core/audible/_retry.py +84 -0
- libex_core/audible/authors/__init__.py +14 -0
- libex_core/audible/authors/by_name.py +352 -0
- libex_core/audible/authors/catalog.py +1211 -0
- libex_core/audible/authors/profile.py +106 -0
- libex_core/audible/authors/screens.py +879 -0
- libex_core/audible/books.py +869 -0
- libex_core/audible/chapters.py +261 -0
- libex_core/audible/client.py +899 -0
- libex_core/audible/extras.py +321 -0
- libex_core/audible/releases.py +357 -0
- libex_core/audible/search.py +139 -0
- libex_core/audible/series.py +168 -0
- libex_core/cli/__init__.py +7 -0
- libex_core/cli/_args.py +140 -0
- libex_core/cli/_db_args.py +126 -0
- libex_core/cli/_render.py +618 -0
- libex_core/cli/_run.py +58 -0
- libex_core/cli/_shaping.py +105 -0
- libex_core/cli/commands/__init__.py +4 -0
- libex_core/cli/commands/abs_search.py +83 -0
- libex_core/cli/commands/author.py +110 -0
- libex_core/cli/commands/book.py +146 -0
- libex_core/cli/commands/completion.py +40 -0
- libex_core/cli/commands/config.py +34 -0
- libex_core/cli/commands/db.py +572 -0
- libex_core/cli/commands/narrator.py +44 -0
- libex_core/cli/commands/releases.py +146 -0
- libex_core/cli/commands/search.py +70 -0
- libex_core/cli/commands/series.py +79 -0
- libex_core/cli/environment.py +151 -0
- libex_core/cli/exit_codes.py +96 -0
- libex_core/cli/main.py +69 -0
- libex_core/cli/output.py +86 -0
- libex_core/cli/parser.py +90 -0
- libex_core/cli/session.py +112 -0
- libex_core/cli/store_state.py +43 -0
- libex_core/exceptions.py +99 -0
- libex_core/log_safety.py +111 -0
- libex_core/lookup/__init__.py +58 -0
- libex_core/lookup/_common.py +7 -0
- libex_core/lookup/_shaping.py +76 -0
- libex_core/lookup/_store.py +497 -0
- libex_core/lookup/author_books.py +473 -0
- libex_core/lookup/authors.py +176 -0
- libex_core/lookup/books.py +571 -0
- libex_core/lookup/releases.py +240 -0
- libex_core/lookup/search.py +390 -0
- libex_core/lookup/series.py +290 -0
- libex_core/models.py +630 -0
- libex_core/py.typed +0 -0
- libex_core/shaping.py +173 -0
- libex_core/storage/__init__.py +68 -0
- libex_core/storage/base.py +14 -0
- libex_core/storage/dialect.py +357 -0
- libex_core/storage/filtering.py +204 -0
- libex_core/storage/merge.py +266 -0
- libex_core/storage/migrations/env.py +65 -0
- libex_core/storage/migrations/script.py.mako +28 -0
- libex_core/storage/migrations/versions/438dbe70d041_create_core_tables.py +258 -0
- libex_core/storage/models.py +505 -0
- libex_core/storage/read/__init__.py +11 -0
- libex_core/storage/read/_compat.py +216 -0
- libex_core/storage/read/books.py +541 -0
- libex_core/storage/read/people.py +379 -0
- libex_core/storage/read/series.py +151 -0
- libex_core/storage/read/shapes.py +271 -0
- libex_core/storage/read/stats.py +72 -0
- libex_core/storage/sorting.py +72 -0
- libex_core/storage/store.py +576 -0
- libex_core/storage/types.py +55 -0
- libex_core/storage/upgrade.py +147 -0
- libex_core/storage/write/__init__.py +41 -0
- libex_core/storage/write/books.py +202 -0
- libex_core/storage/write/entities.py +437 -0
- libex_core/storage/write/params.py +103 -0
- libex_core/storage/write/serialize.py +75 -0
- libex_core/storage/write/statements.py +481 -0
- libex_core/storage/write/support.py +164 -0
- libex_core/text.py +49 -0
- libex_core-0.20.0.data/data/share/bash-completion/completions/libex-core +385 -0
- libex_core-0.20.0.data/data/share/fish/vendor_completions.d/libex-core.fish +447 -0
- libex_core-0.20.0.data/data/share/man/man1/libex-core.1 +1556 -0
- libex_core-0.20.0.data/data/share/zsh/site-functions/_libex-core +699 -0
- libex_core-0.20.0.dist-info/METADATA +215 -0
- libex_core-0.20.0.dist-info/RECORD +95 -0
- libex_core-0.20.0.dist-info/WHEEL +4 -0
- libex_core-0.20.0.dist-info/entry_points.txt +3 -0
- libex_core-0.20.0.dist-info/licenses/LICENSE +21 -0
libex_core/CHANGELOG.md
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `libex_core` are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
`libex_core` is below 1.0, so this project borrows post-1.0 MAJOR
|
|
7
|
+
discipline into the MINOR slot rather than relying on SemVer's 0.x carve-out:
|
|
8
|
+
while pre-1.0, MINOR carries any breaking change, and PATCH is reserved for
|
|
9
|
+
fixes that have no effect on the package's public surface.
|
|
10
|
+
|
|
11
|
+
`libex_core` is not published to PyPI. It is packaged for it as the
|
|
12
|
+
`libex-core` distribution, and any first publish is a 0.x release.
|
|
13
|
+
Entries below that predate publication are historical record for whoever
|
|
14
|
+
embeds this package, not evidence that anyone consumed a given version at the
|
|
15
|
+
time it was cut.
|
|
16
|
+
|
|
17
|
+
## [0.20.0]
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **Every `libex_core.lookup` function takes a keyword-only `store`, a `LocalStore`.** Without it nothing changes: nothing is stored and an outage is an outage. With it, what Audible answered is written to the store under the same merge rules the hosted service uses (a stored value is never replaced by less, and the links between records only grow), and the book, series or author returned is the row the store then holds, not the raw answer, with one addition: a book served from its stored row also carries Audible's series entry for any link left out of the write (see below), in Audible's order with the row's own entries after them. The store keys books and series by ASIN alone, so a row belongs to one region, and one stored for another region is never overwritten and never served for this one. What the store hands back after a write, and what it hands back when Audible cannot be reached, is only ever a row stored for the region asked; when a stored row cannot be served (it belongs to another region, the book was skipped, or the write failed) the caller gets Audible's live book. A write skips, per ASIN, a book whose stored row belongs to another region, together with its links and chapters, and a series profile is gated the same way. A series link from a book to a series stored for another region is left out of the write, so that series' title, description and update time are not touched through it and the link is not added. The book is still served with Audible's series entry for that link, so the caller does not lose it; only what is stored is limited. Each skip logs a warning, and the rest of a list is written as usual. The check runs inside the write, so a concurrent writer cannot slip past it. One window remains, on PostgreSQL: two transactions that first insert the same new ASIN for different regions at the same moment cannot see each other, and the row keeps the region of whichever commits first. A store that is closed or was never opened is refused with `StoreClosed` before anything is requested. The storage libraries are imported only when a store is passed, so `libex_core.lookup` still works without the `storage` extra.
|
|
21
|
+
- **With a store, a lookup answers from it when Audible cannot be reached.** `get_book`, `get_books`, `get_chapters`, `get_series`, `get_series_books` and `get_author` return the stored copy instead of raising, and `get_books` fills an ASIN whose request failed from the store, leaving in `notFetched` only what the store lacks too. Only records stored for the region asked are used: a book ASIN is region-specific, so a copy another marketplace stored is not offered in its place. When some books of a `get_books` or `get_series_books` list were answered from the store, the whole list is in the order requested (series order for a series) rather than Audible's order. When Audible confirms it has no record, that is still `NotFoundException` (or `notFound`, or `placeholderRecords`), never overruled by stored data, and a lookup that fails with nothing stored raises as before.
|
|
22
|
+
- **The stored copy answers in a few more places, and not in others.** `search_series` adds the stored series whose names match, after Audible's, up to ten. `get_author_books` by ASIN unions the ASINs of the author's stored books into discovery on every call, and takes the author's name from the stored profile when there is one, so what was found once is not lost to a thin walk; a stored read that fails counts as a failed discovery source, not as an author with no books. `quick_search` and the Audiobookshelf quick search try the stored books for an `Author - Title` query that the catalog could not answer. A catalog search, the catalog step of an author search by name, a release window and the author-books-by-name walk are not answered from the store when Audible cannot be reached: they raise as before. `new_releases`, `coming_soon` and the searches write the books they find. `categories` accepts `store` and does nothing with it: the taxonomy is never stored.
|
|
23
|
+
- **Chapters are stored only for a book already in the store.** A chapter listing hangs off its book's record, so `get_chapters` with a store keeps the richer of the stored and the offered listing when the book is held, and otherwise returns Audible's listing unstored. A book held for another region does not count as held: its chapters are not stored.
|
|
24
|
+
- **A failed write is reported, not raised.** The live answer is still returned and the failure is logged. `BookList` (the author-books and series-books results) and `Hydration` carry `store_write_failed`, True when a fetched book could not be written, and `from_store`, the ASINs of the books answered from the store because Audible could not answer for them. Every answer given from the store during an outage is logged as a warning.
|
|
25
|
+
- **`LIBEX_CORE_STORAGE` turns the local store on for the command line.** `off` or empty, the default, keeps nothing. `sqlite` uses `libex.db` in the platform's per-user data directory; an absolute path uses that SQLite file; a `sqlite:///` URL with an absolute path or a `postgresql://` URL uses that database. A relative path, a relative SQLite URL and an in-memory SQLite database are refused. The value is read from the environment only and never printed. SQLite needs the `storage` extra, and PostgreSQL the `postgres` extra.
|
|
26
|
+
- **New `libex-core db` commands read the store.** `db upgrade` creates or brings the schema up to date, and is the only command that changes its structure; `db status` prints whether the store is `ok`, `not-initialised`, `outdated`, `ahead` or `foreign`, with its revision, and exits 0 only for `ok`. `db book`, `books`, `chapters`, `sku`, `author`, `author-books`, `series`, `series-books`, `narrators`, `narrator-books`, `genres`, `plans`, `plan`, `vvab`, `new-releases`, `coming-soon` and `stats` each print JSON in the shape of the matching hosted stored-data route, make no request to Audible and need no proxy. `book sku` prints the stored books of a SKU group and is answered from the store alone. A command that finds nothing exits 3.
|
|
27
|
+
- **With `LIBEX_CORE_STORAGE` set, the lookup commands open the store first and store what they fetch.** A store that is not ready stops the command before any request is made. A lookup answered from the store because Audible was unreachable is printed as a success.
|
|
28
|
+
- **The package has a PyPI long description**, and the documented public modules (`libex_core.asin`, `libex_core.audible.client`, `libex_core.exceptions`, `libex_core.models`) declare `__all__`. Importing `libex_core` itself still exposes only `__version__`; use the module that holds the name. `libex_core.models` also gains `NarratorProfileResponse` and `AudioSampleResponse`, the narrator profile shape, which is what `db narrators` prints.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- **`get_series_books` can report `discovery-incomplete`.** When Audible could not give the member list and stored members answer in its place, the list is returned with `complete` False and `discovery-incomplete`, because nothing confirms the store holds the whole series. Without a store a series still never reports it, and `hydration-deadline` never appears for a series.
|
|
32
|
+
- **The command-line exit status 5 now also covers storage.** It is returned when storage is off for a `db` command or `book sku`, when the `storage` or `postgres` extra is missing, when the store is unreachable or not ready, and when the store's file or directory cannot be read, created or written. That last message is fixed text and does not include the path.
|
|
33
|
+
|
|
34
|
+
## [0.19.0]
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- **`libex_core.storage.LocalStore(url)` is a Libex database you own, on SQLite or Postgres.** It takes a `sqlite` or `sqlite+aiosqlite` URL, or a `postgresql` or `postgresql+asyncpg` URL, and nothing else. `session()` gives a read session, and `write()` gives a write session that is serialised against other writers, commits when the block ends and rolls back if it raises. The store is an async context manager; `close()` releases it and is safe to call twice. `LocalStore` and its errors (`StoreError`, `StoreConfigError`, `StoreConnectionError`, `StoreNotInitialised`, `StoreOutdated`, `ForeignDatabase`, `StoreClosed`, `StoreMigrationError`) are exported from `libex_core.storage`.
|
|
38
|
+
- **`upgrade()` is the only thing that creates tables.** It runs the package's own migration chain, which records its progress in its own version table, so it does not meet a hosted Libex's migrations. It runs in one transaction, so a failure leaves the database as it was, and returns the revision it reached, logging the outcome. SQLite migrations run with foreign keys off and are checked afterwards: if a migration would leave rows that break a foreign key it is rolled back and `upgrade()` raises `StoreMigrationError`, a `StoreError` that carries no row data. A new SQLite file is created with mode 0600, and its folder, if new, with mode 0700 (Linux and macOS); a file that already exists is left as it is, with a warning if other users can read it. SQLite is switched to write-ahead logging during the upgrade. On SQLite, the region and genre type columns carry a CHECK constraint, so a value outside the allowed set is refused as Postgres refuses it.
|
|
39
|
+
- **`open()` never upgrades, and refuses a database that is not ready.** An empty database raises `StoreNotInitialised`. A database that has tables but no record of this package's migrations, which includes a hosted Libex database, raises `ForeignDatabase` and is not touched. A database behind this version raises `StoreOutdated`, and so does one made by a newer `libex-core`. `upgrade()` refuses a foreign or newer database the same way. `status()` reports where a database stands without changing it, and does not create a missing SQLite file.
|
|
40
|
+
- **URLs are checked before anything connects.** A SQLite URL takes a file path and nothing else: no query string, host, credentials or `file:` URI, and a symbolic link or a non-file path is refused. A Postgres URL needs a host, a user and a database name, and the port defaults to 5432. Its only query keys are `ssl` (`disable`, `allow`, `prefer`, `require`, `verify-ca` or `verify-full`, default `prefer`) and `application_name`. Anything else raises `StoreConfigError`.
|
|
41
|
+
- **Errors never contain the URL or the password.** Messages say what is wrong without quoting the URL, and `repr` shows only the backend. `StoreConnectionError` carries the name of the driver's error class and nothing the driver said.
|
|
42
|
+
- **A Postgres connection takes its settings only from the URL.** The `PG*` environment variables and `~/.pgpass` are never read, and neither are a service file or `~/.postgresql`. A URL without a password connects without one. The certificate trust store used by `verify-ca` and `verify-full` is the system's, which `SSL_CERT_FILE` and `SSL_CERT_DIR` can replace, so those two variables still affect a connection.
|
|
43
|
+
- **The default `ssl=prefer` does not protect against an active attacker.** `prefer`, `allow` and `require` do not verify the server's certificate (`allow` also tries an unencrypted connection first), so someone who can intercept the connection can read or alter it. On a network you do not trust, use `verify-full`.
|
|
44
|
+
- **A failed login is not retried over the other transport.** Under `prefer` and `allow`, the plain (or encrypted) attempt happens only when the server refuses the first transport; a wrong password or other rejected login raises `StoreConnectionError` straight away.
|
|
45
|
+
- **Postgres needs the `postgres` extra.** If asyncpg is not installed, `LocalStore` raises `StorageUnavailable`, whose message names `libex-core[postgres]`.
|
|
46
|
+
|
|
47
|
+
No command in this release uses a store, and nothing opens one unless the caller does.
|
|
48
|
+
|
|
49
|
+
## [0.18.0]
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
- **`libex_core.lookup` now covers series search, authors, an author's books, new releases, coming soon and categories.** Eight more lookups join the ten from 0.14.0, in the same shape (the request callable first, `region` a keyword defaulting to `us`, the published model back). `search_series(get, name)` returns a list of `SeriesResponse`. `get_author(get, asin)` returns an `AuthorResponse` and `search_authors(get, name)` a list of them. `new_releases` and `coming_soon` take `days` and an optional `category` and return a list of `BookResponse`; `categories` returns the genre tree. As before they are the hosted live path without the cache or the database: Audible's answer is normalized and settled, and nothing is stored.
|
|
53
|
+
- **`get_author_books` and `get_author_books_by_name` return a `BookList`: `books`, `complete` and `incomplete_reasons`.** The hosted routes tell a caller whether a list is whole in the `X-Libex-Complete` response headers; a library caller has none, so the answer carries it. `complete` is False when discovery stopped before confirming it had every ASIN, or when fewer books came back than ASINs were found, and `incomplete_reasons` then names why, drawn from `INCOMPLETE_REASONS`: `discovery-incomplete`, `hydration-deadline`, `hydration-failed` and `hydration-not-found`. It is empty exactly when `complete` is True. A list that is not complete is still returned, since what was gathered is worth having; nothing here retries or finishes it afterwards. Those judgements are made before any filter, so filtering a list shorter does not make it incomplete. By ASIN, discovery and the hydration that follows share one 25 second budget, and a prolific author's lookup that reaches it comes back incomplete with `hydration-deadline` or `discovery-incomplete`. By name, the catalog is searched on the exact name, ignoring case, and a walk that stopped before a confirmed end returns what it gathered with `discovery-incomplete`. Without a store, the author's books are those Audible lists: unlike the hosted route, there are no stored books to union in.
|
|
54
|
+
- **`new_releases` and `coming_soon` take `days` of 30, 60, 90, 120, 240 or 365 (`RELEASE_WINDOWS`, default 30) and an optional numeric `category` id.** Without a category the result is a live sample of the window, not all of it, because Audible caps an uncategorized catalog query at a few hundred results. A category scopes the walk, and the ids come from `categories`. Nothing fans out across categories on its own; reaching a whole window means one call per category and merging the results. `new_releases` returns newest first and skips pre-orders; `coming_soon` returns soonest first and skips titles already out. A `days` outside the windows or a `category` that is not numeric is `ValueError`, before any request, and the message does not repeat the value.
|
|
55
|
+
- **`categories(get, *, region, flat, depth)` returns Audible's genre taxonomy** as a nested tree of `CategoryNode`, or with `flat=True` a flat list of `FlatCategoryNode` in which each node carries its ancestors root-first. `depth` limits the levels (1 is the top level only); below 1 is `ValueError`. It is fetched live on every call.
|
|
56
|
+
- **`get_books` and `get_series_books`, and the new author-books and release lookups, take `filters`, `sort` and `order`.** The filter names and sortable fields are those of `libex_core.shaping`. A filter or sort outside them, or an `order` other than `asc` or `desc`, is `ValueError` before any request is made. Shaping applies to the books only: in `get_books` it runs after `notFound`, `placeholderRecords` and `notFetched` are worked out, so a book that was found but filtered out is never reported missing. Defaults follow the hosted routes: `asc`, except `new_releases`, which is `desc`; with no `sort`, the books keep the order the lookup produced.
|
|
57
|
+
- **New `libex-core` commands: `series search`, `author get`, `author search`, `author books`, `author books-by-name`, `releases new`, `releases coming-soon` and `releases categories`.** Each prints JSON on standard output and takes `--region`. `releases new` and `releases coming-soon` take `--days` (the six windows, default 30) and `--category ID`; `releases categories` takes `--flat` and `--depth` (1 to 9). `book bulk`, `series books`, `author books`, `author books-by-name`, `releases new` and `releases coming-soon` take one flag per filter (for example `--language`, `--genre`, `--longer-than`), plus `--sort` and `--order`. `series books`, `author books` and `author books-by-name` print the list of books alone, as the hosted routes do; when the list may not be whole it is still printed, a one-line notice naming the reasons goes to standard error, and the status is 0. Exit statuses are as before: 2 for a rejected argument, 3 for nothing found, 4 for Audible unreachable.
|
|
58
|
+
- **The generated completions and man page cover the new commands, their flags and their values.**
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
- **`get_series_books` returns a `BookList` instead of a list of `BookResponse`.** In 0.14.0 it returned the list itself; the books are now in `.books`, beside `complete` and `incomplete_reasons`, the same shape the author-books lookups return. Callers that iterated, indexed or took the length of the result must use `.books`. `complete` is False, with `hydration-failed` and/or `hydration-not-found` in `incomplete_reasons`, when a member's request failed or Audible has no record of it; what was gathered is still returned. The member list is one request, so `hydration-deadline` never appears for a series, and `discovery-incomplete` appears only when a store answers in place of a member list Audible could not give, as the entry for store-backed lookups describes. Completeness is judged before any filter. The `series books` command now prints the books alone and sends the one-line incompleteness notice to standard error, as the author commands do.
|
|
62
|
+
- **A `category` is checked more strictly.** A value with a trailing newline is no longer accepted as numeric; it is `ValueError`, as any other non-numeric value is.
|
|
63
|
+
- **Every lookup reports an outage with the same message, "Audible unavailable".** The author-books lookups previously said "Audible unavailable for author books". Branch on the exception type, not its text.
|
|
64
|
+
- **Two lookups differ from the hosted routes, on purpose.** A 404 from Audible while building new releases, coming soon or categories raises `NotFoundException`; the hosted routes answer 503 there. `search_series` and `search_authors` raise `AudibleAPIException` when candidates were found and every one of them failed to fetch, because an empty list would pass an outage off as an absence; the hosted routes answer 404 there. When nothing was found at all, both raise `NotFoundException`.
|
|
65
|
+
- **The command line no longer repeats what was typed when it refuses something.** A value that is not one of the allowed choices is refused with the list of choices and without the value, and unknown arguments are refused with a fixed message that does not list them. Previously both were quoted back.
|
|
66
|
+
- **Flag abbreviations are no longer accepted.** A flag must be given by its exact name: `--reg us` for `--region us`, which worked in 0.14.0, is now refused as an unrecognized argument.
|
|
67
|
+
|
|
68
|
+
## [0.17.0]
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
- **`libex_core.storage.read` reads the stored catalog.** It covers books (by ASIN, in bulk, by SKU group, search, plan, VVAB, new releases, coming soon, the distinct plans and genres, and tracks), authors and their books, narrators and their books, series and their books, and `count_stored` for per-table counts. Each function is async and takes a SQLAlchemy session as its first argument, and the results are the same dictionaries the hosted service serves from its stored-data routes. The building blocks that produce those dictionaries (book, narrator and series-position shaping) are in `libex_core.storage.read.shapes`.
|
|
72
|
+
- **The readers raise on failure.** A broken database raises; it is never turned into an empty result, so a caller can tell a missing book (`None` or an empty list) from a database that could not be read.
|
|
73
|
+
- **`libex_core.storage.filtering` and `libex_core.storage.sorting` build the filters and the sort for those reads.** `apply_book_filters`, `apply_narrator_filters`, `apply_genre_filter`, `apply_category_filter` and `apply_sort` take and return SQLAlchemy statements, and the sort allow-lists for books and narrators are published there.
|
|
74
|
+
- **The reads work on SQLite as well as Postgres.** Case-insensitive matching, JSON containment and key checks, series-position classification and putting missing values last each have a SQLite equivalent. The statements Postgres receives are unchanged.
|
|
75
|
+
|
|
76
|
+
### Changed
|
|
77
|
+
- **Known difference: text sort order is not the same on both backends.** Sorting by title, publisher, narrator name or a non-numeric series position follows the database's locale collation on Postgres and plain byte order on SQLite, so mixed-case or accented text can come back in a different order.
|
|
78
|
+
- **Known difference: a search pattern ending in a lone backslash.** On Postgres it is an error; on SQLite it is accepted and matches nothing.
|
|
79
|
+
- **Known difference: series-position ordering and non-ASCII digits.** SQLite treats only ASCII digits (0-9) as digits when deciding whether a series position is numeric. Postgres's `\d` may also accept other Unicode digits, depending on locale. This affects series-position ordering only.
|
|
80
|
+
- **Known difference: distinct plan names that are not strings.** Plan names are strings by contract. If a plan list holds a non-string JSON element, SQLite renders it with its own spacing, which can differ from Postgres's.
|
|
81
|
+
- **SQLite version requirements.** The reads need SQLite 3.30 or later for `NULLS LAST`, and the JSON1 functions, which are built in from 3.38.
|
|
82
|
+
|
|
83
|
+
Still groundwork: nothing opens a database or creates tables yet, and no command uses these readers. They need a session you have built yourself over tables you have created.
|
|
84
|
+
|
|
85
|
+
## [0.16.0]
|
|
86
|
+
|
|
87
|
+
### Added
|
|
88
|
+
- **`libex_core.storage.dialect` prepares a SQLite engine to run the same SQL the hosted service runs on Postgres.** `configure_sqlite(engine)` turns on foreign key enforcement, which SQLite leaves off, and a busy timeout so two processes sharing one file queue rather than fail on the first overlap. File databases are put in write-ahead logging mode, and writes open with `BEGIN IMMEDIATE` so a transaction that reads and then writes cannot find the write lock taken from under it. It also registers a `lower()` that matches Postgres and the JSON containment and merge functions the merge rules need. The JSON functions refuse a stored document nested more than 200 levels deep with a `ValueError`. It accepts a sync or an async engine, is safe to call twice on the same one, and raises `ValueError` for an engine that is not SQLite. SQLite 3.35 or newer is required: below that, `configure_sqlite` and `require_sqlite_version()` raise `SQLiteTooOld`, naming both versions.
|
|
89
|
+
- **`libex_core.storage.merge` holds the keep-the-richer-data merge rules, and they give identical results on SQLite and Postgres.** Stored data is never replaced by less. A blank or missing incoming value keeps what is stored, and a longer description wins over a shorter one. Extras only grow: a thinner response adds keys to a richer stored set and never replaces it. Links between books and authors, narrators, genres and series are only added, never removed. Chapters keep the richer list: a response with no chapters cannot erase a stored list, and a later response that has chapters replaces it whole.
|
|
90
|
+
- **`libex_core.storage.write` writes normalized Audible responses into the stored schema.** It provides `write_books`, `upsert_author`, `upsert_genre`, `upsert_narrator`, `upsert_series`, `write_author_profile`, `write_series_profile` and `write_track`. Each takes the session first, reads no settings or environment, never commits, and raises on failure; the caller owns the transaction. `exclusive_write(session)` serializes writers on SQLite so they queue in the event loop instead of timing out in the driver, and does nothing on other databases; its lock is kept per event loop, so an engine reused across `asyncio.run` calls is fine. The write functions raise `ValueError` for any database other than Postgres or SQLite. The names load on first use, like the rest of `libex_core.storage`, and raise `StorageUnavailable` if the `storage` extra is not installed.
|
|
91
|
+
|
|
92
|
+
### Known differences between the two databases
|
|
93
|
+
- **`lower()` matches Postgres for every character Python's Unicode tables know.** A few characters added to Unicode more recently than the Python in use may be lower-cased differently.
|
|
94
|
+
- **SQLite does not reject an unknown region string.** Postgres does, through its region type.
|
|
95
|
+
|
|
96
|
+
Nothing in this release opens a database or creates tables, and no command uses it.
|
|
97
|
+
|
|
98
|
+
## [0.15.0]
|
|
99
|
+
|
|
100
|
+
### Added
|
|
101
|
+
- **A new `libex_core.storage` subpackage holds the stored schema for books, authors, series, narrators, genres and tracks, and the links between them.** `Book`, `Author`, `Series`, `Narrator`, `Genre` and `Track` are the table classes, `Base` is their declarative base, and `UTCDateTime` and `JSONDocument` are the column types they use. Nothing in this release reads or writes a database, no command uses it, and nothing in the package creates the tables.
|
|
102
|
+
- **The schema works on SQLite as well as Postgres.** On Postgres it is the same tables, columns and indexes as the hosted service has, unchanged. On SQLite, timestamps come back as UTC-aware datetimes (a naive value written in is taken to be UTC), a Python `None` in a JSON column is stored as SQL `NULL` rather than the JSON value `null`, and the partial index on authors with no ASIN is created there too.
|
|
103
|
+
- **Importing `libex_core.storage` loads no database library.** The names above load on first access. If SQLAlchemy or aiosqlite is not installed, accessing one raises `StorageUnavailable`, a subclass of `ImportError`, whose message names the `storage` extra and the command to install it. `require_storage()` runs the same check on its own, without importing the libraries.
|
|
104
|
+
- **Two new install extras.** `libex-core[storage]` adds SQLAlchemy, Alembic and aiosqlite, enough for SQLite. `libex-core[postgres]` adds asyncpg on top of that. The base install is unchanged and still needs only `httpx` and `pydantic`.
|
|
105
|
+
|
|
106
|
+
## [0.14.0]
|
|
107
|
+
|
|
108
|
+
### Added
|
|
109
|
+
- **The package can now look Audible data up, from code and from the command line.** A new public module, `libex_core.lookup`, has ten async functions: `get_book`, `get_books`, `get_chapters`, `get_series`, `get_series_books`, `search`, `quick_search`, `abs_search`, `abs_quick_search` and `narrator_books`. Each takes the request callable (an `AudibleGet`) first, then its arguments, then `region` as a keyword defaulting to `us`, and returns the published model the hosted route returns for the same Audible answer (`BookResponse`, `BulkBookResponse`, `ChapterResponse`, `SeriesResponse`, `AbsSearchResponse`, or a list of `BookResponse`). They are the hosted live path without the cache or the database: Audible's answer is normalized and settled, and nothing is stored. Earlier entries said the command line did not fetch Audible data and had no lookup commands; it now does.
|
|
110
|
+
- **Audible being unreachable is reported as an outage, never answered from a stored copy and never passed off as an empty result.** Because there is no stored copy to fall back on, a lookup that could not reach Audible raises `AudibleAPIException`, and `get_books` instead lists the affected ASINs in `notFetched` when other ASINs in the same call did come back. Audible confirming it has no record is `NotFoundException` (a bulk lookup lists the ASIN in `notFound`), and is kept apart from an outage. A search or narrator lookup that matches nothing is also `NotFoundException`. The compound `Author - Series - Title` fallback in `quick_search` searches Audible only; the stored-books step the hosted service adds after it is not part of the library.
|
|
111
|
+
- **Bulk lookups keep the hosted accounting.** `get_books` takes up to 1000 ASINs, each entry optionally comma-separated, and rejects the whole call with `NotFoundException` (code `invalid_request`) for an empty list, more than 1000, or any value that is not an ASIN. `notFound`, `placeholderRecords` and `notFetched` return the ASINs as the caller wrote them, in request order, and no ASIN appears in more than one of them or in `books`. A record Audible sent only as a placeholder is never returned as a book: `get_book` raises `NotFoundException` with code `withheld` for it, and `get_books` lists it in `placeholderRecords`. `get_series_books` leaves out members it cannot resolve and can return an empty list.
|
|
112
|
+
- **Paged lookups refuse out-of-range paging instead of clamping it.** `search` and `narrator_books` raise `ValueError` for a `limit` outside 1 to 50 or a `page` outside 0 to 9. The Audiobookshelf-shaped searches return at most five matches, and for them an unknown region is `NotFoundException` with code `invalid_request` rather than `RegionException`, as on the hosted routes. Search text and ASINs the caller typed are never repeated in an exception message or a log line.
|
|
113
|
+
- **New `libex-core` commands: `book get`, `book bulk`, `book chapters`, `series get`, `series books`, `search`, `quick-search`, `abs search`, `abs quick-search` and `narrator books`.** Each prints the result as JSON on standard output and takes `--region`, one of the eleven regions, default `us`. `search` takes `--title`, `--author`, `--narrator`, `--publisher`, `--keywords`, `--query` and `--sort-by`; `search` and `narrator books` take `--limit` (1 to 50, default 10) and `--page` (0 to 9, default 0). `book bulk` takes ASINs as arguments, from `--file PATH` (or `-` for standard input), or both, separated by commas or white space; a file larger than 1 MiB, or one that cannot be read as text, is refused as a usage error without repeating its path. These commands need the same proxy configuration as `libex-core config` reports: with neither `LIBEX_CORE_PROXY_URL` nor `LIBEX_CORE_ALLOW_DIRECT_EGRESS` set they exit 5 and send nothing.
|
|
114
|
+
- **Exit statuses follow the existing contract.** A lookup that succeeds exits 0, including a bulk result that is only partly complete: it is printed whole, with the ASINs not found, withheld or not fetched listed beside the books. A rejected argument or ASIN is 2, nothing found or no chapter list is 3 (many records have no chapter list), and Audible being unreachable is 4. No status was reassigned.
|
|
115
|
+
|
|
116
|
+
### Changed
|
|
117
|
+
- **The generated shell completions now complete the flags and values of the leaf commands.** Bash, zsh and fish previously completed only the top-level words; they now also complete nested commands (`book get`, `abs search`, and so on), each command's own flags, and the region names after `--region`.
|
|
118
|
+
- **The man page now documents each nested command under its full name**, such as `book get`, and escapes the hyphens in command names so they render and search as typed.
|
|
119
|
+
|
|
120
|
+
## [0.13.0]
|
|
121
|
+
|
|
122
|
+
### Added
|
|
123
|
+
- **`normalize_chapters` keeps what Audible sends beyond the chapter list.** The result gains `contentReference` and `contentUrl` (Audible's `content_reference` and `content_url` groups, verbatim) and `audibleExtras`, which gathers every other key under `response`, `contentMetadata` and `chapterInfo`. Each key appears only when Audible sent something for it; `response_groups`, the echo of the request, is the one recorded omission. A chapter's own unreproduced keys ride in that chapter's `audibleExtras`. Each of these verbatim parts is sanitized and held to 64 KB and 32 levels, as a book's extras are: NUL characters are stripped (from chapter titles too), non-finite or oversized numbers become `None`, and a part over a limit is omitted and recorded. A value of an unexpected form (a `content_reference` that is a list, a `chapters` value that is not a list of objects) is carried in `audibleExtras` under its own key instead of failing validation.
|
|
124
|
+
- **Nested sub-chapters are normalized and kept.** A chapter's `chapters` list, which Audible nests under it, was dropped before; each sub-chapter is now normalized the same way and carried in `chapters` on its chapter, nested up to `MAX_NESTING_DEPTH` (32) levels. Sub-chapters nested deeper are kept as Audible sent them in that chapter's `audibleExtras`. Absent when Audible sent none.
|
|
125
|
+
- **`ChapterResponse` gains `extrasWithheld`, and `normalize_chapters` fills it.** It records what the bounding changed or left out: `sanitized` (counts), a field name (`contentReference`, `contentUrl`, `audibleExtras`) mapped to the reason that part was withheld whole, `chapterExtras` (a count per reason of chapters whose own `audibleExtras` were withheld) and `subChapters` (a count of sub-chapter lists cut at the depth limit). It is absent from the result, and `None` on the model, when nothing was withheld.
|
|
126
|
+
- **`libex_core.audible.extras` publishes `bound_extras(blob, asin, region)` and `MAX_NESTING_DEPTH`.** `bound_extras` sanitizes one verbatim blob and applies the size and depth caps, returning `(blob or None, counts of what was sanitized, reason it was withheld whole or None)`; book, series and chapter normalizers all use it. `MAX_NESTING_DEPTH` is 32.
|
|
127
|
+
- **`normalize_chapters` takes optional `asin` and `region`** (default empty strings), used only to label the log line written when something is withheld. Existing calls are unaffected.
|
|
128
|
+
- **`ChapterItem` gains `chapters` and `audibleExtras`, and `ChapterResponse` gains `contentReference`, `contentUrl` and `audibleExtras`.** All default to `None`, so code that builds either without them is unaffected.
|
|
129
|
+
- **`normalize_series` keeps every product key beyond `asin`, `title` and `publisher_summary`** as `audibleExtras`, built the way a book's is, with `extrasWithheld` recording anything left out. Both appear only when there is something to say. `SeriesResponse` gains `audibleExtras` and `extrasWithheld`, defaulting to `None`.
|
|
130
|
+
- **`fetch_series_search_asins(get, name, region)` searches Audible by title and returns the unique series ASINs on the matching products, in the order found.** It asks for 10 results, so it is not a complete walk. An empty list means no series was found and is not an error; the region is checked first (`RegionException`), and the name is sent to Audible as the title and is never logged or put in a raised message.
|
|
131
|
+
|
|
132
|
+
### Changed
|
|
133
|
+
- **The output of `normalize_chapters` and `normalize_series` is no longer limited to the previous keys.** A response that carried only the previously reproduced keys normalizes exactly as before; one that carried more now has the extra keys above. An embedder that compares the whole dictionary, or builds a model that forbids unknown fields, will see the difference.
|
|
134
|
+
|
|
135
|
+
## [0.12.0]
|
|
136
|
+
|
|
137
|
+
### Added
|
|
138
|
+
- **The package can now rebuild Audible's new-releases and coming-soon lists, and fetch its genre taxonomy.** A new public module, `libex_core.audible.releases`, exposes `fetch_new_releases` and `fetch_coming_soon`, both `(get, region, days=30, category=None, *, now=None)`. Audible has no endpoint for either list, so each walks the catalog sorted by release date, newest first, and keeps the books inside the window: `fetch_new_releases` the last `days` (pre-orders skipped), returned newest first; `fetch_coming_soon` the next `days` (titles already out skipped), returned soonest first. Books with no parseable release date are skipped by the walk and never returned. `now` defaults to the current UTC time and can be passed to fix the window. The request callable is the first argument and the region is checked first (`RegionException` for one of the eleven it is not).
|
|
139
|
+
- **Lower-level pieces of the same walk are public too.** `walk_catalog(get, region, category_id, collect, should_stop)` runs one catalog query with your own date gates and returns the accepted books deduped by ASIN. `new_releases_gates` and `coming_soon_gates` build the two gates for a window, `new_releases_sort_key` and `coming_soon_sort_key` give the orderings, and `release_datetime` reads a normalized book's `releaseDate` back into a datetime, or `None`. The constants `CATEGORIES_PATH`, `RELEASE_PAGE_SIZE` (50) and `GENRE_TAXONOMY_LEVELS` (5) are exported with them.
|
|
140
|
+
- **`fetch_catalog_genres(get, region)` fetches one region's genre taxonomy, flattened, and `build_category_tree` shapes it.** `flatten_genre_nodes` turns the response into one row per node per parent (`genre_id`, `name`, `parent_id`, with `""` for a top-level node), following the tree to whatever depth Audible returned and deduping on node and parent. `build_category_tree(nodes, *, flat=False, depth=None)` returns those rows as a nested tree, or with `flat=True` as a flat list in which every node carries its ancestors root-first; both are sorted by name at every level, and `depth` limits the levels returned (1 is the top level only).
|
|
141
|
+
- **`CategoryNode`, `CategoryAncestor` and `FlatCategoryNode` are now public in `libex_core.models`.** They are the shapes `build_category_tree` returns. Field names, optionality and defaults are unchanged from the hosted service's copies.
|
|
142
|
+
- **A window is as complete as the walk can make it, and no more.** Audible caps every catalog query at roughly 535 results however it is filtered, and a parent category is not a superset of its children. Called with no `category`, the functions return a slice of the catalog, not the full window; reaching all of it means calling once per category id from the taxonomy and merging, which this module does not do for you.
|
|
143
|
+
- **The books come back as normalized, not settled.** Their tri-state flags are left as Audible gave them, for the caller to store or settle. Nothing here reads or writes a cache or a database, and the module does not log.
|
|
144
|
+
- **`fetch_new_releases` and `fetch_coming_soon` raise `ValueError` for `days` below 1, and `build_category_tree` for a `depth` below 1.** The messages repeat nothing the caller passed in. A `NotFoundException` or `AudibleAPIException` from the request callable propagates unchanged.
|
|
145
|
+
|
|
146
|
+
## [0.11.0]
|
|
147
|
+
|
|
148
|
+
### Added
|
|
149
|
+
- **The package can now look authors up on Audible.** A new public package, `libex_core.audible.authors`, has four modules. `profile` has `fetch_author_profile`, which returns an author's contributor record raw; `normalize_author`, which turns one into the author response shape; and `fetch_author_suggestion_asins`, which asks Audible's search suggestions which authors a name resolves to and returns their ASINs in Audible's order, unvalidated. `screens` has `fetch_author_books_by_screen` and its `ScreenBooksResult`: the author's books read from Audible's author-detail screen, the one source that names the author by ASIN. `catalog` has `fetch_author_books_by_catalog` and its `CatalogBooksResult`: the windowed, category-sliced catalog search, with each book attributed to the author by ASIN. `by_name` has `walk_author_books_by_name`, for a caller that has a name and no ASIN, with `NameWalkOutcome` and the `STOP_COMPLETED`, `STOP_PLATEAU`, `STOP_PAGE_CAP`, `STOP_PAGE_FAILED` and `STOP_DEADLINE` reasons. Like the other fetch functions, each takes the request callable as its first argument and checks the region first (`RegionException` for one of the eleven it is not), before anything is sent.
|
|
150
|
+
- **Each author-books walk reports what it found and whether it reached a confirmed end, and leaves the rest to the caller.** None of the three is complete alone, and none fills a gap from another: a walk that stopped early, hit a cap or lost a page says so in its result and returns the ASINs it had. Nothing here retries, falls back to a different source or decides that a short answer is good enough. The walks are bounded (page, result and time limits, and an optional absolute `deadline`); a result that stopped on one of those limits is not a complete one.
|
|
151
|
+
- **`AuthorResponse` is now public in `libex_core.models`.** Field names, optionality and defaults are those of the hosted service's copy: `id`, `asin`, `name`, `description`, `image`, `region`, `regions`, `genres` and `updatedAt`.
|
|
152
|
+
- **`fetch_author_profile` raises `ValueError` for a value that is not an ASIN, and `fetch_author_books_by_screen` answers one with an empty, clean result.** In both cases nothing is sent, and the `ValueError` message never repeats the rejected value. The screen walk returns no ASINs, no pages fetched and a completed reason rather than raising, because Audible answers an unknown author ASIN the same way. `fetch_author_books_by_catalog` does not validate its author ASIN, since it is only compared with what comes back and never sent.
|
|
153
|
+
|
|
154
|
+
## [0.10.0]
|
|
155
|
+
|
|
156
|
+
### Added
|
|
157
|
+
- **The package can now filter and sort a list of book dictionaries.** A new public module, `libex_core.shaping`, exposes `filter_dicts` and `sort_dicts`, the in-memory filtering and sorting the live book lists use, with no database or web framework involved. `filter_dicts(items, filters)` takes a dictionary of filter name to value, ignores `None` values and unknown names, keeps the input order, and returns the input list itself when no filter is active. `sort_dicts(items, sort, order, allowed)` returns the list unchanged when `sort` is empty or not in `allowed`; otherwise it sorts ascending, or descending when `order` is `desc`, and books missing the field or holding `None` for it go to the end in either direction. `allowed` is any container of field names, such as a tuple or the keys of a dictionary; only membership is tested.
|
|
158
|
+
- **The filter and sort surface is published as data, so a front end can build its parameters from it.** `BOOK_FILTER_SPECS` is a tuple of `FilterSpec` entries (`name`, `type`, `description`) for the twelve filters: `language`, `book_format`, `explicit`, `whisper_sync`, `has_pdf`, `is_vvab`, `plan_name`, `rating_better_than`, `rating_worse_than`, `longer_than`, `shorter_than` and `genre`. `BOOK_FILTER_FIELDS` is the set of their names. `BOOK_SORT_FIELDS` is the tuple of sortable book fields (`title`, `releaseDate`, `rating`, `lengthMinutes`, `language`, `publisher`, `updatedAt`), `BookSortField` is an enum with exactly those members, and `SortOrder` is the `asc`/`desc` enum.
|
|
159
|
+
- **Filters are limited to what is cheap on a list already in memory.** Numeric ranges, equality on the format and boolean fields, plan membership, and a case-insensitive partial match on genre names. Free-text search on title or description is not offered. A book missing the field a range filter targets is excluded by that filter, so a book with no length is never "longer than" anything.
|
|
160
|
+
|
|
161
|
+
## [0.9.0]
|
|
162
|
+
|
|
163
|
+
### Added
|
|
164
|
+
- **The package can now search Audible, not only fetch by ASIN.** A new public module, `libex_core.audible.search`, exposes `build_search_params`, `fetch_search_products` and `fetch_suggestion_asins`, plus the constants `SEARCH_PATH`, `SEARCH_SUGGESTIONS_PATH` and `MAX_SEARCH_RESULTS` (50). `build_search_params` takes the filters (`title`, `author`, `keywords`, `narrator`, `publisher`, `products_sort_by`) and `limit` and `page`, keyword-only, and returns the parameters of a catalog search; a filter that is missing or an empty string is left out, and no filter at all is allowed. `fetch_search_products` sends them and returns the matched products raw, with the response groups and image sizes added so full product metadata comes back in one call. `fetch_suggestion_asins` asks Audible's search suggestions what a partial query resolves to and returns the ASINs of the book rows in the order Audible gave them, unvalidated. As with the other fetch functions, the request callable is the first argument, and the region is checked first (`RegionException` for one of the eleven it is not).
|
|
165
|
+
- **`build_search_params` raises `ValueError` for a `limit` outside 1 to `MAX_SEARCH_RESULTS` or a negative `page`.** The message names the bounds and never repeats any search text; the package neither inspects nor logs search text. Earlier hosted behaviour silently clamped an over-large `limit` to 50; the package does not clamp, it refuses. Audible stops returning results past page 9, and that is not enforced here: a `page` above 9 is accepted and sent.
|
|
166
|
+
- **The Audiobookshelf custom-metadata-provider models are now public in `libex_core.models`.** `AbsSeriesRef`, `AbsBookResponse` and `AbsSearchResponse` describe that format, which is deliberately narrower than the AudiMeta-derived shapes; the module docstring now says they are not derived from AudiMeta. `to_abs_book` converts a normalized book dictionary into an `AbsBookResponse`. Field names, optionality and defaults are unchanged from the hosted service's copy.
|
|
167
|
+
|
|
168
|
+
## [0.8.0]
|
|
169
|
+
|
|
170
|
+
### Added
|
|
171
|
+
- **`BulkBookResponse.notFetched`, a list of the requested ASINs that could not be looked up because Audible was unreachable and no stored or cached copy covered them.** It defaults to an empty list, so code that builds a `BulkBookResponse` without it is unaffected, and it serializes always-present like `placeholderRecords`.
|
|
172
|
+
|
|
173
|
+
### Changed
|
|
174
|
+
- **The documented meaning of `BulkBookResponse.notFound` narrows to ASINs Audible confirmed it has no record of.** The field, its type and its position are unchanged, but it no longer holds ASINs that could not be fetched; those belong in `notFetched`. An embedder that fills `notFound` itself and treated it as "missing or unfetched" should move the unfetched ones to `notFetched`. The field description (visible in the generated schema) was reworded to match.
|
|
175
|
+
|
|
176
|
+
## [0.7.0]
|
|
177
|
+
|
|
178
|
+
### Added
|
|
179
|
+
- **The package now ships a `libex-core` command line.** It is installed as the `libex-core` script and also runs as `python -m libex_core`. Two commands exist so far: `libex-core config` prints, as JSON, whether requests would go through a proxy or leave directly, and the proxy host (never the proxy URL, and no request is made); `libex-core completion bash|zsh|fish` prints a completion script. Neither fetches Audible data. `--help` and `--version` work everywhere. By default the library's warnings print to standard error with no flag, `-q` prints only the final error line, `-v` asks for more detail (the package emits no INFO-level records, so it adds nothing), and `-vv` adds debug output with tracebacks. Results go to standard output; logs and errors go to standard error.
|
|
180
|
+
- **The command line's exit status says what kind of failure it was.** 0 success, 1 unexpected error, 2 bad usage or a rejected argument, 3 not found, 4 Audible unavailable, 5 bad environment configuration, 130 interrupted, 141 the reader of standard output went away. A `LibexException` maps by its `code`: `invalid_request` is 2, `not_on_audible`, `not_in_libex` and `withheld` are 3, `upstream_unavailable` is 4, and a code that has no mapping is 1. The error line on standard error ends with the code; `config_error` and `unexpected_error` are the command line's own and are never raised by the library. These numbers are a public contract and will not be reassigned.
|
|
181
|
+
- **The command line reads two environment variables.** `LIBEX_CORE_PROXY_URL` is the http or https proxy every request goes through; it is an environment variable and not an option because it can carry credentials. `LIBEX_CORE_ALLOW_DIRECT_EGRESS` (`1`, `true`, `yes` or `on`; `0`, `false`, `no`, `off` or empty to refuse) lets requests leave from the machine's own address when no proxy is set. Any other value is a configuration error (exit 5), reported even when a proxy is set.
|
|
182
|
+
- **A man page and shell completions install with the wheel**, under the environment prefix.
|
|
183
|
+
- **The package is now buildable and publishable as `libex-core`.** It declares `httpx` (`>=0.28.1,<0.29`) and `pydantic` (`>=2.13.4,<3`) as its dependencies, requires Python 3.12 or later, and ships a `py.typed` marker so type checkers use its annotations.
|
|
184
|
+
|
|
185
|
+
### Changed
|
|
186
|
+
- **The library still reads no environment variable.** The command line's environment module is the only code in the package that touches the process environment, and only when a configuration is requested, never at import. An embedder's own environment cannot change what the library does. The package docstring now states this.
|
|
187
|
+
|
|
188
|
+
## [0.6.0]
|
|
189
|
+
|
|
190
|
+
### Added
|
|
191
|
+
- **The package can now fetch and normalize Audible books, chapters and series, not only describe them.** New public modules: `libex_core.audible.books` (`fetch_products`, `normalize_product`, `normalize_products`, `settle_flags`, `settle_flags_list`, `filter_products`, `is_placeholder_record`, and the response-group and image-size constants), `libex_core.audible.extras` (`build_extras`), `libex_core.audible.chapters` (`fetch_chapter_metadata`, `has_chapter_info`, `normalize_chapters`, `CHAPTERS_RESPONSE_GROUPS`) and `libex_core.audible.series` (`fetch_series`, `fetch_series_book_asins`, `normalize_series`). Normalized output is the same, key for key, as hosted Libex produced before the move; it is pinned by golden files. Earlier entries said this package did not fetch or normalize a product; that no longer holds.
|
|
192
|
+
- **Every fetch function takes the request callable as its first argument.** `AudibleGet`, a new `Protocol` in `libex_core.audible.client`, describes it, so how a request leaves the process stays the embedder's choice. The fetch functions check the region first (`RegionException` for one of the eleven it is not) and every ASIN (`ValueError`, with a message that never repeats the rejected value) before anything is sent. `validated_asin` is exposed for the same check; it accepts a lowercase ASIN and returns it uppercased, which is also the form the fetch functions send. Hosted Libex screens out values that are not ASINs before it calls `fetch_products`, so one bad value does not reject the rest of a batch; the core itself still raises `ValueError` for the whole call. `fetch_products` accepts at most 50 ASINs per call; it does not split a longer list for you.
|
|
193
|
+
- **`libex_core.log_safety`** exposes `is_safe_log_value`, `safe_asin_for_log` and `window_elapsed`, the checks the package uses to keep caller- or Audible-supplied text out of log lines and to rate-limit repeated warnings. `is_safe_log_value` is the same rule hosted Libex used; it is no longer importable from hosted Libex's own logging module.
|
|
194
|
+
|
|
195
|
+
### Changed
|
|
196
|
+
- **The transport's concurrency and retry internals moved into private modules.** Nothing public was removed: `author_books_concurrency` is still importable from `libex_core.audible.client`. Code that reached into the transport's other internals, such as its semaphore or its concurrency limit, now finds them in `libex_core.audible._concurrency`, a private module with no stability promise.
|
|
197
|
+
- **The malformed-author-ASIN warning redacts unsafe values.** It used to log the book's ASIN, the malformed author ASIN and the author name verbatim. The book's ASIN is now `REDACTED` unless it is a well-formed ASIN; the malformed author ASIN and the author name are logged as-is only if `is_safe_log_value` accepts them (short catalogue text), otherwise `REDACTED`. Other warnings naming an ASIN from Audible use the same well-formed-ASIN rule.
|
|
198
|
+
|
|
199
|
+
## [0.5.0]
|
|
200
|
+
|
|
201
|
+
### Added
|
|
202
|
+
- **New public `ErrorCode` enum, and every `LibexException` gains a `code` attribute.** `ErrorCode` is a `StrEnum` with `NOT_IN_LIBEX`, `NOT_ON_AUDIBLE`, `WITHHELD`, `UPSTREAM_UNAVAILABLE` and `INVALID_REQUEST`, its values being the lowercase names. Each exception class has a default: `NotFoundException` is `NOT_ON_AUDIBLE`, `RegionException` is `INVALID_REQUEST`, and `AudibleAPIException`, `CacheException` and the base `LibexException` are `UPSTREAM_UNAVAILABLE`. A raise site can override it with a new optional `code=` argument. Existing raises keep working untouched and keep their messages and status codes; the argument is keyword-optional and last, so no positional call changes meaning.
|
|
203
|
+
|
|
204
|
+
## [0.4.1]
|
|
205
|
+
|
|
206
|
+
### Changed
|
|
207
|
+
- **The package and its response models are no longer described as a drop-in AudiMeta replacement.** The shapes are derived from AudiMeta's and differ from them in places; the package docstring and the models module now say so. Documentation only: no model, field, default or function changed.
|
|
208
|
+
|
|
209
|
+
## [0.4.0]
|
|
210
|
+
|
|
211
|
+
### Added
|
|
212
|
+
- **`BulkBookResponse` gains `placeholderRecords: list[str]`, defaulting to an empty list.** Existing construction sites keep working untouched. What moves for every embedder is the serialized shape: a dump of `BulkBookResponse` now carries a `placeholderRecords` key, `[]` unless something supplied it, so a golden file or snapshot compared against a dump will differ on upgrade. That is why this is a MINOR rather than a PATCH. The field means requested ASINs for which Audible returned a record carrying its 2200-01-01 placeholder publication date, deliberately left out of `books` and never also in `notFound`. Nothing in this package fills it; the embedder decides what goes in.
|
|
213
|
+
|
|
214
|
+
### Changed
|
|
215
|
+
- **The `notFound` field description on `BulkBookResponse` is reworded; its type and default are unchanged.** It now points placeholder records at `placeholderRecords`, and says the list can also hold ASINs that could not be fetched in this request and had no stored copy. This changes the schema's description text in generated OpenAPI, nothing else.
|
|
216
|
+
|
|
217
|
+
## [0.3.0]
|
|
218
|
+
|
|
219
|
+
### Added
|
|
220
|
+
- **`BookResponse` gains eight optional fields: `numRatings`, `numReviews`,
|
|
221
|
+
`publicationName`, `publicationDatetime`, `extendedProductDescription`,
|
|
222
|
+
`productState`, `audibleExtras` and `extrasWithheld`.** Every one defaults
|
|
223
|
+
to `None`, so existing construction sites keep working untouched. What
|
|
224
|
+
does move for every embedder is the serialized shape: pydantic emits all
|
|
225
|
+
eight by default, so a dump of `BookResponse` — and of
|
|
226
|
+
`BulkBookResponse`, which contains it — now carries eight more keys,
|
|
227
|
+
`null` unless something supplied them. A golden file or snapshot compared
|
|
228
|
+
against a dump will differ on upgrade, which is the whole reason this is
|
|
229
|
+
a MINOR rather than a PATCH.
|
|
230
|
+
|
|
231
|
+
- **Nothing in this package fills them.** `libex_core` defines the response
|
|
232
|
+
shape; it does not fetch an Audible product or normalize one into this
|
|
233
|
+
model. An embedder constructing a `BookResponse` decides what goes in
|
|
234
|
+
these fields, so the contracts below are what the shape *means*, not
|
|
235
|
+
behaviour the package performs. `numRatings` and `numReviews` are the
|
|
236
|
+
counts behind the `rating` average, which on its own cannot distinguish
|
|
237
|
+
4.8 from three ratings from 4.8 from two hundred thousand.
|
|
238
|
+
`publicationName` and `publicationDatetime` place a periodical or podcast
|
|
239
|
+
episode in its publication; the datetime is a full instant with a literal
|
|
240
|
+
trailing `Z`, distinct from `releaseDate`, which is a bare calendar date.
|
|
241
|
+
`extendedProductDescription` is the long-form description with its markup
|
|
242
|
+
intact — `description` and `summary` are the flattened ones — and is
|
|
243
|
+
upstream HTML, neither validated nor rewritten here, so encode it on
|
|
244
|
+
output. `productState` is Audible's own state string; match it as an
|
|
245
|
+
opaque string rather than modelling it as an enum, because the vocabulary
|
|
246
|
+
is Audible's and can grow without notice.
|
|
247
|
+
|
|
248
|
+
- **`audibleExtras` is defined as a verbatim catch-all, and three details of
|
|
249
|
+
that definition bind anyone who populates or reads it.** It holds every
|
|
250
|
+
top-level key of Audible's product response that the named fields do not
|
|
251
|
+
already reproduce, so a key Audible invents later surfaces on its own
|
|
252
|
+
rather than disappearing between the fetch and the response. Verbatim
|
|
253
|
+
means value for value, not byte for byte: the content is parsed JSON, so
|
|
254
|
+
key order is not preserved, duplicate keys are already collapsed, and
|
|
255
|
+
numeric spelling is normalized (`1e3` arrives as `1000.0`). Nothing in it
|
|
256
|
+
is ever hoisted to the top level — no splat, no key set at runtime —
|
|
257
|
+
which is what makes an upstream key named `asin` structurally unable to
|
|
258
|
+
collide with the first-class field of that name; it stays nested and is
|
|
259
|
+
read there. And it is tri-state: `null` means nothing was captured, `{}`
|
|
260
|
+
means the product carried nothing beyond the named fields, and an object
|
|
261
|
+
is content. Treat every value in it as untrusted input, including the
|
|
262
|
+
URLs it will contain.
|
|
263
|
+
|
|
264
|
+
- **`extrasWithheld` is the record of what was left out of `audibleExtras`
|
|
265
|
+
and why, so an omission is stated rather than inferred.** It sits beside
|
|
266
|
+
the blob rather than inside it deliberately: the blob is documented as
|
|
267
|
+
Audible's keys only, and a key invented here would collide with a real
|
|
268
|
+
upstream one the day Audible ships a field by that name. Unlike
|
|
269
|
+
`audibleExtras` it is not tri-state — `None` means nothing was withheld,
|
|
270
|
+
and draws no distinction between an intact blob and no blob at all.
|
|
271
|
+
|
|
272
|
+
## [0.2.0]
|
|
273
|
+
|
|
274
|
+
### Security
|
|
275
|
+
- **`get_audible_url()` now rejects a `.` or `..` path segment anywhere
|
|
276
|
+
between the path's slashes, not only a dot segment at the very front.** The
|
|
277
|
+
existing guard only inspected the path's leading character, so a path like
|
|
278
|
+
`/1.0/catalog/products/../../internal` passed it untouched; httpx then
|
|
279
|
+
applies ordinary RFC 3986 dot-segment removal while parsing the URL this
|
|
280
|
+
function builds, silently collapsing that path to `/1.0/internal` — an
|
|
281
|
+
endpoint this function never intended to address. The guard that checks the
|
|
282
|
+
finished URL's host, scheme and port could not catch it either, because
|
|
283
|
+
none of those three change when only the path collapses. A segment that is
|
|
284
|
+
`.` or `..`, one whose percent-encoding (`%2e`/`%2E`) decodes to either of
|
|
285
|
+
those, or one containing an encoded separator — `%2f`/`%2F` for `/`, and
|
|
286
|
+
now `%5c`/`%5C` for the backslash the guard already rejected in its literal
|
|
287
|
+
form — raises `ValueError`. This is a breaking change for a caller of
|
|
288
|
+
`LibexClient.get()` that was constructing a path containing one of these;
|
|
289
|
+
no call site in this codebase ever has, every one interpolating a single
|
|
290
|
+
bare id. The encoded spellings rest on weaker ground than the literal one
|
|
291
|
+
and are refused anyway: httpx never turns an encoded separator or an
|
|
292
|
+
encoded dot into a live one before the request leaves this process, so
|
|
293
|
+
those forms are rejected for what a server might do with them after
|
|
294
|
+
decoding, which is not something this library can see or verify.
|
|
295
|
+
- **A path containing `?` or `#` is now rejected outright, whether or not a
|
|
296
|
+
dot segment is visible in it.** This is the change most likely to reach an
|
|
297
|
+
existing caller: code that inlined a query string into the `path` argument,
|
|
298
|
+
rather than passing `get()`'s own `params`, now raises `ValueError` where it
|
|
299
|
+
previously made a request. The reasoning is not the one above. Neither
|
|
300
|
+
character belongs to the path component — each one ends it — so whatever
|
|
301
|
+
sits immediately in front of one is the path's real final segment, which is
|
|
302
|
+
somewhere a check that splits on `/` never looks. When that segment was `.`
|
|
303
|
+
or `..`, the collapse had already happened in the bytes leaving this
|
|
304
|
+
process: `/1.0/catalog/products/..?x` was transmitted with a path of
|
|
305
|
+
`/1.0/catalog?x`, a level above the prefix the caller asked for, and
|
|
306
|
+
`/1.0/catalog/products/.#x` as `/1.0/catalog/products`, the fragment never
|
|
307
|
+
reaching a server at all. That is measured behaviour of this library, not an
|
|
308
|
+
assumption about anybody else's. Refusing the two characters removes the
|
|
309
|
+
whole shape rather than the individual spellings of it, and costs nothing
|
|
310
|
+
legitimate: query parameters belong in `get()`'s `params` argument, which
|
|
311
|
+
httpx appends to the URL this function returns, and a fragment is never sent
|
|
312
|
+
to a server in the first place.
|
|
313
|
+
- **What this guard does not cover, deliberately.** It compares segments
|
|
314
|
+
against fixed spellings rather than decoding them, so double- and
|
|
315
|
+
nested-encoded forms (`%252e%252e`), unicode normalization, and overlong
|
|
316
|
+
encodings such as `%c0%af` are still accepted, and no claim is made that
|
|
317
|
+
they are caught. None of them decode into a separator in httpx before a
|
|
318
|
+
request leaves this process, nothing in this package produces one, and
|
|
319
|
+
matching every possible spelling of a dot is an arms race with no end. Read
|
|
320
|
+
the guard as closing the shapes named above, not as general input
|
|
321
|
+
sanitisation.
|
|
322
|
+
|
|
323
|
+
## [0.1.0]
|
|
324
|
+
|
|
325
|
+
First tracked version of `libex_core`, and the first entry in this file.
|
|
326
|
+
Everything below shipped in the same change that started this line, which is
|
|
327
|
+
why the line opens with a break rather than growing into one later.
|
|
328
|
+
|
|
329
|
+
### Removed
|
|
330
|
+
- **The module-level `configure_transport()` and `audible_get()` functions,
|
|
331
|
+
and the process-wide transport state behind them, are gone.** Egress used
|
|
332
|
+
to be a single, mutable choice for the whole process: call
|
|
333
|
+
`configure_transport()` once at import, and every subsequent call to the
|
|
334
|
+
module-level `audible_get()` read back whatever it had set. There is no
|
|
335
|
+
compatibility shim for either name — code built against them breaks
|
|
336
|
+
immediately on upgrade rather than continuing to work with a deprecation
|
|
337
|
+
warning.
|
|
338
|
+
|
|
339
|
+
### Added
|
|
340
|
+
- **`LibexClient` replaces both: one instance per caller, whose transport is
|
|
341
|
+
decided once at construction and never replaced for that instance's whole
|
|
342
|
+
life.** Construct one with `LibexClient(proxy_url=..., allow_direct_egress=...)`
|
|
343
|
+
and call its `get(region, path, ...)` method wherever code used to call the
|
|
344
|
+
module-level `audible_get()`. `proxy_url` has no default — omitting it is a
|
|
345
|
+
`TypeError` from Python's own argument checking, not a state this library
|
|
346
|
+
has to notice and refuse at request time, so "nobody ever decided how this
|
|
347
|
+
instance egresses" is not a state a `LibexClient` can be in. A blank or
|
|
348
|
+
missing `proxy_url` still needs a separate `allow_direct_egress=True` to
|
|
349
|
+
mean "leave on this machine's own address" — otherwise it's refused,
|
|
350
|
+
because an empty proxy setting is indistinguishable, at the wire, from one
|
|
351
|
+
simply left unset by mistake. That guard carries over unchanged from
|
|
352
|
+
`configure_transport()`; only its scope moves from once-per-process to
|
|
353
|
+
once-per-instance.
|
|
354
|
+
- **An instance can be closed permanently**, with `await client.aclose()` or
|
|
355
|
+
by using it as `async with LibexClient(...) as client:`. Closing is
|
|
356
|
+
terminal: every `get()` call afterward raises `RuntimeError` instead of
|
|
357
|
+
silently rebuilding a client and egressing again, and a second `aclose()`
|
|
358
|
+
is a no-op. `is_open` reports whether a live HTTP client currently exists
|
|
359
|
+
for the instance — it reads `False` both before the first request and
|
|
360
|
+
after `aclose()`, deliberately not the complement of the underlying HTTP
|
|
361
|
+
library's own closed-state check.
|
|
362
|
+
- Concurrency, retry and backoff limits stay process-wide rather than
|
|
363
|
+
becoming per-instance state: every `LibexClient` built in the same process
|
|
364
|
+
still shares one exit IP and draws from the same two concurrency pools,
|
|
365
|
+
regardless of how many instances exist.
|
|
366
|
+
|
|
367
|
+
### Security
|
|
368
|
+
- **A crafted request path could have redirected a request to a host other
|
|
369
|
+
than Audible's own.** The URL builder concatenated a caller-supplied path
|
|
370
|
+
directly onto the Audible hostname with no separator guarantee, so a path
|
|
371
|
+
starting with `@` could turn the rest of the string into userinfo and push
|
|
372
|
+
the intended host aside — a path like `"@evil.example/x"` resolved to
|
|
373
|
+
`evil.example`, not Audible. A path is now rejected outright unless it
|
|
374
|
+
starts with a single `/`, contains no backslash, and the finished URL
|
|
375
|
+
still resolves to exactly the intended host, scheme, and port. This starts
|
|
376
|
+
to matter only from this release on, because `get()` is the first published
|
|
377
|
+
method that takes a path directly from whoever is embedding the library —
|
|
378
|
+
every caller of the equivalent internal function before it always passed a
|
|
379
|
+
fixed path, so the bug existed but had no way to be reached.
|
libex_core/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audible metadata fetched and normalized into data shaped after AudiMeta's.
|
|
3
|
+
The shapes are derived from AudiMeta's and differ from them in places; this is
|
|
4
|
+
not a drop-in replacement.
|
|
5
|
+
|
|
6
|
+
This package holds the pieces of that work that carry no database, no cache,
|
|
7
|
+
no web framework, and no environment configuration of their own, so it can be
|
|
8
|
+
embedded in another application without dragging any of that in behind it.
|
|
9
|
+
|
|
10
|
+
The library reads no environment variable: every setting arrives as an
|
|
11
|
+
argument. The one exception is the command line, whose environment module
|
|
12
|
+
(`libex_core.cli.environment`) is the only code in the package that touches
|
|
13
|
+
the process environment, and only when it is asked for a configuration.
|
|
14
|
+
|
|
15
|
+
Importing `libex_core` imports nothing else: the top level re-exports no
|
|
16
|
+
names, so `import libex_core` costs no more than an empty module and pulls in
|
|
17
|
+
neither httpx, pydantic nor the storage libraries. What an embedder uses is
|
|
18
|
+
imported from where it lives:
|
|
19
|
+
|
|
20
|
+
- `libex_core.audible.client`: `LibexClient`, the one object that reaches
|
|
21
|
+
Audible, and `AudibleGet`, the shape of its `get`.
|
|
22
|
+
- `libex_core.lookup`: one function per endpoint, returning the published
|
|
23
|
+
models.
|
|
24
|
+
- `libex_core.models`: the published response models.
|
|
25
|
+
- `libex_core.exceptions`: `LibexException`, its subclasses and `ErrorCode`.
|
|
26
|
+
- `libex_core.asin`: ASIN validation and normalisation.
|
|
27
|
+
- `libex_core.storage`: the optional local store, which needs the `storage`
|
|
28
|
+
extra and loads its libraries only on first use.
|
|
29
|
+
|
|
30
|
+
Everything else under this package, `libex_core.cli` included, is internal
|
|
31
|
+
and may change in any release; the `libex-core` command's own options and
|
|
32
|
+
exit statuses are the public contract of the command line.
|
|
33
|
+
|
|
34
|
+
`__version__` is declared here because this package can move independently of
|
|
35
|
+
the hosted app that embeds it, and an embedder needs a version to pin against
|
|
36
|
+
that isn't tied to the hosted app's own release line.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
__version__ = "0.20.0"
|
|
40
|
+
|
|
41
|
+
__all__ = ["__version__"]
|
libex_core/__main__.py
ADDED
libex_core/asin.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ASIN validation and normalisation.
|
|
3
|
+
|
|
4
|
+
Pure regex logic with no framework dependency: what makes a string an
|
|
5
|
+
Audible ASIN, and what form of it Audible and the database both answer to.
|
|
6
|
+
It lives on its own because both the request surface, which validates a
|
|
7
|
+
path parameter before it ever reaches a route body, and the service layer,
|
|
8
|
+
which validates an ASIN found mid-response before using it to key a lookup,
|
|
9
|
+
need the same answer, and neither one is the natural owner of the other's
|
|
10
|
+
dependencies.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# Standard library
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
__all__ = ["is_valid_asin", "normalise_asin"]
|
|
17
|
+
|
|
18
|
+
ASIN_PATTERN = re.compile(r'^[A-Z0-9]{10}$')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalise_asin(asin: str) -> str:
|
|
22
|
+
"""
|
|
23
|
+
Returns the form of an ASIN that Audible and the database both answer to.
|
|
24
|
+
|
|
25
|
+
Audible's catalogue is case-sensitive on the ASIN. A lowercase key returns
|
|
26
|
+
the bare-ASIN shape Libex reads as a miss rather than the product, and
|
|
27
|
+
stored keys are uppercase, so the database fallback misses the same way --
|
|
28
|
+
a book that exists is reported absent. Callers may send either case, since
|
|
29
|
+
is_valid_asin has always accepted both, which makes uppercasing the step
|
|
30
|
+
that has to happen before the value is used for anything. It lives here
|
|
31
|
+
beside the validator so no route has to remember it.
|
|
32
|
+
"""
|
|
33
|
+
return asin.upper()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_valid_asin(asin: str) -> bool:
|
|
37
|
+
"""Validates that a string matches Audible ASIN format."""
|
|
38
|
+
return bool(ASIN_PATTERN.fullmatch(normalise_asin(asin)))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Everything Libex does with Audible that needs no database, no cache and no
|
|
3
|
+
application settings.
|
|
4
|
+
|
|
5
|
+
The transport (client.py): region-aware URLs and headers, the shared httpx
|
|
6
|
+
client, and the concurrency and retry policy every outbound request goes
|
|
7
|
+
through. On top of it, one module per kind of record or lookup, each pairing
|
|
8
|
+
its fetch functions with the normalizer for what they fetch. A
|
|
9
|
+
fetch function takes the callable that makes the request (an AudibleGet) as
|
|
10
|
+
its first argument, so how a request leaves the process is always the
|
|
11
|
+
embedder's choice and set once, at process start.
|
|
12
|
+
"""
|