Metadata-Version: 2.5
Name: bowmark-web
Version: 1.25.0
Summary: The Python client for the Bowmark capability library — the whole bowmark.* surface, with no Bowmark source on your disk. ZERO runtime dependencies, deliberately and permanently.
Project-URL: Homepage, https://bowmark.ai
Project-URL: Documentation, https://bowmark.ai/install
Project-URL: Repository, https://github.com/bowmark-ai/web
Author-email: Bowmark AI <christopher@bowmark.ai>
License: MIT
Keywords: agent,bowmark,browser-automation,capability,web-automation
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# bowmark-web

The Python client for the [Bowmark](https://bowmark.ai) capability library — the whole
`bowmark.*` surface, running on our servers, with no Bowmark source on your disk.

**Zero runtime dependencies**, deliberately and permanently. `urllib` and `json` are the
whole transport.

```sh
pip install bowmark-web bowmark-web-stubs
```

**You need an API key.** Sign up at https://bowmark.ai/sign-up, create a key at
https://bowmark.ai/dashboard/keys, and pass it in:

```python
from bowmark_web import session

async with session(api_key="bmk_…") as bm:   # or set BOWMARK_API_KEY and omit it
    tracks = await bm.music.search("aphex twin")
```

A client with no key raises `BowmarkError` with `code="no_api_key"` on its first call,
before anything is sent.

The second package is where the types live. [PEP 561](https://peps.python.org/pep-0561/)
requires a stub distribution to be named `<pkg>-stubs`, so the split is mandated rather
than chosen. Skip it and the client still works; you just lose autocomplete for the
catalog.

## Two shapes, and the second one is the one you want

```python
import asyncio
from bowmark_web import bowmark, session


async def main() -> None:
    # ONE call. Opens a one-shot session, runs it, closes it.
    tracks = await bowmark.music.search("aphex twin")

    # SEVERAL calls against ONE live instance — one browser, one cookie jar.
    async with session() as bm:
        found = await bm.music.search("burial", 5)
        detail = await bm.music.getTrack(found["tracks"][0]["url"])
        print(detail["track"]["description"])


asyncio.run(main())
```

A store flow is the same shape, and is where a shared instance actually earns its keep:

```python
async with session() as bm:
    found = await bm.providers.gymshark.search({"query": "hoodie"})
    await bm.providers.gymshark.addToCart(
        {"variantId": found["products"][0]["variantId"]}
    )
    cart = await bm.providers.gymshark.getCart()   # itemCount is 1
```

**A type checker will not know `gymshark`, and that is not a bug.** It is a Shopify family
member; there are half a million storefronts and no manifest carries one, so neither
client's generated types enumerate them. The call works. Silence the checker with
`getattr(bm.providers, "gymshark")` if you want a clean run.

**`bowmark.<unit>.<fn>()` is wrong for a multi-step flow, and it fails quietly.** Two
calls get two instances and two cookie jars, so a cart the first filled does not exist
for the second — Shopify answers `POST /cart/add.js` with a 200 and the line echoed
back, then reports `item_count: 0`. Reach for `session()` the moment a flow has a second
step.

**Each call inside a session is a round trip.** Said out loud rather than hidden: a
surface that looks like a local function call and is actually stateful is how an N+1 gets
written without anyone noticing.

## Configuration

Read at CALL time, not at import — so a `.env` loader that runs after your first import
still works.

| | |
|---|---|
| `BOWMARK_API_KEY` | `bmk_…`. **Required** unless you pass `api_key=`. With neither, the first call raises `code="no_api_key"`. |
| `BOWMARK_API_URL` | Defaults to `https://api.bowmark.ai`. |

Every entry point takes the same keyword overrides — `api_key` first, then `base_url`, `headers`,
`timeout`, `on_log`.

## Making raw HTTP calls without the SDK

If you need to call the API directly without using the client library, you must override Python's default `urllib` User-Agent header. The default `Python-urllib/3.x` is blocked by Cloudflare, which sits in front of our API. Set a custom User-Agent on every request:

```python
import urllib.request
import json

url = "https://api.bowmark.ai/v1/session"
headers = {
    "Authorization": f"Bearer {api_key}",
    "Content-Type": "application/json",
    "User-Agent": "bowmark-client",  # Required: override the default urllib User-Agent
}

request = urllib.request.Request(url, headers=headers, method="POST", data=json.dumps({}).encode())
with urllib.request.urlopen(request) as response:
    session = json.loads(response.read())
```

This requirement applies **only** to raw HTTP calls using `urllib`. The shipped client library handles this automatically. Any User-Agent header other than Python's default (e.g., `"bowmark-client"`, `"my-app/1.0"`, even `"curl/7.0"`) works fine.

## Errors

```python
from bowmark_web import BowmarkError, BowmarkNeedsUserError

try:
    await bm.providers.linkedin.searchPeople({"query": "cto"})
except BowmarkNeedsUserError as paused:
    # NOT a failure. The call paused for a human login and the session is still open.
    print("sign in here:", paused.handoff["url"])
except BowmarkError as err:
    print(err.code, err.path)     # branch on `code`; `str(err)` is prose for a person
```

`code` is the field to branch on. `str(err)` is written for an agent to read, which is
the wrong shape for an `except` block — both are present and neither replaces the other.

## Two guards run before the request, not after

A bad argument is refused in your process, so it never costs a round trip and is never
metered.

- **The wire guard** refuses anything JSON cannot carry — a `datetime`, a `set`, a
  `tuple`, a class instance, a non-string dict key, an `int` past 2⁵³−1, a circular
  structure — and names the exact position: `args[0].checkIn`.
- **The shape guard** refuses an argument the declared signature does not accept, from
  the same generated table the TypeScript client reads.

Both lean toward **accepting**. An object is open, an unmodelled type accepts anything,
and a surplus argument is ignored — because a false refusal is a correct call rejected by
your own client with no flag to turn it off, and a false accept costs one round trip.

Two consequences worth knowing:

- **An unknown FUNCTION on a known unit is refused**, naming the manifest version. Upgrade
  the package, or use `run(script)`, which is untyped by construction and reaches anything.
- **An unknown UNIT passes straight through.** Most of the library is Shopify family
  members (`bowmark.providers.gymshark.…`) which are deliberately in no manifest — there
  are half a million of them.

## `run(script)` — the string surface

```python
from bowmark_web import run

envelope = await run('return await bowmark.music.search("burial")')
envelope["status"]   # "ok" | "error" | "partial" | "needs_user"
```

Returns the envelope rather than raising, because a script is composite: `status`, `logs`
and `result` are read together. Untyped by construction — a string gets no checking, so
the generated stubs cover `session()` and `bowmark` and never this.

## `login()` is TYPED, but not yet wired here

Multi-account connections' `packages/catalog/src/manifest.ts` synthesizes a `login()`
method (and a trailing `{ "connection": … }` option on every signed-in one) for the
manifest BOTH clients generate from, so `bowmark_web-stubs` types them for parity with
the Node client. **This runtime does not yet implement the credential lift `login()`
needs**: the Node client's transport (`packages/bowmark-web/node/src/session.ts`) lifts
`username`/`password`/`totpCode`/`totpSeed` into a per-request
`x-bowmark-credential-<name>` header before a `login()` call reaches the wire, because
the server refuses a plain string in that position. Calling the typed `login()` method
from this Python client today sends those fields as plain strings in the body and gets
a clear `CredentialError` back — loud, not a silent leak, but not yet a working call.
`bm.connections.list/delete/update` are Node-only for the same reason: neither is
generated from the manifest, and neither has a Python-runtime counterpart yet.

## Development

```sh
python3 -m unittest discover -s tests -t .
```

Stdlib only — no pip, no venv, no network — which is what lets this suite run inside the
monorepo's hermetic CI gate. It is also the point of the package having no dependencies: a
package that cannot be tested without installing something is one whose tests do not run.

`bowmark_web/_validators.json` and `python-stubs/bowmark_web-stubs/__init__.pyi` are
**GENERATED** by `pnpm run gen:public-types` in the monorepo, from the same library
manifest as the TypeScript `.d.ts`. Never edit them by hand. `gate:public-types` asserts
the two languages type the same functions and fails a PR that breaks it; a stale copy is
warned rather than failed, because every unit that lands anywhere staleens it and no
branch can keep it true — `regen-public-types.yml` repairs that on `main`.
