diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed17d6..72fdcf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.6.2 + +- Lead the README with tested examples for transcript reads, quotes with source links, and company monitoring. +- Keep detailed async, pagination, error, and Premium polling examples in the SDK guide. + ## 0.6.1 Generated from the API document of 2026-10-15. diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000..e6a1fd3 --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,179 @@ +# SDK guide + +The official Python SDK for searching indexed YouTube transcripts. Find timestamped quotes, speaker appearances, mentions, sponsors and recommendations with synchronous and asynchronous clients, typed responses and cursor pagination. + +[Arcmira](https://arcmira.com) · [API docs](https://arcmira.com/docs) · [OpenAPI schema](https://api.arcmira.com/v1/openapi.json) · [MCP setup](https://arcmira.com/docs/mcp-server) + +We recommend [uv](https://docs.astral.sh/uv/) as your Python package manager: + +```sh +uv add arcmira +``` + +(`pip install arcmira` works too.) + +Set `ARCMIRA_API_KEY` before you import the client, or pass `api_key` to it. + +## First call + +Reads take ids. Resolve a name to an id, then read with the id. + +```python +from arcmira import Arcmira + +client = Arcmira() +match = client.entities.resolve(q="Ramp", type="organization") +ramp = match.best or match.suggested +if ramp is None: + raise LookupError(match.note) +for mention in client.mentions.list(entity_id=ramp.id, after="2026-09-01", before="2026-10-01"): + print(mention.media.video_id, mention.start_seconds, mention.description) +``` + +`entities.resolve` answers one of three ways. `best` is a certain match. `suggested` is the likeliest row, with a reason, when no row is certain. `ask` lists the options when several rows fit, and `best` and `suggested` are both `None`. For a show, pass `type="channel"` and use `best.youtube_channel_id` as `channel_id`. + +A name where an id belongs raises `BadRequestError` with the code `id_required`. Its message names the parameter and the resolve call. + +## Dates + +Every dated read takes `after` and `before`. The window is half-open, `[after, before)`. Each accepts an ISO date (`2026-09-01`) or a datetime with an offset (`2026-09-01T00:00:00Z`), read in UTC. Each dated read echoes the window it applied in `window`. + +## Premium transcripts + +A Premium read is one call. It answers 200 `ready` when the account owns the transcript. Otherwise it starts transcribing the whole video and answers 202 `pending` with the job. The read uses credits from your plan, then your on-demand budget. You set that budget in the dashboard, and Arcmira enforces it, so the call takes no spending amount. + +Read again after `Retry-After`. Repeated reads join the same job and never use credits twice. + +```python +import time +from arcmira import Arcmira + +client = Arcmira() +for _ in range(60): + read = client.transcripts.with_raw_response.get("dQw4w9WgXcQ", quality="premium") + if read.data.state == "ready": + for line in read.data.lines: + print(line.start, line.text) + break + if read.data.state == "failed": + raise RuntimeError(f"{read.data.last_attempt.status}: {read.data.last_attempt.error}") + time.sleep(int(read.headers.get("retry-after") or read.data.job.next_poll_seconds or 10)) +else: + raise TimeoutError("Still processing. Resume the same Premium read later.") +``` + +The timeout only stops this polling loop. It does not cancel the job. Resume with the same video id and `quality="premium"`; do not set `retry=True` while the job is pending. + +`read.data` is a `TranscriptResult`, discriminated on `state`. `ready` carries the transcript. `pending` carries `job`, with `eta_seconds`, `next_poll_seconds` and `charge`. `failed` means the last transcription failed or was refunded; it carries `job` and `last_attempt` and uses no credits. `retry=True` transcribes it again and uses credits again. Without `with_raw_response`, `client.transcripts.get(...)` returns the same union without the status and headers. + +A quote is free and changes nothing. + +```python +quote = client.transcripts.quote("dQw4w9WgXcQ") +print(quote.quote.rows, quote.charge.amount, quote.charge.from_, quote.max_on_demand_cents) +``` + +`client.transcripts.list_requests()` lists past Premium transcriptions with their state. + +A read without `quality="premium"` returns captions and starts no transcription. + +## Errors + +A refusal raises a typed error from `arcmira.errors`. Each derives from `arcmira.core.api_error.ApiError` and carries `status_code`, `headers` and `body`. `body.error` holds `type`, `code`, `message`, `param`, `gate`, `unlock`, `details`, `doc_url` and `request_id`. Switch on `type` and `gate` first. Codes inside a type can grow. + +| Status | Error | Example codes | +|---|---|---| +| 400 | `BadRequestError` | `invalid_query`, `id_required`, `invalid_cursor` | +| 401 | `UnauthorizedError` | `invalid_api_key` | +| 402 | `PaymentRequiredError` | `quota_exceeded`, `spend_limit_exceeded` | +| 403 | `ForbiddenError` | `paid_plan_required`, `freshness_requires_paid` | +| 404 | `NotFoundError` | `entity_not_found` | +| 409 | `ConflictError` | `tracker_already_exists` | +| 429 | `TooManyRequestsError` | `rate_limited` | +| 500, 503 | `InternalServerError`, `ServiceUnavailableError` | | + +A priced refusal carries the price in `error.details.quote`. Nothing is charged. + +```python +from arcmira.errors import ForbiddenError, PaymentRequiredError + +try: + client.transcripts.get("dQw4w9WgXcQ", quality="premium") +except (PaymentRequiredError, ForbiddenError) as refusal: + error = refusal.body.error + print(error.code, error.details.quote.rows, error.unlock.url) +``` + +`str(refusal)` reads `402 quota_exceeded: `. `member_limit` and `fair_use_cap` are 402 refusals with no `unlock`: an admin of the account raises the limit, or it resets with the month. `on_demand_paused` is a 402 refusal with no `unlock` too: a payment for on-demand usage was declined, and an admin of the account updates the card at https://arcmira.com/dashboard/billing. + +## Pagination + +`mentions.list`, `recommendations.list`, `channels.videos.list` and `transcripts.list_requests` return pagers. Iterate them and they follow `next_cursor` for you. Cursors are opaque. Keep the filters the same between pages. + +```python +for episode in client.channels.videos.list("UC-DRzaGnL_vtBUpCFH5M0tg", limit=10): + print(episode.video_id) +``` + +Each page body names its rows: `mentions`, `recommendations`, `episodes` or `requests`. The alert lists (`monitors.alerts.list`, `trackers.alerts.list`) return one page of `alerts`, newest first. Pass a larger `limit` to read further. + +## Async + +`AsyncArcmira` has the same methods to await. Paginated methods return async iterators after you await the first page. + +```python +from arcmira import AsyncArcmira + +async def ramp_sponsorships(): + client = AsyncArcmira() + async for row in await client.recommendations.list(entity_id="ent_14", class_="sponsored"): + print(row.class_, row.media.video_id, row.start_seconds) +``` + +`class` is a Python keyword, so the parameter and the field are spelled `class_`. The wire name stays `class`. + +## Methods + +Each method has the full parameter list in the [generated reference](reference.md). + +| Group | Methods | +|---|---| +| `entities` | `resolve`, `get`, `momentum` | +| `mentions` | `list`, `count` | +| `recommendations` | `list` | +| `transcripts` | `search`, `get`, `quote`, `list_requests` | +| `channels` | `coverage`, `videos.list`, `sponsors.list` | +| `monitors` | `list`, `create`, `update`, `delete`, `rotate_webhook_secret`, `trackers.list`, `trackers.add`, `entities.add`, `alerts.list` | +| `trackers` | `list`, `update`, `delete`, `alerts.list` | +| `integrations` | `slack.list` | +| `feedback` | `submit`, `get` | +| `me` | `get`, `update_settings` | +| `health` | `check` | + +`transcripts.search` returns spoken passages from `GET /v1/search`. Its filters take ids too. + +Every tracker lives in a monitor, which sets how it alerts. To follow an entity you have an id for, call `monitors.entities.add(monitor_id, entity_ids=["ent_14"])`. To watch an exact name before it is indexed, pass `names=[AddEntitiesRequestNamesItem(name="Ramp", type="organization")]` (from `arcmira.monitors`) to the same call. A channel is named by its YouTube channel id. + +`me.get()` names the account the key spends as `account` (`id`, `name`, `kind`, `plan`) and your `role` there; `email_masked` is you. + +See [CHANGELOG.md](CHANGELOG.md) for what changed from 0.3. + +## Agents + +The [Arcmira MCP server](https://github.com/arcmira/mcp) gives AI agents the same data at `https://mcp.arcmira.com/mcp`. [llms.txt](llms.txt) describes this package for agents. + +## Regenerate and verify + +Run `bash scripts/generate.sh` with Node 22 or newer, Python 3, Docker, and Fern access for the `arcmira` organization. Generation pins Fern CLI 5.131.1 and Python generator 5.31.0, disables CLI version redirection and telemetry, and reads `fern/openapi.json`. `fern/method-names.json` names the group and method of every operation by operationId. An operation without a name, or a name for an operation the document lacks, fails the build. The overlay combines the transcript read's success schemas into `TranscriptResult` and rejects unknown or ambiguous cursor collections. Generated source is never edited by hand. `scripts/overrides/api_error.py` holds the `ApiError` text, and the installer copies it back after every generation. + +```sh +uv sync +uv run python -m unittest discover -s tests -v +uv build +``` + +The tests use a local HTTP server that returns the bodies the API sends. They check both client variants, the ready and pending reads, typed refusals with their quote, the query and body each call sends, and opaque pagination. They need no live API key and use no credits. + +## License + +Apache-2.0. See [LICENSE](./LICENSE). diff --git a/README.md b/README.md index 6252dc0..51d14ad 100644 --- a/README.md +++ b/README.md @@ -1,179 +1,98 @@ -# Arcmira: YouTube Transcript Search +# Arcmira for Python -The official Python SDK for searching indexed YouTube transcripts. Find timestamped quotes, speaker appearances, mentions, sponsors and recommendations with synchronous and asynchronous clients, typed responses and cursor pagination. +Pull YouTube transcripts, search spoken passages, and monitor company mentions with Arcmira. Results include timestamps and source links for research, citations, and agent workflows. -[Arcmira](https://arcmira.com) · [API docs](https://arcmira.com/docs) · [OpenAPI schema](https://api.arcmira.com/v1/openapi.json) · [MCP setup](https://arcmira.com/docs/mcp-server) +[Get an API key](https://arcmira.com/docs/authentication) · [API docs](https://arcmira.com/docs) · [Pricing](https://arcmira.com/pricing) -We recommend [uv](https://docs.astral.sh/uv/) as your Python package manager: +## Install ```sh uv add arcmira ``` -(`pip install arcmira` works too.) +`pip install arcmira` works too. Set `ARCMIRA_API_KEY` to your API key. Save an example as `example.py` and run `python example.py` in your environment. -Set `ARCMIRA_API_KEY` before you import the client, or pass `api_key` to it. +## Find quotes with source links -## First call - -Reads take ids. Resolve a name to an id, then read with the id. +Search YouTube and podcast transcripts by topic. Keep the source link beside each quote so readers can check the original. ```python +from urllib.parse import urljoin from arcmira import Arcmira client = Arcmira() -match = client.entities.resolve(q="Ramp", type="organization") -ramp = match.best or match.suggested -if ramp is None: - raise LookupError(match.note) -for mention in client.mentions.list(entity_id=ramp.id, after="2026-09-01", before="2026-10-01"): - print(mention.media.video_id, mention.start_seconds, mention.description) +hits = client.transcripts.search(q="agent payments", limit=5) +for hit in hits.chunks: + print(hit.text) + print(urljoin("https://arcmira.com", hit.watch_url)) +print(hits.note) ``` -`entities.resolve` answers one of three ways. `best` is a certain match. `suggested` is the likeliest row, with a reason, when no row is certain. `ask` lists the options when several rows fit, and `best` and `suggested` are both `None`. For a show, pass `type="channel"` and use `best.youtube_channel_id` as `channel_id`. - -A name where an id belongs raises `BadRequestError` with the code `id_required`. Its message names the parameter and the resolve call. - -## Dates - -Every dated read takes `after` and `before`. The window is half-open, `[after, before)`. Each accepts an ISO date (`2026-09-01`) or a datetime with an offset (`2026-09-01T00:00:00Z`), read in UTC. Each dated read echoes the window it applied in `window`. - -## Premium transcripts +Search covers indexed transcripts within your plan's date window. Check `window`, `search_index`, and `note` before treating an empty result as evidence that nobody said it. -A Premium read is one call. It answers 200 `ready` when the account owns the transcript. Otherwise it starts transcribing the whole video and answers 202 `pending` with the job. The read uses credits from your plan, then your on-demand budget. You set that budget in the dashboard, and Arcmira enforces it, so the call takes no spending amount. +## Pull a YouTube transcript -Read again after `Retry-After`. Repeated reads join the same job and never use credits twice. +Read available captions with timestamps. Replace the video id with the video you need. ```python -import time from arcmira import Arcmira client = Arcmira() -for _ in range(60): - read = client.transcripts.with_raw_response.get("dQw4w9WgXcQ", quality="premium") - if read.data.state == "ready": - for line in read.data.lines: - print(line.start, line.text) - break - if read.data.state == "failed": - raise RuntimeError(f"{read.data.last_attempt.status}: {read.data.last_attempt.error}") - time.sleep(int(read.headers.get("retry-after") or read.data.job.next_poll_seconds or 10)) -else: - raise TimeoutError("Still processing. Resume the same Premium read later.") +transcript = client.transcripts.get("dQw4w9WgXcQ") +if transcript.state == "ready": + for line in transcript.lines or []: + print(line.start, line.text) ``` -The timeout only stops this polling loop. It does not cancel the job. Resume with the same video id and `quality="premium"`; do not set `retry=True` while the job is pending. +For a Premium transcript with speaker labels, first call `client.transcripts.quote(video_id)` for a free quote, then read with `quality="premium"`. A Premium read uses credits from your plan, then your on-demand budget. It may return `pending`; poll after `job.next_poll_seconds`. A `failed` result needs an explicit retry. [Premium states and polling](https://github.com/arcmira/python/blob/master/GUIDE.md#premium-transcripts). -`read.data` is a `TranscriptResult`, discriminated on `state`. `ready` carries the transcript. `pending` carries `job`, with `eta_seconds`, `next_poll_seconds` and `charge`. `failed` means the last transcription failed or was refunded; it carries `job` and `last_attempt` and uses no credits. `retry=True` transcribes it again and uses credits again. Without `with_raw_response`, `client.transcripts.get(...)` returns the same union without the status and headers. +## Monitor company mentions -A quote is free and changes nothing. +Follow an exact name in newly analyzed media, even before it is indexed. Replace `you@example.com` with your alert address. External recipients must confirm before delivery. ```python -quote = client.transcripts.quote("dQw4w9WgXcQ") -print(quote.quote.rows, quote.charge.amount, quote.charge.from_, quote.max_on_demand_cents) -``` - -`client.transcripts.list_requests()` lists past Premium transcriptions with their state. - -A read without `quality="premium"` returns captions and starts no transcription. - -## Errors - -A refusal raises a typed error from `arcmira.errors`. Each derives from `arcmira.core.api_error.ApiError` and carries `status_code`, `headers` and `body`. `body.error` holds `type`, `code`, `message`, `param`, `gate`, `unlock`, `details`, `doc_url` and `request_id`. Switch on `type` and `gate` first. Codes inside a type can grow. - -| Status | Error | Example codes | -|---|---|---| -| 400 | `BadRequestError` | `invalid_query`, `id_required`, `invalid_cursor` | -| 401 | `UnauthorizedError` | `invalid_api_key` | -| 402 | `PaymentRequiredError` | `quota_exceeded`, `spend_limit_exceeded` | -| 403 | `ForbiddenError` | `paid_plan_required`, `freshness_requires_paid` | -| 404 | `NotFoundError` | `entity_not_found` | -| 409 | `ConflictError` | `tracker_already_exists` | -| 429 | `TooManyRequestsError` | `rate_limited` | -| 500, 503 | `InternalServerError`, `ServiceUnavailableError` | | - -A priced refusal carries the price in `error.details.quote`. Nothing is charged. - -```python -from arcmira.errors import ForbiddenError, PaymentRequiredError - -try: - client.transcripts.get("dQw4w9WgXcQ", quality="premium") -except (PaymentRequiredError, ForbiddenError) as refusal: - error = refusal.body.error - print(error.code, error.details.quote.rows, error.unlock.url) -``` - -`str(refusal)` reads `402 quota_exceeded: `. `member_limit` and `fair_use_cap` are 402 refusals with no `unlock`: an admin of the account raises the limit, or it resets with the month. `on_demand_paused` is a 402 refusal with no `unlock` too: a payment for on-demand usage was declined, and an admin of the account updates the card at https://arcmira.com/dashboard/billing. - -## Pagination - -`mentions.list`, `recommendations.list`, `channels.videos.list` and `transcripts.list_requests` return pagers. Iterate them and they follow `next_cursor` for you. Cursors are opaque. Keep the filters the same between pages. - -```python -for episode in client.channels.videos.list("UC-DRzaGnL_vtBUpCFH5M0tg", limit=10): - print(episode.video_id) -``` - -Each page body names its rows: `mentions`, `recommendations`, `episodes` or `requests`. The alert lists (`monitors.alerts.list`, `trackers.alerts.list`) return one page of `alerts`, newest first. Pass a larger `limit` to read further. - -## Async - -`AsyncArcmira` has the same methods to await. Paginated methods return async iterators after you await the first page. - -```python -from arcmira import AsyncArcmira +from arcmira import Arcmira +from arcmira.monitors import AddEntitiesRequestNamesItem -async def ramp_sponsorships(): - client = AsyncArcmira() - async for row in await client.recommendations.list(entity_id="ent_14", class_="sponsored"): - print(row.class_, row.media.video_id, row.start_seconds) +client = Arcmira() +created = client.monitors.create( + name="Company watch", + notify_frequency="daily", + notify_emails=["you@example.com"], +) +added = client.monitors.entities.add( + created.monitor.id, + names=[AddEntitiesRequestNamesItem(name="Ramp", type="organization")], +) +for result in added.results: + print(result.tracker_id if result.attached else result.reason) ``` -`class` is a Python keyword, so the parameter and the field are spelled `class_`. The wire name stays `class`. - -## Methods - -Each method has the full parameter list in the [generated reference](reference.md). - -| Group | Methods | -|---|---| -| `entities` | `resolve`, `get`, `momentum` | -| `mentions` | `list`, `count` | -| `recommendations` | `list` | -| `transcripts` | `search`, `get`, `quote`, `list_requests` | -| `channels` | `coverage`, `videos.list`, `sponsors.list` | -| `monitors` | `list`, `create`, `update`, `delete`, `rotate_webhook_secret`, `trackers.list`, `trackers.add`, `entities.add`, `alerts.list` | -| `trackers` | `list`, `update`, `delete`, `alerts.list` | -| `integrations` | `slack.list` | -| `feedback` | `submit`, `get` | -| `me` | `get`, `update_settings` | -| `health` | `check` | - -`transcripts.search` returns spoken passages from `GET /v1/search`. Its filters take ids too. +This creates a monitor and tracker in your account. Creation is free; delivered alerts use credits from your plan, then your on-demand budget. Check each `attached` result because a name may already belong to another monitor. For retries of writes, reuse the same `idempotency_key`. -Every tracker lives in a monitor, which sets how it alerts. To follow an entity you have an id for, call `monitors.entities.add(monitor_id, entity_ids=["ent_14"])`. To watch an exact name before it is indexed, pass `names=[AddEntitiesRequestNamesItem(name="Ramp", type="organization")]` (from `arcmira.monitors`) to the same call. A channel is named by its YouTube channel id. +## Choose the next task -`me.get()` names the account the key spends as `account` (`id`, `name`, `kind`, `plan`) and your `role` there; `email_masked` is you. +| Task | SDK call | +| --- | --- | +| Find a company, person, or show | `entities.resolve(q=..., context=...)` | +| Read mentions of an entity | `mentions.list(entity_id=...)` | +| Find a channel's sponsors | `channels.sponsors.list(channel_id)` | +| Separate recommendations from paid mentions | `recommendations.list(entity_id=..., class_="organic")` | +| Read alerts from a monitor | `monitors.alerts.list(monitor_id)` | -See [CHANGELOG.md](CHANGELOG.md) for what changed from 0.3. +Entity filters take ids. Resolve names first; explain a `suggested` match and ask the user when the resolver returns `ask`. Use `after` inclusive and `before` exclusive for date windows. Paginated methods fetch more rows as you iterate, so stop when you have enough. -## Agents +`AsyncArcmira` provides the same methods asynchronously. Await the first page, then use `async for` for paginated results. -The [Arcmira MCP server](https://github.com/arcmira/mcp) gives AI agents the same data at `https://mcp.arcmira.com/mcp`. [llms.txt](llms.txt) describes this package for agents. +## Use with an AI agent -## Regenerate and verify - -Run `bash scripts/generate.sh` with Node 22 or newer, Python 3, Docker, and Fern access for the `arcmira` organization. Generation pins Fern CLI 5.131.1 and Python generator 5.31.0, disables CLI version redirection and telemetry, and reads `fern/openapi.json`. `fern/method-names.json` names the group and method of every operation by operationId. An operation without a name, or a name for an operation the document lacks, fails the build. The overlay combines the transcript read's success schemas into `TranscriptResult` and rejects unknown or ambiguous cursor collections. Generated source is never edited by hand. `scripts/overrides/api_error.py` holds the `ApiError` text, and the installer copies it back after every generation. - -```sh -uv sync -uv run python -m unittest discover -s tests -v -uv build -``` +For tasks such as "pull YouTube transcripts", "find quotes with timestamps", "research podcast sponsors", or "monitor company mentions", connect the [Arcmira MCP server](https://github.com/arcmira/mcp). The [agent index](https://arcmira.com/llms.txt) links to current API and citation guidance. -The tests use a local HTTP server that returns the bodies the API sends. They check both client variants, the ready and pending reads, typed refusals with their quote, the query and body each call sends, and opaque pagination. They need no live API key and use no credits. +## Reference -## License +- [SDK guide](https://github.com/arcmira/python/blob/master/GUIDE.md): async examples, typed errors, pagination, and Premium polling. +- [Method reference](https://github.com/arcmira/python/blob/master/reference.md): request and response types for every method. +- [TypeScript and JavaScript SDK](https://github.com/arcmira/arcmira). +- [Release notes](https://github.com/arcmira/python/blob/master/CHANGELOG.md). -Apache-2.0. See [LICENSE](./LICENSE). +Requires Python 3.9+. Apache-2.0. [Source](https://github.com/arcmira/python) · [License](https://github.com/arcmira/python/blob/master/LICENSE). diff --git a/VERSION b/VERSION index ee6cdce..b616048 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.1 +0.6.2 diff --git a/llms.txt b/llms.txt index 0b4ed16..a9d4f03 100644 --- a/llms.txt +++ b/llms.txt @@ -4,6 +4,10 @@ This repo is the `arcmira` Python SDK for the Arcmira API: synchronous and asynchronous clients, typed responses and cursor pagination. The live agent index is on the website. Fetch that for product, API and citation rules, not a copy inside this package. +## Tasks + +Use Arcmira to pull YouTube transcripts, find quotes with timestamps, research podcast sponsors, and monitor company mentions. Start with the README examples. Keep source links beside quotes, preserve the reported coverage window, and distinguish captions from Premium transcripts. For agent tasks, connect the MCP server; for an application, use this SDK. + ## Install ```sh diff --git a/src/arcmira/_package.py b/src/arcmira/_package.py index 6262c42..798b6cc 100644 --- a/src/arcmira/_package.py +++ b/src/arcmira/_package.py @@ -1,5 +1,5 @@ # Written by scripts/install-generated.py from VERSION. -__version__ = '0.6.1' +__version__ = '0.6.2' homepage = "https://arcmira.com" docs = "https://arcmira.com/docs" api_base = "https://api.arcmira.com/v1" diff --git a/src/arcmira/core/client_wrapper.py b/src/arcmira/core/client_wrapper.py index d487135..ec87430 100644 --- a/src/arcmira/core/client_wrapper.py +++ b/src/arcmira/core/client_wrapper.py @@ -33,7 +33,7 @@ def get_headers(self) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { - "User-Agent": "arcmira/0.6.1", + "User-Agent": "arcmira/0.6.2", "X-Fern-Language": "Python", "X-Fern-Runtime": f"python/{platform.python_version()}", "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", diff --git a/tests/fixtures/readme-responses.json b/tests/fixtures/readme-responses.json new file mode 100644 index 0000000..4858739 --- /dev/null +++ b/tests/fixtures/readme-responses.json @@ -0,0 +1,120 @@ +{ + "search": { + "query": "x", + "limit": 1, + "returned": 1, + "filters": { + "channel_ids": [ + "x" + ], + "entity_ids": [ + "x" + ], + "about": [ + { + "id": "x", + "name": "x", + "type": "x" + } + ], + "by": [ + { + "id": "x", + "name": "x", + "type": "x" + } + ], + "kind": [ + "sponsored" + ] + }, + "window": { + "after": "x", + "before": "x" + }, + "chunks": [ + { + "id": "x", + "video_id": "x", + "channel_id": "x", + "source": "x", + "published_at": "x", + "text": "agent payments on air", + "start_seconds": 1, + "watch_url": "https://arcmira.com/watch?v=dQw4w9WgXcQ&t=1", + "score": 1 + } + ], + "as_of": "x", + "search_index": { + "state": "live", + "missing_before": "x" + }, + "note": "x" + }, + "get_transcript": { + "state": "ready", + "video": { + "id": "x", + "title": "x", + "channel_id": "x", + "channel_name": "x", + "published_at": "x", + "duration_seconds": 1, + "watch_url": "x" + }, + "quality": "captions", + "source": "creator_captions", + "language": "x", + "languages": [ + { + "code": "x", + "name": "x", + "generated": true + } + ], + "rows_billed": 1, + "as_of": "x", + "note": "x", + "lines": [ + { + "start": 0, + "end": 4, + "text": "Welcome back to the show." + } + ] + }, + "create_monitor": { + "monitor": { + "id": "mon_2", + "name": "x", + "paused": true, + "notify_emails": [ + "x" + ], + "notify_webhook": true, + "webhook_disabled_at": "x", + "webhook_disabled_reason": "x", + "notify_slack": true, + "slack_integration_id": "x", + "slack_channel_id": "x", + "created_at": "x", + "updated_at": "x", + "access": "account", + "tracker_count": 1 + }, + "message": "x" + }, + "add_entities": { + "monitor_id": "mon_2", + "results": [ + { + "name": "Ramp", + "type": "organization", + "tracker_id": "trk_200", + "created": true, + "attached": true + } + ] + } +} diff --git a/tests/test_readme.py b/tests/test_readme.py new file mode 100644 index 0000000..8c8c12b --- /dev/null +++ b/tests/test_readme.py @@ -0,0 +1,45 @@ +import contextlib +import io +import json +import re +import unittest +from pathlib import Path +from unittest.mock import patch + +import httpx +from arcmira import Arcmira + +ROOT = Path(__file__).resolve().parents[1] +RESPONSES = json.loads((ROOT / 'tests/fixtures/readme-responses.json').read_text()) + + +class ReadmeTests(unittest.TestCase): + def test_examples_return_quotes_captions_and_attached_tracker(self): + requests = [] + routes = { + ('GET', '/v1/search'): 'search', + ('GET', '/v1/transcripts/dQw4w9WgXcQ'): 'get_transcript', + ('POST', '/v1/monitors'): 'create_monitor', + ('POST', '/v1/monitors/mon_2/entities'): 'add_entities', + } + def respond(request): + requests.append(request) + return httpx.Response(200, json=RESPONSES[routes[(request.method, request.url.path)]]) + with httpx.Client(transport=httpx.MockTransport(respond)) as http: + client = Arcmira(api_key='fixture-key', httpx_client=http, max_retries=0) + output = io.StringIO() + examples = re.findall(r'```python\n(.*?)```', (ROOT / 'README.md').read_text(), re.S) + self.assertEqual(len(examples), 3) + with patch('arcmira.Arcmira', return_value=client), contextlib.redirect_stdout(output): + for code in examples: + exec(compile(code, 'README.md', 'exec'), {}) + printed = output.getvalue().splitlines() + self.assertIn('agent payments on air', printed) + self.assertIn('https://arcmira.com/watch?v=dQw4w9WgXcQ&t=1', printed) + self.assertIn('0.0 Welcome back to the show.', printed) + self.assertIn('trk_200', printed) + self.assertEqual([(r.method, r.url.path) for r in requests], list(routes)) + self.assertEqual(dict(requests[0].url.params), dict(q='agent payments', limit='5')) + self.assertEqual(json.loads(requests[2].content), dict(name='Company watch', notify_frequency='daily', notify_emails=['you@example.com'])) + self.assertEqual(json.loads(requests[3].content), dict(names=[dict(name='Ramp', type='organization')])) + self.assertTrue(all(r.headers['Authorization'] == 'Bearer fixture-key' for r in requests))