From 2a13ebd98d885184ed1d9cc7d6347f43bdd033e3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 7 Aug 2026 04:04:00 +0000 Subject: [PATCH] Sync SDK v1.2.0 from neo repository --- AGENT_GUIDE.md | 479 ++++++++++++++---- CHANGELOG.md | 25 +- README.md | 329 ++++++++++++- ofspectrum/__init__.py | 58 ++- ofspectrum/client.py | 64 ++- ofspectrum/exceptions.py | 229 +++++++-- ofspectrum/models/__init__.py | 32 +- ofspectrum/models/notebook.py | 54 +- ofspectrum/models/notebook_version.py | 496 +++++++++++++++++++ ofspectrum/models/quota.py | 2 +- ofspectrum/models/token.py | 94 +++- ofspectrum/resources/__init__.py | 7 +- ofspectrum/resources/audio.py | 10 +- ofspectrum/resources/base.py | 3 +- ofspectrum/resources/notebook_commits.py | 319 ++++++++++++ ofspectrum/resources/notebooks.py | 600 +++++++++++++++++++++-- ofspectrum/resources/quotas.py | 4 +- ofspectrum/resources/tokens.py | 66 ++- ofspectrum/resources/webhooks.py | 5 +- ofspectrum/utils/retry.py | 6 +- pyproject.toml | 3 +- test_api.py | 93 ++-- tests/test_client.py | 108 ++++ tests/test_exceptions.py | 91 ++++ tests/test_notebook_commits.py | 286 +++++++++++ tests/test_notebooks.py | 486 ++++++++++++++++++ tests/test_tokens.py | 167 ++++++- tests/test_version.py | 31 ++ 28 files changed, 3811 insertions(+), 336 deletions(-) create mode 100644 ofspectrum/models/notebook_version.py create mode 100644 ofspectrum/resources/notebook_commits.py create mode 100644 tests/test_client.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_notebook_commits.py create mode 100644 tests/test_notebooks.py create mode 100644 tests/test_version.py diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 600bdbd..042e084 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -12,7 +12,9 @@ The public SDK repository is: https://github.com/ofspectrum/python-sdk ``` -Customer applications should normally install the SDK with `pip install ofspectrum`; they should not need the private Neo monorepo source. +Customer applications should normally install SDK `1.2.0` with +`pip install "ofspectrum==1.2.0"`; they should not need the private Neo +monorepo source. If an agent needs SDK source orientation, use the public SDK repository above. From that repository root, inspect these files first: @@ -69,13 +71,14 @@ Ask these questions before implementation, one at a time: 2. What is the application goal, or what does the existing app do and where should watermarking fit? 3. What is the primary entity that should be traceable: voice actor, project, audio asset, or something custom? For an existing project, map this onto an entity you already have. 4. Should each watermarked file get a unique token, or share a token with a voice actor/project/asset group? -5. Do you need a custom public verification key? If not, use Standard tokens. +5. Do you need a custom public verification key or any new private notebook? If yes, use Pro; otherwise use Standard. 6. What metadata should be public? 7. What metadata should be private and credential-gated? -8. Do you need to store license, provenance, or C2PA-like manifest files? -9. Should the app support encode only, decode lookup, streaming PCM encode, or all of them? -10. Where should token IDs, notebook IDs, media IDs, and file records be stored? For an existing project, prefer adding columns/relations to existing records rather than creating a parallel schema. -11. For a new project: what should be built (CLI app, web server, worker, API service, notebook script)? For an existing project: where in the current codebase should encode/decode/notebook calls be added (e.g. upload handler, publish step, background job, moderation pipeline)? +8. Do you need license or provenance summaries, or supporting image, audio, or video evidence? +9. Should version control inherit the account default, be explicitly on, or be explicitly off for each token? +10. Should the app support encode only, decode lookup, streaming PCM encode, or all of them? +11. Where should token IDs, notebook IDs, media IDs, revisions, and file records be stored? For an existing project, prefer adding columns/relations to existing records rather than creating a parallel schema. +12. For a new project: what should be built (CLI app, web server, worker, API service, notebook script)? For an existing project: where in the current codebase should encode/decode/notebook calls be added (e.g. upload handler, publish step, background job, moderation pipeline)? Recommended interview flow: @@ -103,11 +106,11 @@ Token ownership model: Token type policy: - Use Standard tokens by default. -- Use Pro tokens only when the workflow needs a configurable public verification key. +- Use Pro tokens when the workflow needs a configurable public verification key or any new private notebook. Metadata/provenance model: - Choose one: - - Use notebooks for public/private metadata, provenance, license text, and attachments. + - Use notebooks for public/private metadata, provenance, license text, and accepted image/audio/video evidence. - Custom: [describe] Audio workflow: @@ -120,14 +123,18 @@ Notebook workflow: - Choose one or more: - Public notebook for metadata visible to anyone who resolves the token. - Private notebook protected by a credential. - - Media attachments for manifests, images, licenses, or reference assets. + - Image, audio, or video attachments for supporting evidence or reference assets. + - Revision-safe staged saves for text and media changes. Implementation requirements: - Store OFSPECTRUM_API_KEY in environment variables. - Never hardcode API keys. - Catch OfSpectrumError and its subclasses. +- Branch on stable `OfSpectrumError.code` values instead of parsing messages. - Treat quota errors as customer-actionable errors. -- Keep token IDs and notebook IDs in the app database. +- Keep token IDs, notebook IDs, media IDs, and current notebook revisions in the app database. +- Use a new idempotency key for each logical staged-save operation and reuse that key only when retrying the same operation. +- Configure account defaults and authorize storage auto-expansion in the OfSpectrum Web Console; an API key must not attempt to create charge authorization. - For an existing project, reuse the current datastore and add references (e.g. ofs_token_id) to existing records instead of duplicating a parallel schema. - Do not change existing auth, storage, or framework conventions more than the integration requires. - Show clear, customer-facing error messages. @@ -198,52 +205,114 @@ Customers must create API keys in the OfSpectrum dashboard. The SDK does not nee Tokens are the primary routing unit for watermark identity. A token is embedded into audio during encode and returned during decode. -Supported SDK methods: +Common SDK calls: ```python -client.tokens.list() -client.tokens.get(token_id) +import os + +tokens = client.tokens.list() +token = client.tokens.get("token-uuid") +client.tokens.create(name="Standard Token") + +# Pro creation always requires an explicit public_key. +verification_key = int(os.environ["OFSPECTRUM_PUBLIC_KEY"]) client.tokens.create( - name, - token_type="standard", - public_key=None, - ai_auth_enabled=False, - ai_auth_access_type=None, - ai_auth_price=None, - ai_auth_other_instructions=None, - ai_auth_tags=None, + name="Pro Token", + token_type="pro", + public_key=verification_key, ) -client.tokens.list_ai_auth_tags() -client.tokens.create_ai_auth_tag(tag) + +tags = client.tokens.list_ai_auth_tags() +tag = client.tokens.create_ai_auth_tag("Voice Clone") client.tokens.update( - token_id, - name=None, - public_key=None, - token_type=None, - enterprise_verification=None, - ai_auth_enabled=None, - ai_auth_access_type=None, - ai_auth_price=None, - ai_auth_other_instructions=None, - ai_auth_tags=None, + token_id="token-uuid", + name="Renamed Token", ) ``` +`tokens.create()` and `tokens.update()` also accept the documented AI +authorization fields and the two notebook-setting overrides below. + Current public token types: -| Type | Use When | -|------|----------| -| `standard` | Default. Use when the app does not need a custom verification key. | -| `pro` | Use when the workflow needs a configurable `public_key`. | +| Type | Notebook Contract | Permanent Account Capacity | Use When | +|------|-------------------|----------------------------|----------| +| Standard | One public notebook; no new private notebooks (zero) | Permanent 1 GiB | The app needs neither a custom verification key nor a new private notebook. | +| Pro | One public notebook; five private notebooks | Permanent 6 GiB | The workflow needs a configurable `public_key` or any new private notebook. | +| Enterprise | One public notebook; ten private notebooks | Permanent 11 GiB | Admin-managed Enterprise workflows. Public SDK callers cannot create this type. | Recommended behavior: - Create Standard tokens by default. -- Create Pro tokens only when the customer explicitly needs a configurable verification key. +- Create Pro tokens when the customer needs a configurable verification key or any new private notebook. - Existing tokens can be upgraded from Standard to Pro, but cannot be downgraded. +- A Standard-to-Pro upgrade replaces that token's 1 GiB entitlement with 6 GiB; it does not produce 7 GiB. +- Permanent capacity remains with the account after token retirement. - A token type upgrade may consume quota or incur a billing charge. - Store token IDs in the customer app database. +Token responses in SDK `1.2.0` also expose: + +| Field | Meaning | +|-------|---------| +| `version_control_override` | `None` to inherit the account default, or an explicit `True`/`False`. | +| `storage_auto_expand_override` | `None` to inherit the account default, or an explicit `True`/`False`. | +| `version_control_enabled` | Effective version-control setting. | +| `storage_auto_expand_enabled` | Effective storage auto-expansion setting. | +| `storage_entitlement_bytes` | Permanent account capacity contributed by this token. | + +### Notebook Settings and Charge Authorization + +Both `version_control_override` and `storage_auto_expand_override` preserve four +distinct call states: + +| Value passed | `tokens.create()` | `tokens.update()` | +|--------------|-------------------|-------------------| +| Omitted | Make no token-level selection; account behavior applies. | Leave the current override unchanged. | +| `None` | Explicitly inherit the account default. | Clear the current override and inherit the account default. | +| `False` | Explicitly disable the setting for this token. | Explicitly disable the setting for this token. | +| `True` | Explicitly enable the setting for this token. | Explicitly enable the setting for this token. | + +```python +# Explicit token setting. +token = client.tokens.update( + token_id="token-uuid", + version_control_override=True, +) + +# Restore inheritance from the account default. +token = client.tokens.update( + token_id="token-uuid", + version_control_override=None, +) + +# Explicitly off. +token = client.tokens.update( + token_id="token-uuid", + version_control_override=False, +) + +# Leave both overrides unchanged by omitting them. +token = client.tokens.update( + token_id="token-uuid", + name="Renamed Token", +) +``` + +The SDK may manage version-control overrides. It cannot use an API key to turn +storage charging from effectively off to effectively on, including by changing +an override to `None` when the account default is on. The owner must configure +account defaults and authorize storage auto-expansion in the OfSpectrum Web Console +with a verified browser session. Handle a rejected transition by its stable error +code and direct the owner to the Web Console; do not retry it as a transient failure. + +Authorized overage capacity is allocated in whole 1 GiB blocks and charged when +a block is first allocated. Required blocks renew monthly while data needs them, +including after auto-expansion is disabled. Successful charges are not refunded +after deletion. A failed renewal preserves all data and allows read/delete, but +blocks writes that add new media data. Deletion during the 24-hour unpaid +reduction window can reduce the unpaid requirement. + ### AI Authorization Tokens can publish how AI systems are allowed to use the associated content. @@ -307,7 +376,10 @@ if result.watermarked: Use `public_key` only when the workflow requires explicit verification key configuration: ```python -result = client.audio.decode("suspect.wav", public_key=258) +import os + +verification_key = int(os.environ["OFSPECTRUM_PUBLIC_KEY"]) +result = client.audio.decode("suspect.wav", public_key=verification_key) ``` If a workflow requires `public_key` and the wrong key is provided, decode may return `watermarked=False` instead of raising an error. Treat this as a verification mismatch and check the token configuration. @@ -339,7 +411,7 @@ Supported SDK methods: ```python client.notebooks.list(token_id) -client.notebooks.get(note_id) +client.notebooks.get(note_id) # Current revision and ordered media. client.notebooks.create(token_id, note_name, text_content=None, is_public=True, credential_val=None) client.notebooks.update(note_id, note_name=None, text_content=None, credential_val=None) client.notebooks.delete(note_id) @@ -347,14 +419,40 @@ client.notebooks.upload_media(note_id, file, filename=None, media_type=None) client.notebooks.list_media(note_id) client.notebooks.get_media_url(media_id) client.notebooks.download_media(media_id, output_path=None) -client.notebooks.delete_media(media_id) +client.notebooks.delete_media(media_id, note_id=note_id) ``` +SDK `1.2.0` also provides revision-safe save sessions with typed results: + +| Method | Return type | +|--------|-------------| +| `begin(note_id, idempotency_key=...)` | `NotebookSaveSession` | +| `stage(note_id, save_session_id, file, idempotency_key=...)` | `NotebookStagedUpload` | +| `status(note_id, save_session_id)` | `NotebookSaveSessionStatus` | +| `upload_status(upload_id)` | `NotebookStagedUpload` | +| `cancel(note_id, save_session_id, idempotency_key=...)` | `NotebookSaveSessionCancellation` | +| `commit(note_id, ..., save_session_id=..., save_batch_id=...)` | `NotebookCommitResponse` | + +Use `NotebookDesiredState` for the complete notebook projection and +`NotebookDesiredMedia` for its ordered media entries. Each media entry references +exactly one current `media_id` or new staged `upload_id`; do not send only the +delta. Read attributes such as `upload_id` and `resulting_revision` directly from +the typed results rather than indexing them as dictionaries. + Media helper notes: -- `upload_media` accepts a path, `Path`, or file object. `filename` and `media_type` are optional; when omitted the SDK derives them from the file. Any file type is accepted (see the media limits below). +- Existing-notebook helpers (`update`, `upload_media`, and `delete_media`) first + fetch the authoritative current projection and use one revision-safe atomic + save session. They preserve visibility, credentials, unchanged text/name, and + all retained media in order; they never construct a desired state from a list + summary or partial local cache. +- `upload_media` accepts a path, `Path`, or file object. `filename` and `media_type` are optional hints; the server detects supported image, audio, and video content from file bytes. SVG is rejected. +- `delete_media` requires the owning `note_id` so it can remove only the selected + media ID from the complete current projection. +- Current media returned by `notebooks.get()` exposes `media_type`, `file_size_bytes`, and `display_order`; compatibility aliases remain available for older integrations. - `get_media_url` returns a short-lived signed URL for a media file (useful for previews or handing a link to a browser). - `download_media` returns the raw bytes, or writes to `output_path` and returns the path when provided. +- Use `client.notebook_commits` for new workflows that save text and the complete media projection together with revision and idempotency protection. Use notebooks for: @@ -364,7 +462,7 @@ Use notebooks for: - Voice actor/profile descriptions. - Project notes. - Asset metadata. -- C2PA/provenance manifest drafts as text or attached JSON files. +- C2PA/provenance manifest summaries as text or rendered image evidence. Notebook visibility: @@ -377,14 +475,13 @@ Notebook limits: | Token Type | Public Notebooks | Private Notebooks | |------------|------------------|-------------------| -| `standard` | 1 | 1 | -| `pro` | 1 | Unlimited | -| `enterprise` | 1 | Unlimited | +| `standard` | 1 | 0 new | +| `pro` | 1 | 5 | +| `enterprise` | 1 | 10 | Default naming: - When using the SDK, pass an explicit `note_name`. -- If the raw API receives no notebook name, the backend falls back to `Public` for public notebooks and `Private` for private notebooks. - Explicit names are recommended, for example `Public Provenance`, `Private License Details`, or `C2PA Manifest`. Additional constraints: @@ -392,10 +489,143 @@ Additional constraints: - Notebook names must be unique under the same token. - Private notebook credentials must be unique under the same token. - Private notebook credentials are optional at the SDK level, but apps that need credential-gated private metadata should explicitly pass `credential_val`. -- Notebook media is intended for supporting files such as manifests, licenses, images, or small references. Each notebook accepts at most 10 files, each file may be up to 100 MB, and the combined media limit is 10 GB per notebook. -- Standard tokens support one private notebook. Existing Pro and Enterprise tokens have no private notebook limit. +- Notebook media is limited to detected image, audio, and video content. Each notebook accepts at most 500 current files, each file may be up to 100 MiB, and notebook text is limited to 10 MiB of UTF-8 data. +- Media consumes account capacity. Each acquired Standard, Pro, or Enterprise token contributes a permanent 1, 6, or 11 GiB entitlement respectively; available capacity may also include legacy credit and paid whole-GiB blocks. +- Standard tokens cannot create new private notebooks. Existing Standard private notebooks are grandfathered and remain editable or deletable, but cannot be replaced after deletion. +- Pro tokens support five private notebooks, and Enterprise tokens support ten. - If a token already has a public notebook, update the existing notebook instead of creating another one. +#### Revision-Safe Save Flow + +Use `client.notebook_commits` when a save changes notebook text and media as one +logical operation: + +1. Call `notebooks.get()` and retain its current revision and ordered media. +2. Begin one save session. +3. Stage every new file with that session's `save_session_id` and a distinct + idempotency key. +4. Call `status()` when you need the state of the session and all its uploads. +5. Build the complete desired state with retained `media_id` values and new + `upload_id` values in display order. +6. Commit with the same session ID, the revision from step 1, and a UUID + `save_batch_id`; or cancel the session if the user abandons the save. +7. Reuse an operation's idempotency key only when retrying that exact operation. + +```python +from uuid import uuid4 + +from ofspectrum import NotebookDesiredMedia, NotebookDesiredState + +current = client.notebooks.get(notebook_id) +if current.revision is None or current.media is None: + raise RuntimeError("The current notebook response is incomplete") + +session = client.notebook_commits.begin( + current.id, + idempotency_key=str(uuid4()), +) +image = client.notebook_commits.stage( + current.id, + session.save_session_id, + file="evidence.png", + idempotency_key=str(uuid4()), +) +audio = client.notebook_commits.stage( + current.id, + session.save_session_id, + file="reference.wav", + idempotency_key=str(uuid4()), +) + +status = client.notebook_commits.status(current.id, session.save_session_id) +for upload in status.uploads: + print(upload.filename, upload.state) + +retained = tuple( + NotebookDesiredMedia( + media_id=media.id, + filename=media.filename, + display_order=index, + ) + for index, media in enumerate(current.media) +) +desired_state = NotebookDesiredState( + note_name=current.note_name, + text_content=current.text_content, + is_public=current.is_public, + credential_val=current.credential_val, + media=retained + ( + NotebookDesiredMedia( + upload_id=image.upload_id, + filename="evidence.png", + display_order=len(retained), + ), + NotebookDesiredMedia( + upload_id=audio.upload_id, + filename="reference.wav", + display_order=len(retained) + 1, + ), + ), +) + +save_batch_id = uuid4() +committed = client.notebook_commits.commit( + note_id=current.id, + desired_state=desired_state, + expected_revision=current.revision, + idempotency_key=str(uuid4()), + save_session_id=session.save_session_id, + save_batch_id=save_batch_id, +) +print(committed.resulting_revision) +``` + +If the user abandons the save before commit, cancel the same session instead: + +```python +cancelled = client.notebook_commits.cancel( + current.id, + session.save_session_id, + idempotency_key=str(uuid4()), +) +print(cancelled.state, cancelled.released_bytes) +``` + +Cancel and commit are alternative terminal actions. + +Staging reserves capacity but does not allocate or charge a paid block. Commit +rechecks the complete desired state and applies any required charge atomically +with the notebook change. + +The commit applies the complete current projection atomically and returns the +resulting revision. A revision mismatch never silently overwrites another save. +For a multi-notebook save, generate one UUID `save_batch_id`, commit notebooks +independently, and pass that value to every commit so one failure does not block +the other notebooks. + +#### Version Behavior + +When effective version control is on, each changed commit creates a full snapshot +of the notebook's name, text, and ordered media manifest. A canonical no-op does +not create a version or consume a rate-limit unit. + +Version rate limits are: + +- 60 effective versions per notebook in a rolling hour. +- 500 effective versions per account per UTC day. +- There is no total historical version-count limit. +- API keys cannot access owner history. Owner history is available only in the + Web Console; SDK `1.2.0` provides no history list, download, restore, or delete + methods. +- In the Web Console, restore appends a new version while preserving current + visibility and credentials. +- Individual version deletion is available in the Web Console, but one live version + must remain for a versioned notebook. +- Disabling version control preserves history for view/download, but restore is + unavailable until version control is re-enabled. +- Version history is not an independent file backup. Customers should retain + original copies of important files. + ### Quotas Use quota methods to preflight customer actions and show actionable UI. @@ -477,8 +707,8 @@ Recommended notebook structure: - Verification credential. - Media: - Profile image. - - License PDF. - - C2PA/provenance JSON draft. + - Supporting audio or video evidence. + - Rendered license or provenance image. Encode flow: @@ -521,9 +751,8 @@ Recommended notebook structure: - Client contract notes. - Contributor mapping. - Media: - - Project license. - - Rights manifest. - - Reference assets. + - Rendered rights or license evidence. + - Image, audio, or video reference assets. Tradeoffs: @@ -560,9 +789,9 @@ Recommended notebook structure: - Distribution history. - Customer/order ID. - Media: - - C2PA manifest JSON. - - License file. - - Cover/reference asset. + - C2PA-like manifest summaries in notebook text or rendered image evidence. + - Rendered license evidence. + - Cover or other accepted reference media. Tradeoffs: @@ -587,9 +816,10 @@ If the asset is high-value or externally distributed, create/use an asset token. Otherwise use the project or voice actor token. ``` -## Metadata and C2PA/Provenance Strategy +## Metadata and Provenance Strategy -The current SDK-supported way to attach metadata, license text, provenance details, or C2PA-like manifest files to a token is to use notebooks. +Use notebook text for metadata, license terms, and provenance summaries. Add +supporting media only when OfSpectrum identifies image, audio, or video content. Example public provenance note: @@ -601,22 +831,15 @@ client.notebooks.create( Creator: Example Studio Asset ID: asset_123 License: Commercial use permitted -Generated with: Example Voice Model v2 -Provenance: See attached manifest +Production reference: release_2026_07 +Provenance: See attached image evidence """.strip(), is_public=True, ) ``` -Attach a manifest: - -```python -client.notebooks.upload_media( - note_id=notebook.id, - file="c2pa-manifest.json", - media_type="application/json", -) -``` +Add image evidence through the revision-safe staged-save flow so the text and +complete ordered media projection commit together. ## Customer App Architecture @@ -632,14 +855,17 @@ ofs_tokens - local_owner_id - token_type - public_key +- version_control_override +- storage_auto_expand_override - created_at ofs_notebooks - id - ofs_note_id - ofs_token_id -- purpose # public_provenance, private_license, c2pa_manifest, profile +- purpose # public_provenance, private_license, profile - is_public +- current_revision - created_at audio_assets @@ -654,7 +880,7 @@ audio_assets Recommended runtime flow: 1. Resolve or create the correct token for the selected modeling pattern. -2. Create/update notebooks for metadata and provenance. +2. Create notebooks, then use revision-safe staged commits for metadata and media changes. 3. Encode the audio with the token ID. 4. Store the token ID and output file reference in the customer app database. 5. Decode suspect files and use returned token ID to look up local metadata. @@ -662,9 +888,13 @@ Recommended runtime flow: ## SDK Behavior Notes - API keys are created in the OfSpectrum dashboard, not through the SDK. -- Token IDs, notebook IDs, and media IDs should be stored in the customer app database. +- Token IDs, notebook IDs, media IDs, and current revisions should be stored in the customer app database. - Standard tokens are the safest default for new workflows. -- Pro tokens are needed when the workflow requires a configurable `public_key` or unlimited private notebooks. +- Standard, Pro, and Enterprise tokens contribute permanent 1, 6, and 11 GiB account capacity respectively. +- Pro tokens are needed when the workflow requires a configurable `public_key` or up to five private notebooks; Enterprise creation is Admin-managed. +- Account defaults and storage charge authorization are configured in the Web Console, not through an API key. +- A staged save begins one session and reuses its `save_session_id` across all files and the commit. +- SDK `1.2.0` exposes no owner-history methods; owner history remains Web-only. - The standard SDK encode flow refuses already-watermarked audio instead of overwriting the existing watermark. - Decode returns a token ID when a watermark is detected; the customer app should use that token ID to look up its own local business metadata. - Quota can change between a preflight check and the actual encode/decode call, so still handle quota errors from the final request. @@ -672,10 +902,17 @@ Recommended runtime flow: ## Error Handling Pattern ```python +from uuid import uuid4 + from ofspectrum import ( - OfSpectrumError, AuthenticationError, + ConflictError, + OfSpectrumError, + PaymentRequiredError, QuotaExceededError, + RateLimitError, + ServiceUnavailableError, + ValidationError, WatermarkExistsError, ) @@ -687,11 +924,47 @@ except AuthenticationError: show_user_message("Invalid OfSpectrum API key. Update your integration settings.") except WatermarkExistsError: show_user_message("This audio already appears to contain a watermark.") -except OfSpectrumError as exc: - show_user_message(f"OfSpectrum request failed: {exc.message}") + +try: + committed = client.notebook_commits.commit( + note_id=current.id, + desired_state=desired_state, + expected_revision=current.revision, + idempotency_key=str(uuid4()), + save_session_id=session.save_session_id, + save_batch_id=uuid4(), + ) +except ConflictError as exc: + if exc.code == "NotebookRevisionConflict": + show_user_message("This notebook changed. Reload it before saving again.") + else: + show_user_message(f"The notebook save conflicts with current state ({exc.code}).") +except PaymentRequiredError as exc: + show_user_message(f"Review notebook storage settings in the Web Console ({exc.code}).") +except ValidationError as exc: + show_user_message(f"Correct the notebook save request ({exc.code}).") +except RateLimitError as exc: + show_user_message(f"Wait before saving another version ({exc.code}).") +except ServiceUnavailableError as exc: + show_user_message(f"Retry the same operation later ({exc.code}).") +except OfSpectrumError: + show_user_message("The OfSpectrum request failed. Please try again.") ``` -Show concise, customer-facing error messages rather than raw API payloads. +Show concise, customer-facing error messages rather than raw API payloads. Branch +on stable codes, not English message text: + +| Exception | Example stable codes | +|-----------|----------------------| +| `ConflictError` | `NotebookRevisionConflict`, `NotebookCommitIdempotencyConflict`, `NotebookStagedReferenceConflict`, `NotebookCommitConflict`, `NotebookSaveSessionConflict`, `NotebookSaveSessionRequired` | +| `PaymentRequiredError` | `NotebookStorageAutoExpandDisabled`, `NotebookStoragePaymentRequired`, `StorageChargeAuthorizationRequired` | +| `ValidationError` | `NotebookMediaTooLarge`, `NotebookMediaFileLimitExceeded`, `NotebookTextTooLarge`, `UnsupportedNotebookMedia`, `NotebookMediaHashMismatch`, `NotebookCommitValidationError`, `NotebookCommitPayloadTooLarge` | +| `RateLimitError` | `NotebookVersionRateLimitExceeded` | +| `ServiceUnavailableError` | `NotebookStorageUnavailable`, `NotebookCommitUnavailable` | + +The SDK uses `ValidationError` with `InvalidNotebookCommitRequest` for invalid +local staged-commit arguments. OfSpectrum still validates media, capacity, +revision, and rate limits when it processes the request. ## Agent Implementation Checklist @@ -700,12 +973,13 @@ Ask the customer these questions before coding: 1. New project or an existing codebase? If existing, what is the stack, what entities exist, and where is data stored today? 2. What is the primary entity that should be traceable: voice actor, project, or audio asset? Map onto an existing entity when integrating. 3. Should each watermarked file get a unique token, or share a token? -4. Do you need a custom public verification key? If not, use Standard tokens. +4. Do you need a custom public verification key or any new private notebook? If yes, use Pro; otherwise use Standard. 5. What metadata should be public? 6. What metadata should be private and credential-gated? -7. Do you need to store license/provenance/C2PA-like manifests? -8. Should the app support decode and lookup workflows? -9. Where should token IDs, notebook IDs, and file records be stored? Reuse existing storage when integrating. +7. Do you need license/provenance text or accepted image, audio, or video evidence? +8. Should version control inherit the account default, be explicitly on, or be explicitly off? +9. Should the app support decode and lookup workflows? +10. Where should token IDs, notebook IDs, media IDs, revisions, and file records be stored? Reuse existing storage when integrating. Generate code only after these choices are clear. @@ -721,16 +995,31 @@ Ask the agent to run these checks after implementation. Use a staging or test AP - Create or select a test token. - Store the token ID in the customer app database. - Confirm `client.tokens.get(token.id)` returns the expected token. + - Confirm the configured overrides, effective settings, and permanent capacity match the account and token type. + - Confirm an API key cannot enable storage charge authorization. 3. **Notebook check** - Create one public notebook for the token. - Confirm creating a second public notebook is handled as a clear validation error. - - Create one private notebook with a credential on a Standard token. + - Confirm a Standard token rejects creation of a new private notebook. + - If testing a grandfathered Standard private notebook, confirm it can be updated or deleted but not replaced. - If using a Pro token, confirm multiple private notebooks can be created. - - Upload a small manifest or license file as notebook media. - - Confirm an eleventh media file is rejected. - -4. **Encode/decode check** + - Confirm `notebooks.get()` returns the current revision and ordered media. + - Begin one save session, stage multiple files with its `save_session_id`, and inspect `status()`. + - Commit with that `save_session_id`, the current revision, and a UUID `save_batch_id`. + - In a separate abandoned-save case, cancel the session and inspect the typed cancellation result. + - Retry the same commit with the same key and confirm it returns the completed result without duplicating the save. + - Confirm a stale revision returns `NotebookRevisionConflict` without overwriting current state. + - Confirm SVG and unsupported bytes return `UnsupportedNotebookMedia`. + - Confirm a file over 100 MiB and a 501st current media file are rejected with stable codes. + +4. **Version check** + - With effective version control on, confirm a changed commit creates a full version. + - Confirm a no-op commit does not create another version. + - Confirm the customer app does not expose history-management methods as SDK features. + - Confirm important source files are retained outside notebook version history. + +5. **Encode/decode check** - Encode one sample audio file. - Save the watermarked output separately from the source file. - Decode the watermarked output. @@ -738,17 +1027,17 @@ Ask the agent to run these checks after implementation. Use a staging or test AP - Confirm short or invalid audio is handled as a clear validation error. - If the workflow uses `public_key`, test both the correct key and an incorrect key. -5. **Duplicate watermark check** +6. **Duplicate watermark check** - Try to encode the already-watermarked output again. - Confirm the app handles `WatermarkExistsError` and does not treat this as a successful overwrite. -6. **Quota and billing check** +7. **Quota and billing check** - Call `client.quotas.get_encode_quota()` before encode. - Run encode/decode. - Call quota again and confirm usage/remaining values changed as expected for the environment. - Confirm quota or balance failures show customer-facing messages. -7. **Lookup check** +8. **Lookup check** - Use the decoded token ID to load the token and notebooks. - Confirm the customer app can show the correct local voice actor, project, or audio asset metadata. @@ -757,8 +1046,12 @@ Ask the agent to run these checks after implementation. Use a staging or test AP - Do not create API keys inside the customer app. API keys are created in the OfSpectrum dashboard. - Do not hardcode credentials, token IDs, or public keys. - Do not place `OFSPECTRUM_API_KEY` in browser-side code. +- Do not use an API key to authorize storage auto-expansion or new storage charges. - Do not assume encode overwrites an existing watermark. - Do not create multiple public notebooks for the same token; update the existing public notebook instead. +- Do not upload SVG, documents, or unknown bytes as notebook media. +- Do not implement owner-history list, download, restore, or delete through SDK `1.2.0`; those actions are Web-only. +- Do not market version history as file backup; retain original important files. ## Minimal End-to-End Example @@ -780,12 +1073,6 @@ public_note = client.notebooks.create( is_public=True, ) -client.notebooks.upload_media( - note_id=public_note.id, - file="c2pa-manifest.json", - media_type="application/json", -) - encoded = client.audio.encode( audio="input.wav", token_id=token.id, diff --git a/CHANGELOG.md b/CHANGELOG.md index edfbf84..36a8911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,31 @@ # Changelog -## Unreleased +## 1.2.0 - 2026-07-31 + +- Standard tokens no longer allow new private notebooks; existing private notebooks remain grandfathered. +- Limited Pro tokens to five private notebooks and Enterprise tokens to ten. +- Pro token creation now requires an explicit `public_key`. +- Added permanent account capacity from acquired tokens: 1 GiB for Standard, 6 GiB for Pro, and 11 GiB for Enterprise. +- Added token-level version-control and storage auto-expansion overrides, effective-setting fields, and permanent-capacity fields. +- Preserved omitted, `None`, `False`, and `True` distinctly when creating or updating token settings: omission leaves an update unchanged, `None` selects inheritance, and booleans explicitly disable or enable a setting. +- Documented whole-GiB allocation, immediate first-allocation charging, monthly renewal, successful-charge non-refund, payment-required restrictions, and the 24-hour unpaid reduction window. +- Updated `notebooks.get()` to return the current notebook revision and ordered media projection. +- Added revision-safe notebook save sessions: begin one session, stage multiple files with its `save_session_id`, inspect session or upload status, then cancel or commit the session. +- Changed existing-notebook `update`, `upload_media`, and `delete_media` helpers to read the authoritative complete projection and commit through one atomic save session; `delete_media` now requires the owning `note_id` so only the selected attachment is removed. +- Added UUID `save_batch_id` support for grouping independent notebook commits. +- Added typed save-session, staged-upload, cancellation, and commit results, plus typed notebook conflict, payment, validation, rate-limit, and availability exceptions. +- Raised notebook current media capacity to 500 files while preserving the 100 MiB per-file and 10 MiB UTF-8 text limits. +- Notebook media now accepts detected image, audio, and video content; SVG and unsupported bytes are rejected. +- Added stable notebook error codes for media validation, revision, save-session and idempotency conflicts, payment-required capacity, version-rate limits, and temporary unavailability. +- Documented full-snapshot version behavior and the 60-version-per-notebook rolling-hour and 500-version-per-account UTC-day limits. +- Owner history remains Web-only; SDK `1.2.0` does not provide history list, download, restore, or delete methods. +- Clarified that version history is not an independent file-backup service. ## 1.1.6 -- Added notebook media limits of 10 files, 100 MB per file, and 10 GB total. +- Documented the notebook media upload constraints available in that release. - Added SDK methods to list and create reusable AI authorization tags. -- Normalized unlimited private notebook limits to `None` in public SDK token models. +- Normalized legacy private notebook-limit responses in public SDK token models. ## 1.1.4 diff --git a/README.md b/README.md index 20bce6e..1eef39c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # OfSpectrum Python SDK -Official Python SDK for the OfSpectrum audio watermarking API. +Official Python SDK for the OfSpectrum audio watermarking API. This guide +describes the SDK `1.2.0` contract. ## Installation ```bash -pip install ofspectrum +pip install "ofspectrum==1.2.0" ``` Or install from source: @@ -57,6 +58,20 @@ Synthetic WAV files for encode/decode smoke tests are available in [`examples/au Standard tokens are the simplest option. Pro tokens support workflow-specific verification-key configuration. +Each acquired token also adds permanent notebook media capacity to its owner's +account: + +| Token Type | Notebook Contract | Permanent Account Capacity | +|------------|-------------------|----------------------------| +| Standard | One public notebook; no new private notebooks (zero) | Permanent 1 GiB | +| Pro | One public notebook; five private notebooks | Permanent 6 GiB | +| Enterprise | One public notebook; ten private notebooks | Permanent 11 GiB | + +Public SDK callers can create Standard and Pro tokens. Enterprise creation is +Admin-managed. Retiring a token does not remove its permanent capacity, and a +Standard-to-Pro upgrade replaces that token's 1 GiB entitlement with 6 GiB; it +does not add them together. + ```python import os @@ -124,6 +139,66 @@ Creating a tag does not attach it to a token. Pass the selected tag names to Token deletion is not available via API. Tokens are consumable resources. +### Notebook Settings + +SDK `1.2.0` token responses expose configured overrides, effective settings, +and permanent capacity: + +```python +token = client.tokens.get("token-uuid") + +print(token.version_control_override) # None, True, or False +print(token.version_control_enabled) # Effective boolean +print(token.storage_auto_expand_override) # None, True, or False +print(token.storage_auto_expand_enabled) # Effective boolean +print(token.storage_entitlement_bytes) # Permanent capacity in bytes +``` + +Both `version_control_override` and `storage_auto_expand_override` preserve all +four call states: + +| Value passed | `tokens.create()` | `tokens.update()` | +|--------------|-------------------|-------------------| +| Omitted | Make no token-level selection; account behavior applies. | Leave the current override unchanged. | +| `None` | Explicitly inherit the account default. | Clear the current override and inherit the account default. | +| `False` | Explicitly disable the setting for this token. | Explicitly disable the setting for this token. | +| `True` | Explicitly enable the setting for this token. | Explicitly enable the setting for this token. | + +```python +# Explicitly enable version control for this token. +token = client.tokens.update( + token_id="token-uuid", + version_control_override=True, +) + +# Return version control to the account default. +token = client.tokens.update( + token_id="token-uuid", + version_control_override=None, +) + +# Explicitly disable both settings for this token. +token = client.tokens.update( + token_id="token-uuid", + version_control_override=False, + storage_auto_expand_override=False, +) +``` + +You configure account defaults and authorize storage auto-expansion in the +OfSpectrum Web Console with a verified browser session. An API key cannot authorize +a transition that enables storage charges, including a transition to `None` +when the account default is enabled. Both account defaults are off until the +owner changes them. + +When authorized auto-expansion is needed, capacity is allocated in whole 1 GiB +blocks and charged when a block is first allocated. Required blocks renew +monthly while the data needs them, even if auto-expansion is later disabled. A +successful charge is not refunded after media deletion. If renewal fails, data +remains available for read and deletion, but writes that add new media data are +blocked. Deleting media during the 24-hour unpaid reduction window can reduce +the unpaid requirement. + ## Audio Watermarking ```python @@ -175,15 +250,7 @@ print(f"Encoded {result.audio_duration:.2f}s of PCM") Attach notes and media files to tokens. Private notebooks require a credential, and limits depend on your account and token configuration. -Notebook limits: - -| Token Type | Public Notebooks | Private Notebooks | -|------------|------------------|-------------------| -| `standard` | 1 | 1 | -| `pro` | 1 | Unlimited | -| `enterprise` | 1 | Unlimited | - -If the limit is reached, the SDK raises a `ValidationError` with a customer-facing message. +Standard tokens cannot create new private notebooks. Existing Standard private notebooks are grandfathered and remain editable or deletable, but cannot be replaced after deletion. If a limit is reached, the SDK raises a `ValidationError` with a customer-facing message. ```python notebook = client.notebooks.create( @@ -193,6 +260,7 @@ notebook = client.notebooks.create( is_public=True, ) +# Private notebook creation requires a Pro or Enterprise token. private_notebook = client.notebooks.create( token_id=token.id, note_name="Private Notes", @@ -201,16 +269,195 @@ private_notebook = client.notebooks.create( credential_val="choose-a-secure-credential", ) -client.notebooks.upload_media( +notebooks = client.notebooks.list(token_id=token.id) +``` + +Each notebook accepts up to 500 current media files, and each file may be up to +100 MiB. Notebook text may contain up to 10 MiB of UTF-8 data. Media uses the +account's available capacity from permanent token entitlements, legacy credit, +and any paid storage blocks. The server detects supported image, audio, and video +content from file bytes; SVG and unsupported bytes are rejected. Filenames and +submitted MIME values are hints only. + +Keep original copies of important files. Notebook version history is not an +independent file-backup service. + +For existing notebooks, `notebooks.update()`, `notebooks.upload_media()`, and +`notebooks.delete_media()` are atomic convenience methods. Each reads the +authoritative current revision and complete ordered media projection before it +writes. Unchanged name, text, visibility, and credential fields are retained; +an upload is staged once in one save session; and a media delete removes only +the selected ID. Pass the owning notebook ID when deleting media: + +```python +client.notebooks.delete_media( + media_id="media-uuid", note_id=notebook.id, +) +``` + +Do not build a desired state from `notebooks.list()` or a partial local cache. +Use `notebooks.get()` when constructing a manual commit. Notebook creation and +whole-notebook deletion continue to use `notebooks.create()` and +`notebooks.delete()` respectively. + +### Revision-Safe Staged Saves + +Use `client.notebook_commits` when one save changes text and media together. +First call `notebooks.get()`; unlike a list summary, it returns the current +revision and ordered media needed to build the complete desired state. +Current media exposes the API field names `media_type`, `file_size_bytes`, and +`display_order`; the legacy `content_type` and `file_size` aliases remain +available for compatibility. + +The save-session methods return typed models: + +| Method | Return type | +|--------|-------------| +| `begin(note_id, idempotency_key=...)` | `NotebookSaveSession` | +| `stage(note_id, save_session_id, file, idempotency_key=...)` | `NotebookStagedUpload` | +| `status(note_id, save_session_id)` | `NotebookSaveSessionStatus` | +| `upload_status(upload_id)` | `NotebookStagedUpload` | +| `cancel(note_id, save_session_id, idempotency_key=...)` | `NotebookSaveSessionCancellation` | +| `commit(note_id, ..., save_session_id=..., save_batch_id=...)` | `NotebookCommitResponse` | + +Begin one session and reuse its `save_session_id` for every file in that logical +save: + +```python +from uuid import uuid4 + +from ofspectrum import NotebookDesiredMedia, NotebookDesiredState + +current = client.notebooks.get(notebook.id) +if current.revision is None or current.media is None: + raise RuntimeError("The current notebook response is incomplete") + +session = client.notebook_commits.begin( + current.id, + idempotency_key=str(uuid4()), +) + +cover = client.notebook_commits.stage( + current.id, + session.save_session_id, file="cover.jpg", + idempotency_key=str(uuid4()), +) +preview = client.notebook_commits.stage( + current.id, + session.save_session_id, + file="preview.mp3", + idempotency_key=str(uuid4()), ) -notebooks = client.notebooks.list(token_id=token.id) +status = client.notebook_commits.status( + current.id, + session.save_session_id, +) +for upload in status.uploads: + print(upload.filename, upload.state) + +retained_media = tuple( + NotebookDesiredMedia( + media_id=media.id, + filename=media.filename, + display_order=index, + ) + for index, media in enumerate(current.media) +) + +desired_state = NotebookDesiredState( + note_name=current.note_name, + text_content="## Version 1.1\n\nUpdated release notes.", + is_public=current.is_public, + credential_val=current.credential_val, + media=retained_media + ( + NotebookDesiredMedia( + upload_id=cover.upload_id, + filename="cover.jpg", + display_order=len(retained_media), + ), + NotebookDesiredMedia( + upload_id=preview.upload_id, + filename="preview.mp3", + display_order=len(retained_media) + 1, + ), + ), +) + +save_batch_id = uuid4() +committed = client.notebook_commits.commit( + note_id=current.id, + desired_state=desired_state, + expected_revision=current.revision, + idempotency_key=str(uuid4()), + save_session_id=session.save_session_id, + save_batch_id=save_batch_id, +) +print(committed.resulting_revision) +for media in committed.media: + print(media.display_order, media.filename) ``` -Each notebook accepts up to 10 media files. Each file may be up to 100 MB, and -the combined media size limit is 10 GB per notebook. +Use `media_id` instead of `upload_id` in `NotebookDesiredMedia` to retain an +existing current file. Each entry requires exactly one of those IDs. + +If the user abandons the save before commit, cancel that same session instead: + +```python +cancelled = client.notebook_commits.cancel( + current.id, + session.save_session_id, + idempotency_key=str(uuid4()), +) +print(cancelled.state, cancelled.released_bytes) +``` + +Cancel and commit are alternative terminal actions; do not cancel a session you +intend to commit. + +A staged save follows this sequence: + +1. Read the notebook with `notebooks.get()` and retain its current revision and + ordered media. +2. Begin one save session. +3. Stage every new image, audio, or video with that session ID and a distinct + idempotency key. +4. Check session status when you need to inspect all staged files. +5. Commit the complete desired notebook state with the same session ID, the + revision from step 1, and a UUID `save_batch_id`; or cancel the session. +6. Reuse an operation's idempotency key only when retrying that exact operation. + +Staging reserves capacity but does not allocate or charge a paid block. Commit +rechecks the complete desired state and applies any required charge atomically +with the notebook change. + +The commit is atomic: it either applies the complete current state and returns a +new revision, or applies none of it. For a multi-notebook save, generate one UUID +`save_batch_id` and pass it to each independent notebook commit so one failed +notebook does not block the others. + +### Version Control + +When a token's effective version-control setting is enabled, a changed commit +creates a full notebook snapshot. Canonical no-op saves create no version and do +not consume a rate-limit unit. + +Version rate limits are: + +- 60 effective versions per notebook in a rolling hour. +- 500 effective versions per account per UTC day. +- There is no total historical version-count limit. +- API keys cannot access owner history. Owner history is available only in the + Web Console; SDK `1.2.0` does not provide history list, download, restore, or + delete methods. +- In the Web Console, restore creates a new version from the selected snapshot + while preserving current visibility and credentials. +- You may delete individual versions in the Web Console, but at least one live + version must remain for a versioned notebook. +- Disabling version control preserves existing history for viewing and download, + but restore remains unavailable until version control is re-enabled. ## Quota Checking @@ -251,6 +498,58 @@ except OfSpectrumError as e: print(f"API error: {e.code} - {e.message}") ``` +For notebook saves, branch on `e.code`; do not parse `e.message`. Stable notebook +codes map to typed SDK exceptions: + +```python +from uuid import uuid4 + +from ofspectrum import ( + ConflictError, + PaymentRequiredError, + RateLimitError, + ServiceUnavailableError, + ValidationError, +) + +try: + committed = client.notebook_commits.commit( + note_id=current.id, + desired_state=desired_state, + expected_revision=current.revision, + idempotency_key=str(uuid4()), + save_session_id=session.save_session_id, + save_batch_id=uuid4(), + ) +except ConflictError as e: + if e.code == "NotebookRevisionConflict": + print("Reload the notebook before saving again") + else: + print(f"Save conflict: {e.code}") +except PaymentRequiredError as e: + print(f"Storage action required: {e.code}") +except ValidationError as e: + print(f"Invalid notebook save: {e.code}") +except RateLimitError as e: + print(f"Save rate limited: {e.code}") +except ServiceUnavailableError as e: + print(f"Save temporarily unavailable: {e.code}") +``` + +Key mappings include: + +| Exception | Example stable codes | +|-----------|----------------------| +| `ConflictError` | `NotebookRevisionConflict`, `NotebookCommitIdempotencyConflict`, `NotebookStagedReferenceConflict`, `NotebookCommitConflict`, `NotebookSaveSessionConflict`, `NotebookSaveSessionRequired` | +| `PaymentRequiredError` | `NotebookStorageAutoExpandDisabled`, `NotebookStoragePaymentRequired`, `StorageChargeAuthorizationRequired` | +| `ValidationError` | `NotebookMediaTooLarge`, `NotebookMediaFileLimitExceeded`, `NotebookTextTooLarge`, `UnsupportedNotebookMedia`, `NotebookMediaHashMismatch`, `NotebookCommitValidationError`, `NotebookCommitPayloadTooLarge` | +| `RateLimitError` | `NotebookVersionRateLimitExceeded` | +| `ServiceUnavailableError` | `NotebookStorageUnavailable`, `NotebookCommitUnavailable` | + +Local staged-commit argument validation raises `ValidationError` with +`code="InvalidNotebookCommitRequest"`. OfSpectrum still validates media, +capacity, revision, and rate limits when it processes the request. + ## Context Manager ```python diff --git a/ofspectrum/__init__.py b/ofspectrum/__init__.py index b0c3e8d..425e5e3 100644 --- a/ofspectrum/__init__.py +++ b/ofspectrum/__init__.py @@ -9,7 +9,7 @@ client = OfSpectrum(api_key="your_api_key") # Create a token - token = client.tokens.create(name="My Token", token_type="pro") + token = client.tokens.create(name="My Token") # Encode watermark result = client.audio.encode( @@ -29,35 +29,49 @@ print(f"Remaining: {quota.remaining}/{quota.limit}") """ -__version__ = "1.1.6" +__version__ = "1.2.0" __author__ = "OfSpectrum" -from .client import OfSpectrum, AsyncOfSpectrum +from .client import AsyncOfSpectrum, OfSpectrum from .exceptions import ( - OfSpectrumError, AuthenticationError, - RateLimitError, + ConflictError, + NetworkError, + OfSpectrumError, + PaymentRequiredError, QuotaExceededError, + RateLimitError, ResourceNotFoundError, + ServiceUnavailableError, + TimeoutError, ValidationError, WatermarkExistsError, - TimeoutError, - ServiceUnavailableError, - NetworkError, ) from .models import ( - Token, AiAuthTag, - TokenCreateParams, - TokenUpdateParams, + DecodeResult, + EncodeResult, Notebook, - NotebookMedia, + NotebookCommitMedia, + NotebookCommitResponse, NotebookCreateParams, - EncodeResult, - DecodeResult, - StreamingEncodeResult, + NotebookDesiredMedia, + NotebookDesiredState, + NotebookEffectiveSettings, + NotebookMedia, + NotebookSaveSession, + NotebookSaveSessionCancellation, + NotebookSaveSessionStatus, + NotebookSettingOverrides, + NotebookSettingsResponse, + NotebookStagedUpload, + NotebookStorageAdmission, Quota, QuotaList, + StreamingEncodeResult, + Token, + TokenCreateParams, + TokenUpdateParams, ) from .utils import RetryConfig, with_retry @@ -76,6 +90,8 @@ "TimeoutError", "ServiceUnavailableError", "NetworkError", + "ConflictError", + "PaymentRequiredError", # Models "Token", "AiAuthTag", @@ -89,6 +105,18 @@ "StreamingEncodeResult", "Quota", "QuotaList", + "NotebookSettingOverrides", + "NotebookEffectiveSettings", + "NotebookSettingsResponse", + "NotebookDesiredMedia", + "NotebookDesiredState", + "NotebookStorageAdmission", + "NotebookSaveSession", + "NotebookStagedUpload", + "NotebookSaveSessionStatus", + "NotebookSaveSessionCancellation", + "NotebookCommitMedia", + "NotebookCommitResponse", # Utils "RetryConfig", "with_retry", diff --git a/ofspectrum/client.py b/ofspectrum/client.py index ece5117..4836e0c 100644 --- a/ofspectrum/client.py +++ b/ofspectrum/client.py @@ -4,22 +4,36 @@ Main entry point for the SDK. """ -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional + import httpx +from .exceptions import NetworkError, raise_for_error from .resources import ( - TokensResource, - NotebooksResource, AudioResource, + NotebookCommitsResource, + NotebooksResource, QuotasResource, # WebhooksResource, # Not yet available + TokensResource, ) -from .exceptions import ( - OfSpectrumError, - AuthenticationError, - NetworkError, - raise_for_error, -) + + +def _raise_response_error(response: httpx.Response) -> None: + """Map every unsuccessful HTTP response to a sanitized SDK exception.""" + + if response.status_code < 400: + return + try: + payload = response.json() + except (TypeError, ValueError): + payload = None + raise_for_error(payload, response.status_code) + + +def _checked_response(response: httpx.Response) -> httpx.Response: + _raise_response_error(response) + return response class OfSpectrum: @@ -80,6 +94,7 @@ def __init__( # Initialize resources self.tokens = TokensResource(self) self.notebooks = NotebooksResource(self) + self.notebook_commits = NotebookCommitsResource(self) self.audio = AudioResource(self) self.quotas = QuotasResource(self) # self.webhooks = WebhooksResource(self) # Not yet available @@ -88,7 +103,7 @@ def _default_headers(self) -> Dict[str, str]: """Get default request headers""" return { "Authorization": f"Bearer {self._api_key}", - "User-Agent": "OfSpectrum-Python-SDK/1.0.0", + "User-Agent": "OfSpectrum-Python-SDK/1.2.0", "Accept": "application/json", } @@ -100,6 +115,7 @@ def _request( json: Optional[Dict[str, Any]] = None, data: Optional[Dict[str, Any]] = None, files: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, timeout: Optional[float] = None, ) -> httpx.Response: """ @@ -112,6 +128,7 @@ def _request( json: JSON body data: Form data files: Files to upload + headers: Request-specific headers timeout: Optional request timeout Returns: @@ -141,20 +158,16 @@ def _request( if files: request_kwargs["files"] = files + if headers: + request_kwargs["headers"] = headers + if timeout: request_kwargs["timeout"] = timeout try: response = self._client.request(**request_kwargs) - # Check for authentication errors - if response.status_code == 401: - raise AuthenticationError( - message="Invalid or expired API key", - status_code=401, - ) - - return response + return _checked_response(response) except httpx.TimeoutException as e: raise NetworkError(f"Request timed out: {e}") @@ -213,6 +226,7 @@ def __init__( # Resources will be initialized when client is opened self.tokens: Optional[TokensResource] = None self.notebooks: Optional[NotebooksResource] = None + self.notebook_commits: Optional[NotebookCommitsResource] = None self.audio: Optional[AudioResource] = None self.quotas: Optional[QuotasResource] = None # self.webhooks: Optional[WebhooksResource] = None # Not yet available @@ -220,7 +234,7 @@ def __init__( def _default_headers(self) -> Dict[str, str]: return { "Authorization": f"Bearer {self._api_key}", - "User-Agent": "OfSpectrum-Python-SDK/1.0.0", + "User-Agent": "OfSpectrum-Python-SDK/1.2.0", "Accept": "application/json", } @@ -235,6 +249,7 @@ async def __aenter__(self): # Note: For true async, resources would need async versions self.tokens = TokensResource(self) self.notebooks = NotebooksResource(self) + self.notebook_commits = NotebookCommitsResource(self) self.audio = AudioResource(self) self.quotas = QuotasResource(self) # self.webhooks = WebhooksResource(self) # Not yet available @@ -269,12 +284,7 @@ async def _async_request(): try: response = await self._client.request(**kwargs) - if response.status_code == 401: - raise AuthenticationError( - message="Invalid or expired API key", - status_code=401, - ) - return response + return _checked_response(response) except httpx.TimeoutException as e: raise NetworkError(f"Request timed out: {e}") except httpx.RequestError as e: @@ -298,7 +308,7 @@ async def _async_request(): url = path if path.startswith("/") else f"/{path}" kwargs["url"] = url kwargs["method"] = method - return sync_client.request(**kwargs) + return _checked_response(sync_client.request(**kwargs)) else: return loop.run_until_complete(_async_request()) except RuntimeError: @@ -311,4 +321,4 @@ async def _async_request(): url = path if path.startswith("/") else f"/{path}" kwargs["url"] = url kwargs["method"] = method - return sync_client.request(**kwargs) + return _checked_response(sync_client.request(**kwargs)) diff --git a/ofspectrum/exceptions.py b/ofspectrum/exceptions.py index 75e025d..8f61222 100644 --- a/ofspectrum/exceptions.py +++ b/ofspectrum/exceptions.py @@ -4,7 +4,7 @@ All exceptions inherit from OfSpectrumError for easy catching. """ -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional, Type class OfSpectrumError(Exception): @@ -75,6 +75,20 @@ def __init__( self.reset_at = reset_at +class PaymentRequiredError(QuotaExceededError): + """Raised when an operation requires an authorized payment path.""" + + def __init__(self, message: str = "Payment required", **kwargs): + super().__init__(message, **kwargs) + + +class ConflictError(OfSpectrumError): + """Raised when revision, session, or idempotency state conflicts.""" + + def __init__(self, message: str = "Request conflicts with current state", **kwargs): + super().__init__(message, **kwargs) + + class ResourceNotFoundError(OfSpectrumError): """Raised when a requested resource is not found""" @@ -164,8 +178,129 @@ def __init__(self, message: str = "Network error", **kwargs): "SYS_5002": OfSpectrumError, "SYS_5003": OfSpectrumError, "SYS_5004": ServiceUnavailableError, + "DuplicateName": ValidationError, + "NotFound": ResourceNotFoundError, + "ValidationError": ValidationError, +} + +NOTEBOOK_ERROR_CODE_MAP = { + "AuthenticationRequired": AuthenticationError, + "InvalidApiKey": AuthenticationError, + "InvalidApiKeyActor": AuthenticationError, + "NotebookNotFound": ResourceNotFoundError, + "NotebookStoragePaymentRequired": PaymentRequiredError, + "NotebookStorageAutoExpandDisabled": PaymentRequiredError, + "NotebookStorageCapacityExceeded": PaymentRequiredError, + "StorageChargeAuthorizationRequired": PaymentRequiredError, + "NotebookStorageChargeAuthorizationRequired": PaymentRequiredError, + "NotebookRevisionConflict": ConflictError, + "NotebookCommitIdempotencyConflict": ConflictError, + "NotebookStorageIdempotencyConflict": ConflictError, + "NotebookIdempotencyConflict": ConflictError, + "NotebookStagedReferenceConflict": ConflictError, + "NotebookCommitConflict": ConflictError, + "NotebookSaveSessionConflict": ConflictError, + "NotebookSaveSessionRequired": ConflictError, + "NotebookMediaTooLarge": ValidationError, + "NotebookMediaFileLimitExceeded": ValidationError, + "NotebookMediaFileLimit": ValidationError, + "NotebookTextTooLarge": ValidationError, + "UnsupportedNotebookMedia": ValidationError, + "NotebookMediaHashMismatch": ValidationError, + "NotebookCommitValidationError": ValidationError, + "NotebookCommitPayloadTooLarge": ValidationError, + "NotebookStorageAdmissionRejected": ValidationError, + "InvalidNotebookStorageRequest": ValidationError, + "InvalidNotebookIdempotencyKey": ValidationError, + "InvalidNotebookCommitRequest": ValidationError, + "NotebookStorageAccountNotEligible": ValidationError, + "NotebookVersionRateLimitExceeded": RateLimitError, + "NotebookStorageUnavailable": ServiceUnavailableError, + "NotebookCommitUnavailable": ServiceUnavailableError, + "NotebookStorageOperationFailed": ServiceUnavailableError, + "NotebookObjectUploadFailed": ServiceUnavailableError, + "NotebookObjectUploadUncertain": ServiceUnavailableError, } +ERROR_CODE_MAP.update(NOTEBOOK_ERROR_CODE_MAP) + + +def _exception_details(details: Optional[Dict[str, Any]]) -> Dict[str, Any]: + if not isinstance(details, dict): + return {} + normalized = dict(details) + nested = details.get("details") + if isinstance(nested, dict): + normalized.update(nested) + return normalized + + +def _raise_mapped_error( + exc_class: Type[OfSpectrumError], + *, + error_code: Optional[str], + message: str, + status_code: int, + details: Optional[Dict[str, Any]] = None, +): + normalized_details = _exception_details(details) + kwargs = { + "message": message, + "code": error_code, + "status_code": status_code, + "details": normalized_details, + } + + if issubclass(exc_class, RateLimitError): + kwargs["retry_after"] = normalized_details.get("retry_after") + elif issubclass(exc_class, ServiceUnavailableError): + kwargs["retry_after"] = normalized_details.get("retry_after") + elif issubclass(exc_class, QuotaExceededError): + kwargs["service"] = normalized_details.get("service") + kwargs["remaining"] = normalized_details.get("remaining", 0) + kwargs["reset_at"] = normalized_details.get("reset_at") + elif issubclass(exc_class, ResourceNotFoundError): + kwargs["resource_type"] = normalized_details.get("resource_type") + kwargs["resource_id"] = normalized_details.get("resource_id") + elif issubclass(exc_class, ValidationError): + kwargs["field"] = normalized_details.get("field") + + raise exc_class(**kwargs) + + +def _exception_class_for_status(status_code: int) -> Type[OfSpectrumError]: + if status_code == 401: + return AuthenticationError + if status_code == 402: + return PaymentRequiredError + if status_code == 404: + return ResourceNotFoundError + if status_code == 409: + return ConflictError + if status_code in (400, 413, 415, 422): + return ValidationError + if status_code == 429: + return RateLimitError + if status_code in (502, 503, 504): + return ServiceUnavailableError + return OfSpectrumError + + +def _raise_status_error( + status_code: int, + *, + message: str = "API request failed", + error_code: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, +): + _raise_mapped_error( + _exception_class_for_status(status_code), + error_code=error_code, + message=message, + status_code=status_code, + details=details, + ) + def _raise_direct_error( error_code: str, @@ -176,6 +311,16 @@ def _raise_direct_error( """Raise SDK exceptions for legacy/direct API error payloads.""" details = details or {} + notebook_exception = NOTEBOOK_ERROR_CODE_MAP.get(error_code) + if notebook_exception is not None: + _raise_mapped_error( + notebook_exception, + error_code=error_code, + message=message, + status_code=status_code, + details=details, + ) + # Map common legacy error codes used by tokens_router and other endpoints. if error_code == "QuotaExceeded": raise QuotaExceededError( @@ -200,14 +345,19 @@ def _raise_direct_error( ) elif error_code == "Unauthorized": raise AuthenticationError(message=message, code=error_code, status_code=status_code or 403, details=details) - elif error_code in ("DuplicateName", "ValidationError"): + elif error_code in ("DuplicateName", "NotebookMediaFileLimit", "ValidationError"): raise ValidationError(message=message, code=error_code, status_code=status_code or 400, details=details) elif error_code == "Missing required fields" or error_code == "InvalidField": raise ValidationError(message=message, code=error_code, status_code=status_code or 400, details=details) elif error_code == "UnableToGenerate": raise OfSpectrumError(message=message, code=error_code, status_code=status_code or 500, details=details) else: - raise OfSpectrumError(message=message, code=error_code, status_code=status_code or 500, details=details) + _raise_status_error( + status_code or 500, + message=message, + error_code=error_code, + details=details, + ) def _raise_detail_error(message: str, status_code: int): @@ -222,14 +372,15 @@ def _raise_detail_error(message: str, status_code: int): if message == "Private notebook limit reached for this token": raise ValidationError( message=( - "Private notebook limit reached for this token. Standard tokens support one " - "private notebook; Pro tokens have no private notebook limit." + "Private notebook limit reached for this token. Standard tokens do not allow new " + "private notebooks; existing private notebooks are grandfathered. Pro tokens " + "support five private notebooks, and Enterprise tokens support ten." ), code="NotebookLimit", status_code=status_code or 400, details={}, ) - raise OfSpectrumError(message=message, status_code=status_code) + _raise_status_error(status_code, message=message) def raise_for_error(response_data, status_code: int): @@ -243,8 +394,10 @@ def raise_for_error(response_data, status_code: int): Raises: OfSpectrumError: Appropriate exception based on error code """ - # If response is a list (e.g., tokens.list returns a list), it's not an error + # Successful list responses (for example tokens.list) are not errors. if not isinstance(response_data, dict): + if status_code >= 400: + _raise_status_error(status_code) return # Check for direct error format: {"error": "ErrorCode", "message": "..."} @@ -254,6 +407,25 @@ def raise_for_error(response_data, status_code: int): message = response_data.get("message", error_code) _raise_direct_error(error_code, message, status_code, response_data) + direct_error = response_data.get("error") + if ( + status_code >= 400 + and response_data.get("status") != "error" + and isinstance(direct_error, dict) + ): + error_code = direct_error.get("code") or direct_error.get("error") + message = direct_error.get("message") or response_data.get("message") + if isinstance(error_code, str): + _raise_mapped_error( + ERROR_CODE_MAP.get( + error_code, _exception_class_for_status(status_code) + ), + error_code=error_code, + message=str(message or error_code), + status_code=status_code, + details=direct_error, + ) + if response_data.get("status") != "error": # Also check for FastAPI validation errors (detail field) if "detail" in response_data and status_code >= 400: @@ -265,11 +437,21 @@ def raise_for_error(response_data, status_code: int): message = detail.get("message") or detail.get("detail") or error_code or "API request failed" if isinstance(error_code, str): _raise_direct_error(error_code, message, status_code, detail) - raise OfSpectrumError(message=str(message), status_code=status_code, details=detail) + _raise_status_error( + status_code, + message=str(message), + details=detail, + ) elif isinstance(detail, list): # FastAPI validation error format messages = [f"{d.get('loc', ['?'])[-1]}: {d.get('msg', '?')}" for d in detail] raise ValidationError(message="; ".join(messages), status_code=status_code) + if status_code >= 400: + message = response_data.get("message") + _raise_status_error( + status_code, + message=message if isinstance(message, str) else "API request failed", + ) return error = response_data.get("error", {}) @@ -278,26 +460,11 @@ def raise_for_error(response_data, status_code: int): details = error.get("details", {}) # Get appropriate exception class - exc_class = ERROR_CODE_MAP.get(code, OfSpectrumError) - - # Build kwargs based on exception type - kwargs = { - "message": message, - "code": code, - "status_code": status_code, - "details": details, - } - - if exc_class == RateLimitError: - kwargs["retry_after"] = details.get("retry_after") - elif exc_class == QuotaExceededError: - kwargs["service"] = details.get("service") - kwargs["remaining"] = details.get("remaining", 0) - kwargs["reset_at"] = details.get("reset_at") - elif exc_class == ResourceNotFoundError: - kwargs["resource_type"] = details.get("resource_type") - kwargs["resource_id"] = details.get("resource_id") - elif exc_class == ValidationError: - kwargs["field"] = details.get("field") - - raise exc_class(**kwargs) + exc_class = ERROR_CODE_MAP.get(code, _exception_class_for_status(status_code)) + _raise_mapped_error( + exc_class, + error_code=code, + message=message, + status_code=status_code, + details=details, + ) diff --git a/ofspectrum/models/__init__.py b/ofspectrum/models/__init__.py index aebebf0..f0ea715 100644 --- a/ofspectrum/models/__init__.py +++ b/ofspectrum/models/__init__.py @@ -2,10 +2,24 @@ OfSpectrum SDK Data Models """ -from .token import AiAuthTag, Token, TokenCreateParams, TokenUpdateParams -from .notebook import Notebook, NotebookMedia, NotebookCreateParams -from .audio import EncodeResult, DecodeResult, StreamingEncodeResult +from .audio import DecodeResult, EncodeResult, StreamingEncodeResult +from .notebook import Notebook, NotebookCreateParams, NotebookMedia +from .notebook_version import ( + NotebookCommitMedia, + NotebookCommitResponse, + NotebookDesiredMedia, + NotebookDesiredState, + NotebookEffectiveSettings, + NotebookSaveSession, + NotebookSaveSessionCancellation, + NotebookSaveSessionStatus, + NotebookSettingOverrides, + NotebookSettingsResponse, + NotebookStagedUpload, + NotebookStorageAdmission, +) from .quota import Quota, QuotaList +from .token import AiAuthTag, Token, TokenCreateParams, TokenUpdateParams __all__ = [ "Token", @@ -20,4 +34,16 @@ "StreamingEncodeResult", "Quota", "QuotaList", + "NotebookSettingOverrides", + "NotebookEffectiveSettings", + "NotebookSettingsResponse", + "NotebookDesiredMedia", + "NotebookDesiredState", + "NotebookStorageAdmission", + "NotebookSaveSession", + "NotebookStagedUpload", + "NotebookSaveSessionStatus", + "NotebookSaveSessionCancellation", + "NotebookCommitMedia", + "NotebookCommitResponse", ] diff --git a/ofspectrum/models/notebook.py b/ofspectrum/models/notebook.py index 92a49d4..06a84e7 100644 --- a/ofspectrum/models/notebook.py +++ b/ofspectrum/models/notebook.py @@ -2,8 +2,14 @@ Notebook models for watermark token notes """ -from dataclasses import dataclass, field -from typing import Optional, List +from dataclasses import dataclass +from typing import List, Optional + + +def _preferred_value(data: dict, current_key: str, legacy_key: str): + """Prefer an explicitly present current field, including empty values.""" + + return data.get(current_key) if current_key in data else data.get(legacy_key) @dataclass @@ -16,6 +22,20 @@ class NotebookMedia: file_size: Optional[int] = None content_type: Optional[str] = None created_at: Optional[str] = None + display_order: Optional[int] = None + updated_at: Optional[str] = None + + @property + def file_size_bytes(self) -> Optional[int]: + """Current API name for the media size, preserving ``file_size`` compatibility.""" + + return self.file_size + + @property + def media_type(self) -> Optional[str]: + """Current API name for the detected type, preserving ``content_type`` compatibility.""" + + return self.content_type @classmethod def from_dict(cls, data: dict) -> "NotebookMedia": @@ -24,9 +44,11 @@ def from_dict(cls, data: dict) -> "NotebookMedia": id=data["id"], filename=data.get("filename", ""), file_url=data.get("file_url") or data.get("media_public"), - file_size=data.get("file_size"), - content_type=data.get("content_type"), + file_size=_preferred_value(data, "file_size_bytes", "file_size"), + content_type=_preferred_value(data, "media_type", "content_type"), created_at=data.get("created_at"), + display_order=data.get("display_order"), + updated_at=data.get("updated_at"), ) @@ -40,9 +62,10 @@ class Notebook: text_content: Optional[str] = None # Backend uses text_content instead of content is_public: bool = False credential_val: Optional[str] = None # Credential for private notes - media: List[NotebookMedia] = field(default_factory=list) + media: Optional[List[NotebookMedia]] = None created_at: Optional[str] = None updated_at: Optional[str] = None + revision: Optional[int] = None # Alias properties for backward compatibility @property @@ -56,17 +79,30 @@ def content(self) -> Optional[str]: @classmethod def from_dict(cls, data: dict) -> "Notebook": """Create Notebook from API response dict""" - media_list = data.get("media") or [] + media_data = data.get("media") + media = ( + [NotebookMedia.from_dict(item) for item in media_data] + if isinstance(media_data, list) + else None + ) + if media is not None: + media.sort( + key=lambda item: ( + item.display_order is None, + item.display_order if item.display_order is not None else 0, + ) + ) return cls( id=data["id"], token_id=data.get("token_id", ""), - note_name=data.get("note_name", "") or data.get("title", ""), - text_content=data.get("text_content") or data.get("content"), + note_name=_preferred_value(data, "note_name", "title") or "", + text_content=_preferred_value(data, "text_content", "content"), is_public=data.get("is_public", False), credential_val=data.get("credential_val"), - media=[NotebookMedia.from_dict(m) for m in media_list], + media=media, created_at=data.get("created_at"), updated_at=data.get("updated_at"), + revision=data.get("revision"), ) diff --git a/ofspectrum/models/notebook_version.py b/ofspectrum/models/notebook_version.py new file mode 100644 index 0000000..1aa1e7b --- /dev/null +++ b/ofspectrum/models/notebook_version.py @@ -0,0 +1,496 @@ +"""Typed models for notebook settings and revision-safe saves.""" + +from dataclasses import dataclass, field +from typing import Any, Dict, Iterator, Mapping, Optional, Sequence + + +def _optional_bool(value: Any) -> Optional[bool]: + return value if isinstance(value, bool) else None + + +def _effective_bool(value: Any) -> bool: + return value if isinstance(value, bool) else False + + +def _non_negative_int(value: Any, default: int = 0) -> int: + if isinstance(value, bool): + return default + try: + normalized = int(value) + except (TypeError, ValueError): + return default + return normalized if normalized >= 0 else default + + +def _optional_non_negative_int(value: Any) -> Optional[int]: + if value is None or isinstance(value, bool): + return None + try: + normalized = int(value) + except (TypeError, ValueError): + return None + return normalized if normalized >= 0 else None + + +def _optional_text(value: Any) -> Optional[str]: + return value if isinstance(value, str) else None + + +def _required_text(data: Mapping[str, Any], key: str) -> str: + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{key} is missing from the API response") + return value + + +def _required_non_negative_int(data: Mapping[str, Any], key: str) -> int: + value = data.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"{key} is invalid in the API response") + return value + + +def _required_bool(data: Mapping[str, Any], key: str) -> bool: + value = data.get(key) + if not isinstance(value, bool): + raise ValueError(f"{key} is invalid in the API response") + return value + + +@dataclass(frozen=True) +class NotebookSettingOverrides: + """Nullable token overrides; ``None`` means inherit the account default.""" + + version_control_override: Optional[bool] = None + storage_auto_expand_override: Optional[bool] = None + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "NotebookSettingOverrides": + return cls( + version_control_override=_optional_bool(data.get("version_control_override")), + storage_auto_expand_override=_optional_bool( + data.get("storage_auto_expand_override") + ), + ) + + def to_dict(self) -> Dict[str, Optional[bool]]: + return { + "version_control_override": self.version_control_override, + "storage_auto_expand_override": self.storage_auto_expand_override, + } + + +@dataclass(frozen=True) +class NotebookEffectiveSettings: + """Server-calculated notebook capabilities and included storage.""" + + version_control_enabled: bool = False + storage_auto_expand_enabled: bool = False + storage_entitlement_bytes: int = 0 + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "NotebookEffectiveSettings": + return cls( + version_control_enabled=_effective_bool(data.get("version_control_enabled")), + storage_auto_expand_enabled=_effective_bool( + data.get("storage_auto_expand_enabled") + ), + storage_entitlement_bytes=_non_negative_int( + data.get("storage_entitlement_bytes") + ), + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "version_control_enabled": self.version_control_enabled, + "storage_auto_expand_enabled": self.storage_auto_expand_enabled, + "storage_entitlement_bytes": self.storage_entitlement_bytes, + } + + +@dataclass(frozen=True) +class NotebookSettingsResponse: + """Configured overrides and server-calculated effective settings.""" + + version_control_override: Optional[bool] = None + storage_auto_expand_override: Optional[bool] = None + version_control_enabled: bool = False + storage_auto_expand_enabled: bool = False + storage_entitlement_bytes: int = 0 + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "NotebookSettingsResponse": + overrides = NotebookSettingOverrides.from_dict(data) + effective = NotebookEffectiveSettings.from_dict(data) + return cls( + version_control_override=overrides.version_control_override, + storage_auto_expand_override=overrides.storage_auto_expand_override, + version_control_enabled=effective.version_control_enabled, + storage_auto_expand_enabled=effective.storage_auto_expand_enabled, + storage_entitlement_bytes=effective.storage_entitlement_bytes, + ) + + @property + def overrides(self) -> NotebookSettingOverrides: + return NotebookSettingOverrides( + version_control_override=self.version_control_override, + storage_auto_expand_override=self.storage_auto_expand_override, + ) + + @property + def effective(self) -> NotebookEffectiveSettings: + return NotebookEffectiveSettings( + version_control_enabled=self.version_control_enabled, + storage_auto_expand_enabled=self.storage_auto_expand_enabled, + storage_entitlement_bytes=self.storage_entitlement_bytes, + ) + + def to_dict(self) -> Dict[str, Any]: + return {**self.overrides.to_dict(), **self.effective.to_dict()} + + +@dataclass(frozen=True) +class NotebookDesiredMedia: + """One current-media or staged-upload reference in desired order.""" + + display_order: int + media_id: Optional[str] = None + upload_id: Optional[str] = None + filename: Optional[str] = None + + def __post_init__(self) -> None: + if (self.media_id is None) == (self.upload_id is None): + raise ValueError("exactly one of media_id or upload_id is required") + selected_id = self.media_id if self.media_id is not None else self.upload_id + if not isinstance(selected_id, str) or not selected_id.strip(): + raise ValueError("media_id and upload_id cannot be empty") + if ( + isinstance(self.display_order, bool) + or not isinstance(self.display_order, int) + or self.display_order < 0 + ): + raise ValueError("display_order must be a non-negative integer") + if self.upload_id is not None and self.filename is None: + raise ValueError("filename is required for staged media") + if self.filename is not None: + if not isinstance(self.filename, str) or not self.filename.isprintable(): + raise ValueError("filename must be a printable string") + filename = self.filename.strip() + if not filename or len(filename) > 1024: + raise ValueError("filename must contain between 1 and 1024 characters") + object.__setattr__(self, "filename", filename) + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "NotebookDesiredMedia": + return cls( + display_order=_required_non_negative_int(data, "display_order"), + media_id=_optional_text(data.get("media_id")), + upload_id=_optional_text(data.get("upload_id")), + filename=_optional_text(data.get("filename")), + ) + + def to_dict(self) -> Dict[str, Any]: + data: Dict[str, Any] = {"display_order": self.display_order} + if self.media_id is not None: + data["media_id"] = self.media_id + if self.upload_id is not None: + data["upload_id"] = self.upload_id + if self.filename is not None: + data["filename"] = self.filename + return data + + +@dataclass(frozen=True) +class NotebookDesiredState(Mapping[str, Any]): + """Complete desired state accepted by the notebook commit endpoint.""" + + note_name: Optional[str] + text_content: Optional[str] + is_public: bool + credential_val: Optional[str] = None + media: Sequence[NotebookDesiredMedia] = field(default_factory=tuple) + + def __post_init__(self) -> None: + if not isinstance(self.is_public, bool): + raise ValueError("is_public must be a boolean") + if any(not isinstance(item, NotebookDesiredMedia) for item in self.media): + raise ValueError("media must contain NotebookDesiredMedia values") + orders = sorted(item.display_order for item in self.media) + if orders != list(range(len(orders))): + raise ValueError("media display_order values must be contiguous from zero") + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "NotebookDesiredState": + media = data.get("media") or [] + return cls( + note_name=_optional_text(data.get("note_name")), + text_content=_optional_text(data.get("text_content")), + is_public=data.get("is_public"), + credential_val=_optional_text(data.get("credential_val")), + media=tuple( + NotebookDesiredMedia.from_dict(item) + for item in media + if isinstance(item, Mapping) + ), + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "note_name": self.note_name, + "text_content": self.text_content, + "is_public": self.is_public, + "credential_val": self.credential_val, + "media": [item.to_dict() for item in self.media], + } + + def __getitem__(self, key: str) -> Any: + return self.to_dict()[key] + + def __iter__(self) -> Iterator[str]: + return iter(("note_name", "text_content", "is_public", "credential_val", "media")) + + def __len__(self) -> int: + return 5 + + +@dataclass(frozen=True) +class NotebookStorageAdmission: + """Capacity projection returned by staging or commit operations.""" + + used_media_bytes: Optional[int] = None + reserved_media_bytes: Optional[int] = None + projected_unique_bytes: Optional[int] = None + included_entitlement_bytes: Optional[int] = None + legacy_credit_bytes: Optional[int] = None + allocated_paid_blocks: Optional[int] = None + required_paid_blocks: Optional[int] = None + projected_new_paid_blocks: Optional[int] = None + storage_auto_expand_enabled: Optional[bool] = None + payment_eligible: Optional[bool] = None + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "NotebookStorageAdmission": + return cls( + used_media_bytes=_optional_non_negative_int(data.get("used_media_bytes")), + reserved_media_bytes=_optional_non_negative_int( + data.get("reserved_media_bytes") + ), + projected_unique_bytes=_optional_non_negative_int( + data.get("projected_unique_bytes") + ), + included_entitlement_bytes=_optional_non_negative_int( + data.get("included_entitlement_bytes") + ), + legacy_credit_bytes=_optional_non_negative_int(data.get("legacy_credit_bytes")), + allocated_paid_blocks=_optional_non_negative_int( + data.get("allocated_paid_blocks") + ), + required_paid_blocks=_optional_non_negative_int( + data.get("required_paid_blocks") + ), + projected_new_paid_blocks=_optional_non_negative_int( + data.get("projected_new_paid_blocks") + ), + storage_auto_expand_enabled=_optional_bool( + data.get("storage_auto_expand_enabled") + ), + payment_eligible=_optional_bool(data.get("payment_eligible")), + ) + + +@dataclass(frozen=True) +class NotebookSaveSession: + """An active or replayed notebook save session.""" + + save_session_id: str + notebook_id: str + state: str + expires_at: str + created: Optional[bool] = None + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "NotebookSaveSession": + return cls( + save_session_id=_required_text(data, "save_session_id"), + notebook_id=_required_text(data, "notebook_id"), + state=_required_text(data, "state"), + expires_at=_required_text(data, "expires_at"), + created=_optional_bool(data.get("created")), + ) + + +@dataclass(frozen=True) +class NotebookStagedUpload: + """Status returned for one upload in a save session.""" + + upload_id: str + state: str + expires_at: str + save_session_id: Optional[str] = None + notebook_id: Optional[str] = None + filename: Optional[str] = None + media_type: Optional[str] = None + file_size_bytes: Optional[int] = None + reused_existing_blob: Optional[bool] = None + reserved_bytes: Optional[int] = None + admission: Optional[NotebookStorageAdmission] = None + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "NotebookStagedUpload": + admission = data.get("admission") + return cls( + upload_id=_required_text(data, "upload_id"), + state=_required_text(data, "state"), + expires_at=_required_text(data, "expires_at"), + save_session_id=_optional_text(data.get("save_session_id")), + notebook_id=_optional_text(data.get("notebook_id")), + filename=_optional_text(data.get("filename")), + media_type=_optional_text(data.get("media_type")), + file_size_bytes=_optional_non_negative_int(data.get("file_size_bytes")), + reused_existing_blob=_optional_bool(data.get("reused_existing_blob")), + reserved_bytes=_optional_non_negative_int(data.get("reserved_bytes")), + admission=( + NotebookStorageAdmission.from_dict(admission) + if isinstance(admission, Mapping) + else None + ), + ) + + +@dataclass(frozen=True) +class NotebookSaveSessionStatus: + """A save session and all uploads currently associated with it.""" + + save_session_id: str + notebook_id: str + state: str + expires_at: str + uploads: Sequence[NotebookStagedUpload] = field(default_factory=tuple) + created: Optional[bool] = None + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "NotebookSaveSessionStatus": + uploads = data.get("uploads") or [] + return cls( + save_session_id=_required_text(data, "save_session_id"), + notebook_id=_required_text(data, "notebook_id"), + state=_required_text(data, "state"), + expires_at=_required_text(data, "expires_at"), + uploads=tuple( + NotebookStagedUpload.from_dict(item) + for item in uploads + if isinstance(item, Mapping) + ), + created=_optional_bool(data.get("created")), + ) + + +@dataclass(frozen=True) +class NotebookSaveSessionCancellation: + """Result of cancelling a notebook save session.""" + + save_session_id: str + state: str + released_bytes: int + idempotent: bool + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "NotebookSaveSessionCancellation": + return cls( + save_session_id=_required_text(data, "save_session_id"), + state=_required_text(data, "state"), + released_bytes=_required_non_negative_int(data, "released_bytes"), + idempotent=_required_bool(data, "idempotent"), + ) + + +@dataclass(frozen=True) +class NotebookCommitMedia: + """One current media item returned by a successful commit.""" + + id: str + filename: str + media_type: str + file_size_bytes: int + display_order: int + + @property + def file_size(self) -> int: + """Backward-compatible alias for ``file_size_bytes``.""" + + return self.file_size_bytes + + @property + def content_type(self) -> str: + """Backward-compatible alias for ``media_type``.""" + + return self.media_type + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "NotebookCommitMedia": + return cls( + id=_required_text(data, "id"), + filename=_required_text(data, "filename"), + media_type=_required_text(data, "media_type"), + file_size_bytes=_required_non_negative_int(data, "file_size_bytes"), + display_order=_required_non_negative_int(data, "display_order"), + ) + + +@dataclass(frozen=True) +class NotebookCommitResponse: + """Result of an atomic desired-state commit.""" + + notebook_id: str + resulting_revision: int + expected_revision: Optional[int] = None + changed: bool = False + state: Optional[str] = None + save_batch_id: Optional[str] = None + version_id: Optional[str] = None + version_sequence: Optional[int] = None + replayed: bool = False + media: Sequence[NotebookCommitMedia] = field(default_factory=tuple) + storage: Optional[NotebookStorageAdmission] = None + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "NotebookCommitResponse": + media = data.get("media") or [] + storage = data.get("storage") + return cls( + notebook_id=_required_text(data, "notebook_id"), + resulting_revision=_required_non_negative_int(data, "resulting_revision"), + expected_revision=_optional_non_negative_int(data.get("expected_revision")), + changed=_effective_bool(data.get("changed")), + state=_optional_text(data.get("state")), + save_batch_id=_optional_text(data.get("save_batch_id")), + version_id=_optional_text(data.get("version_id")), + version_sequence=_optional_non_negative_int(data.get("version_sequence")), + replayed=_effective_bool(data.get("replayed")), + media=tuple( + NotebookCommitMedia.from_dict(item) + for item in media + if isinstance(item, Mapping) + ), + storage=( + NotebookStorageAdmission.from_dict(storage) + if isinstance(storage, Mapping) + else None + ), + ) + + +__all__ = [ + "NotebookSettingOverrides", + "NotebookEffectiveSettings", + "NotebookSettingsResponse", + "NotebookDesiredMedia", + "NotebookDesiredState", + "NotebookStorageAdmission", + "NotebookSaveSession", + "NotebookStagedUpload", + "NotebookSaveSessionStatus", + "NotebookSaveSessionCancellation", + "NotebookCommitMedia", + "NotebookCommitResponse", +] diff --git a/ofspectrum/models/quota.py b/ofspectrum/models/quota.py index b3c6baa..f51074c 100644 --- a/ofspectrum/models/quota.py +++ b/ofspectrum/models/quota.py @@ -3,7 +3,7 @@ """ from dataclasses import dataclass, field -from typing import Optional, List +from typing import List, Optional @dataclass diff --git a/ofspectrum/models/token.py b/ofspectrum/models/token.py index e0f4a48..067eca1 100644 --- a/ofspectrum/models/token.py +++ b/ofspectrum/models/token.py @@ -3,10 +3,50 @@ """ from dataclasses import dataclass, field -from typing import Any, List, Literal, Optional +from typing import Any, List, Literal, Optional, Union -UNSET = object() +class _UnsetType: + """Sentinel type used to distinguish omission from an explicit ``None``.""" + + __slots__ = () + + +UNSET = _UnsetType() +OptionalBoolArgument = Union[bool, None, _UnsetType] + + +def _validate_optional_bool_argument(value: Any, field_name: str) -> None: + if value is UNSET or value is None or isinstance(value, bool): + return + raise ValueError(f"{field_name} must be True, False, None, or omitted") + + +def _validate_public_key(value: Any, *, required: bool = False) -> None: + if value is None: + if required: + raise ValueError("public_key is required for pro tokens") + return + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError("public_key must be an integer") + + +def _optional_bool(value: Any) -> Optional[bool]: + return value if isinstance(value, bool) else None + + +def _effective_bool(value: Any) -> bool: + return value if isinstance(value, bool) else False + + +def _non_negative_int(value: Any) -> int: + if isinstance(value, bool): + return 0 + try: + normalized = int(value) + except (TypeError, ValueError): + return 0 + return normalized if normalized >= 0 else 0 @dataclass @@ -43,6 +83,11 @@ class Token: ai_auth_price: Optional[float] = None ai_auth_other_instructions: Optional[str] = None ai_auth_tags: List[str] = field(default_factory=list) + version_control_override: Optional[bool] = None + storage_auto_expand_override: Optional[bool] = None + version_control_enabled: bool = False + storage_auto_expand_enabled: bool = False + storage_entitlement_bytes: int = 0 created_at: Optional[str] = None updated_at: Optional[str] = None @@ -63,6 +108,21 @@ def from_dict(cls, data: dict) -> "Token": ai_auth_price=data.get("ai_auth_price"), ai_auth_other_instructions=data.get("ai_auth_other_instructions") or None, ai_auth_tags=list(data.get("ai_auth_tags") or []), + version_control_override=_optional_bool( + data.get("version_control_override") + ), + storage_auto_expand_override=_optional_bool( + data.get("storage_auto_expand_override") + ), + version_control_enabled=_effective_bool( + data.get("version_control_enabled") + ), + storage_auto_expand_enabled=_effective_bool( + data.get("storage_auto_expand_enabled") + ), + storage_entitlement_bytes=_non_negative_int( + data.get("storage_entitlement_bytes") + ), created_at=data.get("created_at"), updated_at=data.get("updated_at"), ) @@ -80,6 +140,17 @@ class TokenCreateParams: ai_auth_price: Optional[float] = None ai_auth_other_instructions: Optional[str] = None ai_auth_tags: Optional[List[str]] = None + version_control_override: OptionalBoolArgument = UNSET + storage_auto_expand_override: OptionalBoolArgument = UNSET + + def __post_init__(self) -> None: + _validate_public_key(self.public_key, required=self.token_type == "pro") + _validate_optional_bool_argument( + self.version_control_override, "version_control_override" + ) + _validate_optional_bool_argument( + self.storage_auto_expand_override, "storage_auto_expand_override" + ) def to_dict(self) -> dict: """Convert to API request dict""" @@ -99,6 +170,10 @@ def to_dict(self) -> dict: data["ai_auth_other_instructions"] = self.ai_auth_other_instructions if self.ai_auth_tags is not None: data["ai_auth_tags"] = self.ai_auth_tags + if self.version_control_override is not UNSET: + data["version_control_override"] = self.version_control_override + if self.storage_auto_expand_override is not UNSET: + data["storage_auto_expand_override"] = self.storage_auto_expand_override return data @@ -115,6 +190,17 @@ class TokenUpdateParams: ai_auth_price: Any = UNSET ai_auth_other_instructions: Optional[str] = None ai_auth_tags: Optional[List[str]] = None + version_control_override: OptionalBoolArgument = UNSET + storage_auto_expand_override: OptionalBoolArgument = UNSET + + def __post_init__(self) -> None: + _validate_public_key(self.public_key) + _validate_optional_bool_argument( + self.version_control_override, "version_control_override" + ) + _validate_optional_bool_argument( + self.storage_auto_expand_override, "storage_auto_expand_override" + ) def to_dict(self) -> dict: """Convert to API request dict (only non-None fields)""" @@ -137,4 +223,8 @@ def to_dict(self) -> dict: data["ai_auth_other_instructions"] = self.ai_auth_other_instructions if self.ai_auth_tags is not None: data["ai_auth_tags"] = self.ai_auth_tags + if self.version_control_override is not UNSET: + data["version_control_override"] = self.version_control_override + if self.storage_auto_expand_override is not UNSET: + data["storage_auto_expand_override"] = self.storage_auto_expand_override return data diff --git a/ofspectrum/resources/__init__.py b/ofspectrum/resources/__init__.py index 5f06244..0d06858 100644 --- a/ofspectrum/resources/__init__.py +++ b/ofspectrum/resources/__init__.py @@ -2,15 +2,18 @@ OfSpectrum SDK API Resources """ -from .tokens import TokensResource -from .notebooks import NotebooksResource from .audio import AudioResource +from .notebook_commits import NotebookCommitsResource +from .notebooks import NotebooksResource from .quotas import QuotasResource +from .tokens import TokensResource + # from .webhooks import WebhooksResource # Not yet available __all__ = [ "TokensResource", "NotebooksResource", + "NotebookCommitsResource", "AudioResource", "QuotasResource", # "WebhooksResource", # Not yet available diff --git a/ofspectrum/resources/audio.py b/ofspectrum/resources/audio.py index 7ab4ba7..464b99a 100644 --- a/ofspectrum/resources/audio.py +++ b/ofspectrum/resources/audio.py @@ -2,14 +2,14 @@ Audio resource for watermark encoding and decoding """ -from typing import Any, Iterable, Union, Optional, BinaryIO -from pathlib import Path import json +from pathlib import Path +from typing import Any, BinaryIO, Iterable, Optional, Union from urllib.parse import urlsplit, urlunsplit -import httpx -from .base import BaseResource + +from ..exceptions import OfSpectrumError, raise_for_error from ..models.audio import DecodeResult, EncodeResult, StreamingEncodeResult -from ..exceptions import raise_for_error, OfSpectrumError +from .base import BaseResource class AudioResource(BaseResource): diff --git a/ofspectrum/resources/base.py b/ofspectrum/resources/base.py index b1213c2..7cfafb2 100644 --- a/ofspectrum/resources/base.py +++ b/ofspectrum/resources/base.py @@ -2,7 +2,8 @@ Base resource class for API resources """ -from typing import TYPE_CHECKING, Optional, Dict, Any +from typing import TYPE_CHECKING, Any, Dict, Optional + import httpx if TYPE_CHECKING: diff --git a/ofspectrum/resources/notebook_commits.py b/ofspectrum/resources/notebook_commits.py new file mode 100644 index 0000000..fb5b5e3 --- /dev/null +++ b/ofspectrum/resources/notebook_commits.py @@ -0,0 +1,319 @@ +"""Transport helpers for revision-safe notebook save sessions.""" + +from __future__ import annotations + +import mimetypes +import re +from pathlib import Path +from typing import Any, BinaryIO, Callable, Dict, Mapping, Optional, TypeVar, Union +from urllib.parse import quote +from uuid import UUID + +from ..exceptions import OfSpectrumError, ValidationError, raise_for_error +from ..models.notebook_version import ( + NotebookCommitResponse, + NotebookDesiredState, + NotebookSaveSession, + NotebookSaveSessionCancellation, + NotebookSaveSessionStatus, + NotebookStagedUpload, +) +from .base import BaseResource + +FileInput = Union[str, Path, BinaryIO] +UUIDInput = Union[str, UUID] +ResultModel = TypeVar("ResultModel") + +_NOTEBOOKS_PATH = "/watermark-notes" +_MAX_IDEMPOTENCY_KEY_LENGTH = 200 +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +class NotebookCommitsResource(BaseResource): + """Stage media in one save session and atomically commit desired state.""" + + def begin(self, note_id: str, *, idempotency_key: str) -> NotebookSaveSession: + """Begin or replay an active notebook save session.""" + + response = self._idempotent_request( + "POST", + f"{_NOTEBOOKS_PATH}/{_path_id(note_id, 'note_id')}/save-sessions", + idempotency_key=idempotency_key, + ) + return _typed_result(response, NotebookSaveSession.from_dict) + + def stage( + self, + note_id: str, + save_session_id: UUIDInput, + file: FileInput, + *, + idempotency_key: str, + filename: Optional[str] = None, + content_type: Optional[str] = None, + expected_sha256: Optional[str] = None, + ) -> NotebookStagedUpload: + """Stream one file into an existing save session. + + Call this method repeatedly with the same ``save_session_id`` and a new + idempotency key for each file to stage multiple files in one save. + """ + + session_id = _uuid_text(save_session_id, "save_session_id") + form: Dict[str, str] = {} + if expected_sha256 is not None: + form["expected_sha256"] = _sha256(expected_sha256) + + if isinstance(file, (str, Path)): + path = Path(file) + actual_filename = filename or path.name + actual_content_type = content_type or _guess_content_type(actual_filename) + with path.open("rb") as handle: + response = self._idempotent_request( + "POST", + self._uploads_path(note_id, session_id), + idempotency_key=idempotency_key, + data=form, + files={"file": (actual_filename, handle, actual_content_type)}, + ) + else: + if not callable(getattr(file, "read", None)): + raise _invalid_request("file must be a path or binary file handle", "file") + actual_filename = filename or _handle_filename(file) + if not actual_filename: + raise _invalid_request( + "filename is required for an unnamed file handle", + "filename", + ) + actual_content_type = content_type or _guess_content_type(actual_filename) + response = self._idempotent_request( + "POST", + self._uploads_path(note_id, session_id), + idempotency_key=idempotency_key, + data=form, + files={"file": (actual_filename, file, actual_content_type)}, + ) + + return _typed_result(response, NotebookStagedUpload.from_dict) + + def status( + self, + note_id: str, + save_session_id: UUIDInput, + ) -> NotebookSaveSessionStatus: + """Return a save session and the status of all its uploads.""" + + response = self._get(self._session_path(note_id, save_session_id)) + return _typed_result(response, NotebookSaveSessionStatus.from_dict) + + def upload_status(self, upload_id: UUIDInput) -> NotebookStagedUpload: + """Return the status of one staged upload.""" + + response = self._get( + f"{_NOTEBOOKS_PATH}/staged-uploads/{quote(_uuid_text(upload_id, 'upload_id'))}" + ) + return _typed_result(response, NotebookStagedUpload.from_dict) + + def cancel( + self, + note_id: str, + save_session_id: UUIDInput, + *, + idempotency_key: str, + ) -> NotebookSaveSessionCancellation: + """Cancel a save session and release its staged reservations.""" + + response = self._idempotent_request( + "DELETE", + self._session_path(note_id, save_session_id), + idempotency_key=idempotency_key, + ) + return _typed_result(response, NotebookSaveSessionCancellation.from_dict) + + def commit( + self, + note_id: str, + *, + desired_state: Union[NotebookDesiredState, Mapping[str, Any]], + expected_revision: int, + idempotency_key: str, + save_session_id: Optional[UUIDInput] = None, + save_batch_id: Optional[UUIDInput] = None, + ) -> NotebookCommitResponse: + """Atomically commit a complete desired notebook state.""" + + if not isinstance(desired_state, Mapping): + raise _invalid_request("desired_state must be an object", "desired_state") + state = ( + desired_state.to_dict() + if isinstance(desired_state, NotebookDesiredState) + else dict(desired_state) + ) + + body: Dict[str, Any] = { + "desired_state": state, + "expected_revision": _revision(expected_revision, "expected_revision"), + } + if save_session_id is not None: + body["save_session_id"] = _uuid_text(save_session_id, "save_session_id") + if save_batch_id is not None: + body["save_batch_id"] = _uuid_text(save_batch_id, "save_batch_id") + + response = self._idempotent_request( + "POST", + f"{_NOTEBOOKS_PATH}/{_path_id(note_id, 'note_id')}/commits", + idempotency_key=idempotency_key, + json=body, + ) + return _typed_result(response, NotebookCommitResponse.from_dict) + + def _session_path(self, note_id: str, save_session_id: UUIDInput) -> str: + return ( + f"{_NOTEBOOKS_PATH}/{_path_id(note_id, 'note_id')}/save-sessions/" + f"{quote(_uuid_text(save_session_id, 'save_session_id'))}" + ) + + def _uploads_path(self, note_id: str, save_session_id: UUIDInput) -> str: + return f"{self._session_path(note_id, save_session_id)}/uploads" + + def _idempotent_request( + self, + method: str, + path: str, + *, + idempotency_key: str, + **kwargs: Any, + ) -> Any: + return self._client._request( + method=method, + path=path, + headers={ + "Idempotency-Key": _required_text( + idempotency_key, + "idempotency_key", + max_length=_MAX_IDEMPOTENCY_KEY_LENGTH, + ) + }, + **kwargs, + ) + + +def _result_mapping(response: Any) -> Mapping[str, Any]: + status_code = int(getattr(response, "status_code", 0) or 0) + try: + payload = response.json() + except (TypeError, ValueError) as exc: + raise OfSpectrumError( + message="Notebook commit service returned an invalid response", + code="InvalidNotebookCommitResponse", + status_code=status_code or None, + details={}, + ) from exc + + raise_for_error(payload, status_code) + if status_code >= 400: + raise OfSpectrumError( + message="Notebook commit request failed", + code="NotebookCommitRequestFailed", + status_code=status_code, + details={}, + ) + if not isinstance(payload, Mapping): + raise OfSpectrumError( + message="Notebook commit service returned an invalid response", + code="InvalidNotebookCommitResponse", + status_code=status_code or None, + details={}, + ) + + result = payload.get("data", payload) + if not isinstance(result, Mapping): + raise OfSpectrumError( + message="Notebook commit service returned an invalid response", + code="InvalidNotebookCommitResponse", + status_code=status_code or None, + details={}, + ) + return result + + +def _typed_result( + response: Any, + factory: Callable[[Mapping[str, Any]], ResultModel], +) -> ResultModel: + try: + return factory(_result_mapping(response)) + except OfSpectrumError: + raise + except (KeyError, TypeError, ValueError) as exc: + raise OfSpectrumError( + message="Notebook commit service returned an invalid response", + code="InvalidNotebookCommitResponse", + status_code=int(getattr(response, "status_code", 0) or 0) or None, + details={}, + ) from exc + + +def _required_text( + value: Any, + field: str, + *, + max_length: Optional[int] = None, +) -> str: + if not isinstance(value, str) or not value.strip(): + raise _invalid_request(f"{field} must not be empty", field) + normalized = value.strip() + if max_length is not None and len(normalized) > max_length: + raise _invalid_request(f"{field} is too long", field) + return normalized + + +def _revision(value: Any, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise _invalid_request(f"{field} must be a non-negative integer", field) + return value + + +def _uuid_text(value: Any, field: str) -> str: + try: + parsed = value if isinstance(value, UUID) else UUID(_required_text(value, field)) + except (AttributeError, TypeError, ValueError): + raise _invalid_request(f"{field} must be a UUID", field) from None + return str(parsed) + + +def _sha256(value: Any) -> str: + normalized = _required_text(value, "expected_sha256").lower() + if not _SHA256_RE.fullmatch(normalized): + raise _invalid_request( + "expected_sha256 must be a 64-character hexadecimal digest", + "expected_sha256", + ) + return normalized + + +def _path_id(value: Any, field: str) -> str: + return quote(_required_text(value, field), safe="") + + +def _handle_filename(handle: BinaryIO) -> Optional[str]: + name = getattr(handle, "name", None) + if not isinstance(name, (str, Path)): + return None + filename = Path(name).name + return filename or None + + +def _guess_content_type(filename: str) -> str: + guessed, _ = mimetypes.guess_type(filename) + return guessed or "application/octet-stream" + + +def _invalid_request(message: str, field: str) -> ValidationError: + return ValidationError( + message=message, + code="InvalidNotebookCommitRequest", + status_code=400, + details={"field": field}, + field=field, + ) diff --git a/ofspectrum/resources/notebooks.py b/ofspectrum/resources/notebooks.py index d34c5f0..92c9b73 100644 --- a/ofspectrum/resources/notebooks.py +++ b/ofspectrum/resources/notebooks.py @@ -2,12 +2,42 @@ Notebooks resource for managing token notes """ -from typing import List, Optional, Union, BinaryIO -from pathlib import Path import mimetypes +from pathlib import Path +from typing import ( + Any, + BinaryIO, + Dict, + List, + Mapping, + Optional, + Sequence, + Union, +) +from uuid import uuid4 + +from ..exceptions import OfSpectrumError, ValidationError, raise_for_error +from ..models.notebook import ( + Notebook, + NotebookCreateParams, + NotebookMedia, + NotebookUpdateParams, +) +from ..models.notebook_version import ( + NotebookCommitMedia, + NotebookCommitResponse, + NotebookDesiredMedia, + NotebookDesiredState, +) from .base import BaseResource -from ..models.notebook import Notebook, NotebookCreateParams, NotebookUpdateParams -from ..exceptions import raise_for_error + +_IMMUTABLE_DISABLED_CODES = { + "immutable_write_disabled", + "immutable_writes_disabled", + "notebook_immutable_write_disabled", + "notebook_immutable_writes_disabled", +} +_UNCHANGED = object() class NotebooksResource(BaseResource): @@ -21,7 +51,12 @@ def list(self, token_id: str) -> List[Notebook]: token_id: The token UUID Returns: - List of Notebook objects + List of Notebook objects. Summary responses that omit media expose + ``Notebook.media`` as ``None`` rather than an authoritative empty list. + + Note: + Use :meth:`get` when building a complete desired-state update; it + returns the current revision and ordered media projection. Example: notebooks = client.notebooks.list(token_id="...") @@ -47,16 +82,35 @@ def get(self, note_id: str) -> Notebook: Notebook object Note: - This returns the current notebook state when available. + This side-effect-free request returns the current revision and the + authoritative ordered media projection for desired-state workflows. """ - # Use an empty update request to return the current note state. - response = self._patch(f"/watermark-notes/{note_id}", data={}) + return self._fetch_notebook(note_id, require_authoritative=False) + + def _fetch_notebook( + self, + note_id: str, + *, + require_authoritative: bool, + ) -> Notebook: + response = self._get(f"/watermark-notes/{note_id}") data = response.json() raise_for_error(data, response.status_code) # API returns the note directly or wrapped in data - note_data = data if isinstance(data, dict) and "id" in data else data.get("data", {}) - return Notebook.from_dict(note_data) + note_data = ( + data + if isinstance(data, dict) and "id" in data + else data.get("data", {}) + if isinstance(data, dict) + else None + ) + if require_authoritative: + return _authoritative_notebook(note_data, expected_note_id=note_id) + try: + return Notebook.from_dict(note_data) + except (KeyError, TypeError, ValueError) as exc: + raise _invalid_projection_response() from exc def create( self, @@ -133,16 +187,47 @@ def update( update_data = params.to_dict() if not update_data: - # Nothing to update, attempt to return current notebook + # Nothing to update; return the authoritative current projection. return self.get(note_id) - response = self._patch(f"/watermark-notes/{note_id}", data=update_data) - data = response.json() - raise_for_error(data, response.status_code) + current = self._fetch_notebook(note_id, require_authoritative=True) + desired_state = _desired_state( + current, + note_name=( + current.note_name + if note_name is None or not note_name.strip() + else note_name + ), + text_content=(current.text_content if text_content is None else text_content), + credential_val=( + current.credential_val + if credential_val is None + else credential_val or None + ), + ) - # API returns the note directly - note_data = data if isinstance(data, dict) and "id" in data else data.get("data", {}) - return Notebook.from_dict(note_data) + session_id = None + try: + commits = self._client.notebook_commits + session = commits.begin(note_id, idempotency_key=str(uuid4())) + session_id = session.save_session_id + committed = commits.commit( + note_id, + desired_state=desired_state, + expected_revision=_current_revision(current), + idempotency_key=str(uuid4()), + save_session_id=session_id, + save_batch_id=uuid4(), + ) + return _notebook_from_commit(current, desired_state, committed) + except OfSpectrumError as exc: + self._cancel_session(note_id, session_id) + if _is_explicit_immutable_disabled(exc): + return self._legacy_update(note_id, update_data) + raise + except Exception: + self._cancel_session(note_id, session_id) + raise def delete(self, note_id: str) -> bool: """ @@ -190,10 +275,10 @@ def upload_media( note_id: The notebook UUID file: File path or file-like object filename: Optional filename (required if file is a file-like object) - media_type: Optional media type (auto-detected if not provided) + media_type: Optional MIME hint; the server detects the type from bytes Returns: - Dict with upload result including media_id and url + Dict containing the committed media record Example: result = client.notebooks.upload_media( @@ -201,51 +286,217 @@ def upload_media( file="path/to/audio.mp3" ) print(f"Uploaded: {result['id']}") + + Note: + Existing notebook fields and ordered media are read first, then the + file is staged once in one save session and committed as a complete + desired state. """ if isinstance(file, (str, Path)): path = Path(file) actual_filename = path.name - # Auto-detect media type from file extension - if not media_type: - mime_type, _ = mimetypes.guess_type(str(path)) - media_type = mime_type or "application/octet-stream" - with open(path, "rb") as f: - files = {"file": (actual_filename, f)} - data = {"media_type": media_type} - response = self._post(f"/watermark-notes/{note_id}/media", files=files, data=data) + _reject_obvious_svg(f) else: if not filename: - raise ValueError("filename is required when uploading a file-like object") - # Auto-detect media type from filename - if not media_type: - mime_type, _ = mimetypes.guess_type(filename) - media_type = mime_type or "application/octet-stream" - - files = {"file": (filename, file)} - data = {"media_type": media_type} - response = self._post(f"/watermark-notes/{note_id}/media", files=files, data=data) - - resp_data = response.json() - raise_for_error(resp_data, response.status_code) - - # API returns the media record directly - return resp_data if isinstance(resp_data, dict) else resp_data.get("data", {}) - - def delete_media(self, media_id: str) -> bool: + raise ValidationError( + message="filename is required when uploading a file-like object", + code="InvalidNotebookMediaRequest", + status_code=400, + field="filename", + details={"field": "filename"}, + ) + actual_filename = filename + _reject_obvious_svg(file) + + actual_media_type = media_type or _guess_media_type(actual_filename) + initial_position = _stream_position(file) + current = self._fetch_notebook(note_id, require_authoritative=True) + retained_media = _desired_media(current.media or []) + session_id = None + stage_attempted = False + try: + commits = self._client.notebook_commits + session = commits.begin(note_id, idempotency_key=str(uuid4())) + session_id = session.save_session_id + stage_attempted = True + staged = commits.stage( + note_id, + session_id, + file, + filename=actual_filename, + content_type=actual_media_type, + idempotency_key=str(uuid4()), + ) + desired_state = _desired_state( + current, + media=retained_media + + ( + NotebookDesiredMedia( + upload_id=staged.upload_id, + filename=staged.filename or actual_filename, + display_order=len(retained_media), + ), + ), + ) + committed = commits.commit( + note_id, + desired_state=desired_state, + expected_revision=_current_revision(current), + idempotency_key=str(uuid4()), + save_session_id=session_id, + save_batch_id=uuid4(), + ) + _notebook_from_commit(current, desired_state, committed) + uploaded = _committed_media_at(committed, len(retained_media)) + return _media_result(uploaded, note_id=note_id) + except OfSpectrumError as exc: + self._cancel_session(note_id, session_id) + if _is_explicit_immutable_disabled(exc): + if stage_attempted and not _restore_stream_position( + file, initial_position + ): + raise + return self._legacy_upload_media( + note_id, + file, + filename=filename, + media_type=media_type, + ) + raise + except Exception: + self._cancel_session(note_id, session_id) + raise + + def delete_media(self, media_id: str, note_id: Optional[str] = None) -> bool: """ Delete a media file. Args: media_id: The media UUID + note_id: The owning notebook UUID. Required so the SDK can fetch and + preserve the complete authoritative notebook projection. Returns: True if deleted successfully Note: - Unlike other methods, delete_media only needs the media_id, - not the note_id. + The atomic commit removes only ``media_id`` and retains every other + media item in its current order. """ + if not isinstance(note_id, str) or not note_id.strip(): + raise ValidationError( + message="note_id is required for an atomic notebook media delete", + code="InvalidNotebookMediaRequest", + status_code=400, + field="note_id", + details={"field": "note_id"}, + ) + + current = self._fetch_notebook(note_id, require_authoritative=True) + current_media = current.media or [] + if not any(item.id == media_id for item in current_media): + raise ValidationError( + message="media_id is not present in the current notebook projection", + code="InvalidNotebookMediaRequest", + status_code=400, + field="media_id", + details={"field": "media_id"}, + ) + + retained = [item for item in current_media if item.id != media_id] + desired_state = _desired_state(current, media=_desired_media(retained)) + session_id = None + try: + commits = self._client.notebook_commits + session = commits.begin(note_id, idempotency_key=str(uuid4())) + session_id = session.save_session_id + committed = commits.commit( + note_id, + desired_state=desired_state, + expected_revision=_current_revision(current), + idempotency_key=str(uuid4()), + save_session_id=session_id, + save_batch_id=uuid4(), + ) + _notebook_from_commit(current, desired_state, committed) + return True + except OfSpectrumError as exc: + self._cancel_session(note_id, session_id) + if _is_explicit_immutable_disabled(exc): + return self._legacy_delete_media(media_id) + raise + except Exception: + self._cancel_session(note_id, session_id) + raise + + def _cancel_session(self, note_id: str, session_id: Optional[str]) -> None: + if session_id is None: + return + try: + self._client.notebook_commits.cancel( + note_id, + session_id, + idempotency_key=str(uuid4()), + ) + except Exception: + # Preserve the original write error; abandoned sessions expire server-side. + pass + + def _legacy_update(self, note_id: str, update_data: Dict[str, str]) -> Notebook: + response = self._patch(f"/watermark-notes/{note_id}", data=update_data) + data = response.json() + raise_for_error(data, response.status_code) + + note_data = ( + data + if isinstance(data, dict) and "id" in data + else data.get("data", {}) + if isinstance(data, dict) + else {} + ) + return Notebook.from_dict(note_data) + + def _legacy_upload_media( + self, + note_id: str, + file: Union[str, Path, BinaryIO], + *, + filename: Optional[str], + media_type: Optional[str], + ) -> dict: + if isinstance(file, (str, Path)): + path = Path(file) + actual_filename = path.name + actual_media_type = media_type or _guess_media_type(actual_filename) + with open(path, "rb") as handle: + _reject_obvious_svg(handle) + response = self._post( + f"/watermark-notes/{note_id}/media", + files={"file": (actual_filename, handle)}, + data={"media_type": actual_media_type}, + ) + else: + if not filename: + raise ValidationError( + message="filename is required when uploading a file-like object", + code="InvalidNotebookMediaRequest", + status_code=400, + field="filename", + details={"field": "filename"}, + ) + _reject_obvious_svg(file) + response = self._post( + f"/watermark-notes/{note_id}/media", + files={"file": (filename, file)}, + data={"media_type": media_type or _guess_media_type(filename)}, + ) + + data = response.json() + raise_for_error(data, response.status_code) + return data if isinstance(data, dict) else data.get("data", {}) + + def _legacy_delete_media(self, media_id: str) -> bool: response = self._delete(f"/watermark-notes/media/{media_id}") data = response.json() raise_for_error(data, response.status_code) @@ -307,10 +558,14 @@ def download_media( if response.status_code != 200: try: data = response.json() + except ValueError: + data = None + if data is not None: raise_for_error(data, response.status_code) - except Exception: - from ..exceptions import OfSpectrumError - raise OfSpectrumError(f"Download failed with status {response.status_code}") + raise OfSpectrumError( + f"Download failed with status {response.status_code}", + status_code=response.status_code, + ) content = response.content @@ -322,3 +577,250 @@ def download_media( return str(path) return content + + +def _authoritative_notebook( + data: Any, + *, + expected_note_id: str, +) -> Notebook: + required_fields = { + "id", + "token_id", + "note_name", + "text_content", + "is_public", + "credential_val", + "revision", + "media", + } + if not isinstance(data, Mapping) or not required_fields.issubset(data): + raise _invalid_projection_response() + if data.get("id") != expected_note_id: + raise _invalid_projection_response() + if not isinstance(data.get("token_id"), str): + raise _invalid_projection_response() + if not isinstance(data.get("note_name"), str): + raise _invalid_projection_response() + if data.get("text_content") is not None and not isinstance(data.get("text_content"), str): + raise _invalid_projection_response() + if not isinstance(data.get("is_public"), bool): + raise _invalid_projection_response() + if data.get("credential_val") is not None and not isinstance( + data.get("credential_val"), str + ): + raise _invalid_projection_response() + revision = data.get("revision") + if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0: + raise _invalid_projection_response() + raw_media = data.get("media") + if not isinstance(raw_media, list): + raise _invalid_projection_response() + + media_ids = [] + for item in raw_media: + if not isinstance(item, Mapping): + raise _invalid_projection_response() + media_id = item.get("id") + if not isinstance(media_id, str) or not media_id.strip(): + raise _invalid_projection_response() + media_ids.append(media_id) + if len(media_ids) != len(set(media_ids)): + raise _invalid_projection_response() + + try: + return Notebook.from_dict(dict(data)) + except (KeyError, TypeError, ValueError) as exc: + raise _invalid_projection_response() from exc + + +def _invalid_projection_response() -> OfSpectrumError: + return OfSpectrumError( + message="Notebook service returned an incomplete current projection", + code="InvalidNotebookProjectionResponse", + ) + + +def _current_revision(notebook: Notebook) -> int: + revision = notebook.revision + if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0: + raise _invalid_projection_response() + return revision + + +def _desired_media(media: Sequence[NotebookMedia]) -> tuple: + return tuple( + NotebookDesiredMedia( + media_id=item.id, + filename=item.filename, + display_order=index, + ) + for index, item in enumerate(media) + ) + + +def _desired_state( + current: Notebook, + *, + note_name: Any = _UNCHANGED, + text_content: Any = _UNCHANGED, + credential_val: Any = _UNCHANGED, + media: Optional[Sequence[NotebookDesiredMedia]] = None, +) -> NotebookDesiredState: + if current.media is None: + raise _invalid_projection_response() + return NotebookDesiredState( + note_name=current.note_name if note_name is _UNCHANGED else note_name, + text_content=current.text_content if text_content is _UNCHANGED else text_content, + is_public=current.is_public, + credential_val=( + current.credential_val if credential_val is _UNCHANGED else credential_val + ), + media=_desired_media(current.media) if media is None else tuple(media), + ) + + +def _notebook_from_commit( + current: Notebook, + desired_state: NotebookDesiredState, + committed: NotebookCommitResponse, +) -> Notebook: + ordered = _complete_committed_media(committed, expected_count=len(desired_state.media)) + for expected, actual in zip(desired_state.media, ordered): + if expected.media_id is not None and expected.media_id != actual.id: + raise OfSpectrumError( + message="Notebook commit service returned a mismatched media projection", + code="InvalidNotebookCommitResponse", + ) + return Notebook( + id=current.id, + token_id=current.token_id, + note_name=desired_state.note_name or "", + text_content=desired_state.text_content, + is_public=desired_state.is_public, + credential_val=desired_state.credential_val, + media=[ + NotebookMedia( + id=item.id, + filename=item.filename, + file_size=item.file_size_bytes, + content_type=item.media_type, + display_order=item.display_order, + ) + for item in ordered + ], + created_at=current.created_at, + updated_at=current.updated_at, + revision=committed.resulting_revision, + ) + + +def _complete_committed_media( + committed: NotebookCommitResponse, + *, + expected_count: int, +) -> List[NotebookCommitMedia]: + ordered = sorted(committed.media, key=lambda item: item.display_order) + if len(ordered) != expected_count or [item.display_order for item in ordered] != list( + range(expected_count) + ): + raise OfSpectrumError( + message="Notebook commit service returned an incomplete media projection", + code="InvalidNotebookCommitResponse", + ) + return ordered + + +def _committed_media_at( + committed: NotebookCommitResponse, + display_order: int, +) -> NotebookCommitMedia: + ordered = _complete_committed_media(committed, expected_count=display_order + 1) + return ordered[display_order] + + +def _media_result(media: NotebookCommitMedia, *, note_id: str) -> dict: + return { + "id": media.id, + "note_id": note_id, + "filename": media.filename, + "media_type": media.media_type, + "file_size_bytes": media.file_size_bytes, + "display_order": media.display_order, + } + + +def _guess_media_type(filename: str) -> str: + mime_type, _ = mimetypes.guess_type(filename) + return mime_type or "application/octet-stream" + + +def _reject_obvious_svg(stream: Any) -> None: + if not hasattr(stream, "tell") or not hasattr(stream, "seek"): + return + position = stream.tell() + prefix = stream.read(8192) + stream.seek(position) + if not isinstance(prefix, bytes): + return + normalized = prefix.lstrip(b"\xef\xbb\xbf\x00\t\n\r ").lower() + if normalized.startswith(b" Optional[int]: + if isinstance(file, (str, Path)) or not hasattr(file, "tell"): + return None + try: + return file.tell() + except (OSError, ValueError): + return None + + +def _restore_stream_position( + file: Union[str, Path, BinaryIO], + position: Optional[int], +) -> bool: + if isinstance(file, (str, Path)): + return True + if position is None or not hasattr(file, "seek"): + return False + try: + file.seek(position) + return True + except (OSError, ValueError): + return False + + +def _is_explicit_immutable_disabled(exc: OfSpectrumError) -> bool: + candidates = [exc.code] + details = exc.details if isinstance(exc.details, Mapping) else {} + candidates.extend((details.get("code"), details.get("error"))) + nested = details.get("details") + if isinstance(nested, Mapping): + candidates.extend((nested.get("code"), nested.get("error"))) + + return any(_normalize_error_code(value) in _IMMUTABLE_DISABLED_CODES for value in candidates) + + +def _normalize_error_code(value: Any) -> str: + if not isinstance(value, str): + return "" + normalized = [] + previous = "" + for character in value.strip(): + if character.isupper() and previous and ( + previous.islower() or previous.isdigit() + ): + normalized.append("_") + normalized.append(character.lower() if character.isalnum() else "_") + previous = character + return "_".join(filter(None, "".join(normalized).split("_"))) diff --git a/ofspectrum/resources/quotas.py b/ofspectrum/resources/quotas.py index fd468ad..36b21db 100644 --- a/ofspectrum/resources/quotas.py +++ b/ofspectrum/resources/quotas.py @@ -2,9 +2,9 @@ Quotas resource for checking service usage """ -from .base import BaseResource -from ..models.quota import Quota, QuotaList from ..exceptions import raise_for_error +from ..models.quota import Quota, QuotaList +from .base import BaseResource class QuotasResource(BaseResource): diff --git a/ofspectrum/resources/tokens.py b/ofspectrum/resources/tokens.py index c8a93f3..5b81058 100644 --- a/ofspectrum/resources/tokens.py +++ b/ofspectrum/resources/tokens.py @@ -2,10 +2,18 @@ Tokens resource for managing watermark tokens """ -from typing import Any, List, Literal, Optional -from .base import BaseResource -from ..models.token import AiAuthTag, UNSET, Token, TokenCreateParams, TokenUpdateParams +from typing import Any, List, Literal, Optional, overload + from ..exceptions import raise_for_error +from ..models.token import ( + UNSET, + AiAuthTag, + OptionalBoolArgument, + Token, + TokenCreateParams, + TokenUpdateParams, +) +from .base import BaseResource class TokensResource(BaseResource): @@ -75,6 +83,36 @@ def get(self, token_id: str) -> Token: return Token.from_dict(data[0]) return Token.from_dict(data.get("data", {}) if isinstance(data, dict) else {}) + @overload + def create( + self, + name: str, + token_type: Literal["pro"], + public_key: int, + ai_auth_enabled: bool = False, + ai_auth_access_type: Optional[Literal["direct_use", "premium_track"]] = None, + ai_auth_price: Optional[float] = None, + ai_auth_other_instructions: Optional[str] = None, + ai_auth_tags: Optional[List[str]] = None, + version_control_override: OptionalBoolArgument = UNSET, + storage_auto_expand_override: OptionalBoolArgument = UNSET, + ) -> Token: ... + + @overload + def create( + self, + name: str, + token_type: Literal["standard"] = "standard", + public_key: Optional[int] = None, + ai_auth_enabled: bool = False, + ai_auth_access_type: Optional[Literal["direct_use", "premium_track"]] = None, + ai_auth_price: Optional[float] = None, + ai_auth_other_instructions: Optional[str] = None, + ai_auth_tags: Optional[List[str]] = None, + version_control_override: OptionalBoolArgument = UNSET, + storage_auto_expand_override: OptionalBoolArgument = UNSET, + ) -> Token: ... + def create( self, name: str, @@ -85,6 +123,8 @@ def create( ai_auth_price: Optional[float] = None, ai_auth_other_instructions: Optional[str] = None, ai_auth_tags: Optional[List[str]] = None, + version_control_override: OptionalBoolArgument = UNSET, + storage_auto_expand_override: OptionalBoolArgument = UNSET, ) -> Token: """ Create a new watermark token. @@ -92,13 +132,16 @@ def create( Args: name: Token name (for identification) token_type: "standard" (default) or "pro" - public_key: Verification key when your token workflow requires one. - Defaults to 258 for pro tokens if not provided. + public_key: Verification key, required for pro tokens ai_auth_enabled: Whether AI authorization is enabled ai_auth_access_type: "direct_use" or "premium_track" (optional) ai_auth_price: AI authorization price; must be at least 1 when set ai_auth_other_instructions: Additional AI authorization instructions ai_auth_tags: Searchable AI authorization tags + version_control_override: True/False to override, None to inherit, + or omit to use the API default + storage_auto_expand_override: True/False to override, None to inherit, + or omit to use the API default Returns: Newly created Token object @@ -120,9 +163,6 @@ def create( if token_type not in ("standard", "pro"): raise ValueError("token_type must be 'standard' or 'pro'") - # Set default public_key for pro type - if token_type == "pro" and public_key is None: - public_key = 258 # Default public key, matches web interface params = TokenCreateParams( name=name, token_type=token_type, @@ -132,6 +172,8 @@ def create( ai_auth_price=ai_auth_price, ai_auth_other_instructions=ai_auth_other_instructions, ai_auth_tags=ai_auth_tags, + version_control_override=version_control_override, + storage_auto_expand_override=storage_auto_expand_override, ) response = self._post("/tokens/", json=params.to_dict()) @@ -155,6 +197,8 @@ def update( ai_auth_price: Any = UNSET, ai_auth_other_instructions: Optional[str] = None, ai_auth_tags: Optional[List[str]] = None, + version_control_override: OptionalBoolArgument = UNSET, + storage_auto_expand_override: OptionalBoolArgument = UNSET, ) -> Token: """ Update an existing token. @@ -170,6 +214,10 @@ def update( ai_auth_price: AI authorization price, or None to clear it ai_auth_other_instructions: Additional AI authorization instructions ai_auth_tags: Replacement list of AI authorization tags + version_control_override: True/False to override, None to inherit, + or omit to leave unchanged + storage_auto_expand_override: True/False to override, None to inherit, + or omit to leave unchanged Returns: Updated Token object @@ -184,6 +232,8 @@ def update( ai_auth_price=ai_auth_price, ai_auth_other_instructions=ai_auth_other_instructions, ai_auth_tags=ai_auth_tags, + version_control_override=version_control_override, + storage_auto_expand_override=storage_auto_expand_override, ) update_data = params.to_dict() diff --git a/ofspectrum/resources/webhooks.py b/ofspectrum/resources/webhooks.py index a14e6f9..e512191 100644 --- a/ofspectrum/resources/webhooks.py +++ b/ofspectrum/resources/webhooks.py @@ -2,10 +2,11 @@ Webhooks resource for managing webhook configurations """ -from typing import List, Optional from dataclasses import dataclass -from .base import BaseResource +from typing import List, Optional + from ..exceptions import raise_for_error +from .base import BaseResource @dataclass diff --git a/ofspectrum/utils/retry.py b/ofspectrum/utils/retry.py index 14070a4..8de9999 100644 --- a/ofspectrum/utils/retry.py +++ b/ofspectrum/utils/retry.py @@ -2,13 +2,13 @@ Retry utilities with exponential backoff """ -import time import random +import time from dataclasses import dataclass -from typing import Callable, TypeVar, Optional, Tuple, Type from functools import wraps +from typing import Callable, Optional, Tuple, Type, TypeVar -from ..exceptions import OfSpectrumError, RateLimitError, ServiceUnavailableError, NetworkError +from ..exceptions import NetworkError, RateLimitError, ServiceUnavailableError T = TypeVar("T") diff --git a/pyproject.toml b/pyproject.toml index eef2cca..db8cf6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ofspectrum" -version = "1.1.6" +version = "1.2.0" description = "OfSpectrum Audio Watermarking SDK" readme = "README.md" license = {text = "MIT"} @@ -36,6 +36,7 @@ dependencies = [ dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", + "requests>=2.0.0", "ruff>=0.1.0", ] diff --git a/test_api.py b/test_api.py index bb24760..46ae506 100644 --- a/test_api.py +++ b/test_api.py @@ -6,71 +6,33 @@ # Set environment variables first export TEST_API_KEY_USER_A="your_api_key" export TEST_API_KEY_USER_B="another_api_key" - export TEST_API_BASE_URL="http://localhost:8000/api/v1" # optional + export TEST_API_BASE_URL="http://localhost:8800/api/v1" # optional # Run test python test_api.py """ import os +import struct import sys -import subprocess +import traceback +import wave from pathlib import Path +import requests + +from ofspectrum import OfSpectrum +from ofspectrum.exceptions import AuthenticationError, OfSpectrumError, ValidationError + +__test__ = False + # Fix Windows console encoding if sys.platform == 'win32': try: sys.stdout.reconfigure(encoding='utf-8', errors='replace') - except: + except (AttributeError, OSError): pass -# ============================================================================= -# Auto-install dependencies -# ============================================================================= - -def check_and_install_deps(): - """Check and install required dependencies""" - required = ['requests'] - missing = [] - - for pkg in required: - try: - __import__(pkg) - except ImportError: - missing.append(pkg) - - if missing: - print(f"[Setup] Installing missing dependencies: {missing}") - subprocess.check_call([sys.executable, '-m', 'pip', 'install'] + missing, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - print(" Dependencies installed") - - # Check if SDK is installed or available locally - sdk_path = Path(__file__).parent - if (sdk_path / 'ofspectrum').exists(): - # Local SDK available - sys.path.insert(0, str(sdk_path)) - else: - # Try to install from PyPI - try: - import ofspectrum - except ImportError: - print("[Setup] Installing ofspectrum SDK...") - subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'ofspectrum'], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - print(" SDK installed") - -check_and_install_deps() - -import json -import traceback -import requests -import wave -import struct - -from ofspectrum import OfSpectrum -from ofspectrum.exceptions import OfSpectrumError, AuthenticationError, ValidationError - # ============================================================================= # Configuration # ============================================================================= @@ -78,14 +40,7 @@ def check_and_install_deps(): # Load from environment variables API_KEY_USER_A = os.environ.get("TEST_API_KEY_USER_A", "") API_KEY_USER_B = os.environ.get("TEST_API_KEY_USER_B", "") -BASE_URL = os.environ.get("TEST_API_BASE_URL", "http://localhost:8000/api/v1") - -if not API_KEY_USER_A or not API_KEY_USER_B: - print("Please set environment variables:") - print(" TEST_API_KEY_USER_A=") - print(" TEST_API_KEY_USER_B=") - print(" TEST_API_BASE_URL=") - sys.exit(1) +BASE_URL = os.environ.get("TEST_API_BASE_URL", "http://localhost:8800/api/v1") # Test results tracking results = { @@ -237,7 +192,7 @@ def test_url_decode(api_key, audio_path): if response.status_code == 200: data = response.json() - log_pass("[URL] POST decode", f"Status: 200") + log_pass("[URL] POST decode", "Status: 200") # Check response fields if 'data' in data: @@ -518,6 +473,19 @@ def test_sdk_notebooks(client, token_id, audio_path): log_warn("[SDK] notebooks", "No notebooks to test") return + # Read the authoritative current projection used by revision-safe saves. + try: + current = client.notebooks.get(test_notebook.id) + if current.revision is None or current.media is None: + log_fail("[SDK] notebooks.get()", "Current revision or media is missing") + else: + log_pass( + "[SDK] notebooks.get()", + f"Revision {current.revision}, {len(current.media)} ordered media files", + ) + except Exception as e: + log_fail("[SDK] notebooks.get()", str(e)) + # List media try: media_list = client.notebooks.list_media(note_id=test_notebook.id) @@ -581,6 +549,13 @@ def test_sdk_invalid_api_key(): # ============================================================================= def main(): + if not API_KEY_USER_A or not API_KEY_USER_B: + print("Please set environment variables:") + print(" TEST_API_KEY_USER_A=") + print(" TEST_API_KEY_USER_B=") + print(" TEST_API_BASE_URL=") + return + print("=" * 70) print("Complete API Test - SDK + Direct URL") print("=" * 70) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..26acf9f --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,108 @@ +import httpx +import pytest + +from ofspectrum.client import OfSpectrum, _checked_response +from ofspectrum.exceptions import ( + AuthenticationError, + ResourceNotFoundError, + ServiceUnavailableError, +) + + +def test_client_version_headers_match_release(): + client = OfSpectrum(api_key="test-key") + try: + assert client._default_headers()["User-Agent"] == "OfSpectrum-Python-SDK/1.2.0" + finally: + client.close() + + +def test_checked_response_maps_plain_fastapi_authentication_error(): + response = httpx.Response(401, json={"detail": "Authentication required"}) + + with pytest.raises(AuthenticationError): + _checked_response(response) + + +def test_checked_response_maps_plain_not_found_error(): + response = httpx.Response(404, json={"detail": "Notebook not found"}) + + with pytest.raises(ResourceNotFoundError): + _checked_response(response) + + +def test_checked_response_sanitizes_non_json_service_error(): + response = httpx.Response(503, content=b"upstream details") + + with pytest.raises(ServiceUnavailableError) as exc: + _checked_response(response) + + assert "upstream details" not in str(exc.value) + + +def test_current_notebook_get_uses_authenticated_http_transport(): + def handler(request): + assert request.method == "GET" + assert request.url.path == "/api/v1/watermark-notes/note-1" + assert request.headers["Authorization"] == "Bearer test-key" + return httpx.Response( + 200, + json={ + "id": "note-1", + "token_id": "token-1", + "note_name": "Current", + "revision": 3, + "media": [], + }, + ) + + client = OfSpectrum(api_key="test-key") + client._client.close() + client._client = httpx.Client( + base_url=client._base_url, + headers=client._default_headers(), + transport=httpx.MockTransport(handler), + ) + try: + notebook = client.notebooks.get("note-1") + finally: + client.close() + + assert notebook.revision == 3 + assert notebook.media == [] + + +def test_save_session_preserves_auth_and_idempotency_headers(): + session_id = "11111111-1111-4111-8111-111111111111" + + def handler(request): + assert request.method == "POST" + assert request.url.path == "/api/v1/watermark-notes/note-1/save-sessions" + assert request.headers["Authorization"] == "Bearer test-key" + assert request.headers["Idempotency-Key"] == "begin-key" + return httpx.Response( + 200, + json={ + "save_session_id": session_id, + "notebook_id": "note-1", + "state": "active", + "expires_at": "2026-08-01T00:00:00Z", + "created": True, + }, + ) + + client = OfSpectrum(api_key="test-key") + client._client.close() + client._client = httpx.Client( + base_url=client._base_url, + headers=client._default_headers(), + transport=httpx.MockTransport(handler), + ) + try: + session = client.notebook_commits.begin( + "note-1", idempotency_key="begin-key" + ) + finally: + client.close() + + assert session.save_session_id == session_id diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..2b50bda --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,91 @@ +import pytest + +from ofspectrum.exceptions import ( + AuthenticationError, + ConflictError, + PaymentRequiredError, + RateLimitError, + ResourceNotFoundError, + ServiceUnavailableError, + ValidationError, + raise_for_error, +) + + +def test_private_notebook_limit_error_describes_standard_and_pro_limits(): + with pytest.raises(ValidationError) as exc: + raise_for_error({"detail": "Private notebook limit reached for this token"}, 400) + + assert "Standard tokens do not allow new private notebooks" in str(exc.value) + assert "grandfathered" in str(exc.value) + assert "Pro tokens support five" in str(exc.value) + assert "Enterprise tokens support ten" in str(exc.value) + assert "no private notebook limit" not in str(exc.value) + + +@pytest.mark.parametrize( + ("code", "status_code", "exception_type"), + [ + ("NotebookNotFound", 404, ResourceNotFoundError), + ("NotebookRevisionConflict", 409, ConflictError), + ("NotebookSaveSessionRequired", 409, ConflictError), + ("NotebookStoragePaymentRequired", 402, PaymentRequiredError), + ("StorageChargeAuthorizationRequired", 402, PaymentRequiredError), + ("NotebookMediaTooLarge", 413, ValidationError), + ("NotebookCommitPayloadTooLarge", 413, ValidationError), + ("NotebookVersionRateLimitExceeded", 429, RateLimitError), + ("NotebookCommitUnavailable", 503, ServiceUnavailableError), + ], +) +def test_notebook_direct_errors_map_to_typed_exceptions( + code, status_code, exception_type +): + with pytest.raises(exception_type) as exc: + raise_for_error( + { + "error": code, + "message": "Safe public message", + "details": {"field": "media", "retry_after": 12}, + }, + status_code, + ) + + assert exc.value.code == code + assert exc.value.status_code == status_code + assert exc.value.details["field"] == "media" + + +def test_fastapi_detail_error_preserves_stable_code_and_safe_details(): + with pytest.raises(ConflictError) as exc: + raise_for_error( + { + "detail": { + "error": "NotebookRevisionConflict", + "message": "Reload before saving.", + "details": {"current_revision": 4}, + } + }, + 409, + ) + + assert exc.value.code == "NotebookRevisionConflict" + assert exc.value.details["current_revision"] == 4 + + +@pytest.mark.parametrize( + ("status_code", "exception_type"), + [ + (401, AuthenticationError), + (402, PaymentRequiredError), + (404, ResourceNotFoundError), + (409, ConflictError), + (422, ValidationError), + (429, RateLimitError), + (503, ServiceUnavailableError), + ], +) +def test_unstructured_http_errors_use_status_fallback(status_code, exception_type): + with pytest.raises(exception_type) as exc: + raise_for_error(None, status_code) + + assert exc.value.status_code == status_code diff --git a/tests/test_notebook_commits.py b/tests/test_notebook_commits.py new file mode 100644 index 0000000..b2a0081 --- /dev/null +++ b/tests/test_notebook_commits.py @@ -0,0 +1,286 @@ +from io import BytesIO +from uuid import UUID + +import pytest + +from ofspectrum.exceptions import ConflictError, OfSpectrumError, ValidationError +from ofspectrum.models.notebook_version import ( + NotebookCommitResponse, + NotebookDesiredMedia, + NotebookDesiredState, + NotebookSaveSession, + NotebookSaveSessionCancellation, + NotebookSaveSessionStatus, + NotebookStagedUpload, +) +from ofspectrum.resources.notebook_commits import NotebookCommitsResource + +NOTE_ID = "note/with space" +SESSION_ID = "11111111-1111-4111-8111-111111111111" +UPLOAD_ID = "22222222-2222-4222-8222-222222222222" +BATCH_ID = UUID("33333333-3333-4333-8333-333333333333") + + +class _Response: + def __init__(self, data, status_code=200): + self._data = data + self.status_code = status_code + + def json(self): + return self._data + + +class _Client: + def __init__(self, responses): + self.responses = list(responses) + self.requests = [] + + def _request(self, **kwargs): + self.requests.append(kwargs) + return self.responses.pop(0) + + +def _session_payload(): + return { + "save_session_id": SESSION_ID, + "notebook_id": NOTE_ID, + "state": "active", + "expires_at": "2026-08-01T00:00:00Z", + "created": True, + } + + +def _upload_payload(): + return { + "upload_id": UPLOAD_ID, + "save_session_id": SESSION_ID, + "notebook_id": NOTE_ID, + "state": "staged", + "expires_at": "2026-08-01T00:00:00Z", + "filename": "evidence.png", + "media_type": "image/png", + "file_size_bytes": 8, + "reserved_bytes": 8, + } + + +def test_begin_returns_typed_session_and_sends_idempotency_header(): + client = _Client([_Response({"data": _session_payload()})]) + resource = NotebookCommitsResource(client) + + session = resource.begin(NOTE_ID, idempotency_key=" begin-key ") + + assert isinstance(session, NotebookSaveSession) + assert session.save_session_id == SESSION_ID + assert client.requests == [ + { + "method": "POST", + "path": "/watermark-notes/note%2Fwith%20space/save-sessions", + "headers": {"Idempotency-Key": "begin-key"}, + } + ] + + +def test_stage_reuses_session_and_returns_typed_upload(): + client = _Client([_Response(_upload_payload())]) + resource = NotebookCommitsResource(client) + content = BytesIO(b"\x89PNG\r\n\x1a\n") + + upload = resource.stage( + NOTE_ID, + SESSION_ID, + content, + filename="evidence.png", + idempotency_key="stage-key", + expected_sha256="A" * 64, + ) + + assert isinstance(upload, NotebookStagedUpload) + assert upload.upload_id == UPLOAD_ID + request = client.requests[0] + assert request["path"].endswith(f"/save-sessions/{SESSION_ID}/uploads") + assert request["headers"] == {"Idempotency-Key": "stage-key"} + assert request["data"] == {"expected_sha256": "a" * 64} + assert request["files"]["file"][0:3:2] == ("evidence.png", "image/png") + + +def test_status_upload_status_and_cancel_return_typed_models(): + status_payload = {**_session_payload(), "uploads": [_upload_payload()]} + cancellation_payload = { + "save_session_id": SESSION_ID, + "state": "cancelled", + "released_bytes": 8, + "idempotent": False, + } + client = _Client( + [ + _Response(status_payload), + _Response(_upload_payload()), + _Response(cancellation_payload), + ] + ) + resource = NotebookCommitsResource(client) + + status = resource.status(NOTE_ID, SESSION_ID) + upload = resource.upload_status(UPLOAD_ID) + cancellation = resource.cancel( + NOTE_ID, SESSION_ID, idempotency_key="cancel-key" + ) + + assert isinstance(status, NotebookSaveSessionStatus) + assert isinstance(status.uploads[0], NotebookStagedUpload) + assert isinstance(upload, NotebookStagedUpload) + assert isinstance(cancellation, NotebookSaveSessionCancellation) + assert cancellation.released_bytes == 8 + assert client.requests[1]["path"] == f"/watermark-notes/staged-uploads/{UPLOAD_ID}" + assert client.requests[2]["method"] == "DELETE" + + +def test_commit_sends_complete_state_and_returns_typed_result(): + response = { + "notebook_id": NOTE_ID, + "expected_revision": 4, + "resulting_revision": 5, + "changed": True, + "state": "committed", + "save_batch_id": str(BATCH_ID), + "replayed": False, + "media": [ + { + "id": "media-1", + "filename": "evidence.png", + "media_type": "image/png", + "file_size_bytes": 8, + "display_order": 0, + } + ], + "storage": { + "projected_unique_bytes": 8, + "storage_auto_expand_enabled": False, + }, + } + client = _Client([_Response(response)]) + resource = NotebookCommitsResource(client) + desired_state = NotebookDesiredState( + note_name="Evidence", + text_content="Current text", + is_public=True, + media=( + NotebookDesiredMedia( + display_order=0, + upload_id=UPLOAD_ID, + filename="evidence.png", + ), + ), + ) + + result = resource.commit( + NOTE_ID, + desired_state=desired_state, + expected_revision=4, + idempotency_key="commit-key", + save_session_id=SESSION_ID, + save_batch_id=BATCH_ID, + ) + + assert isinstance(result, NotebookCommitResponse) + assert result.resulting_revision == 5 + assert result.media[0].media_type == "image/png" + assert result.media[0].file_size == 8 + assert result.storage is not None + assert result.storage.projected_unique_bytes == 8 + request = client.requests[0] + assert request["json"] == { + "desired_state": { + "note_name": "Evidence", + "text_content": "Current text", + "is_public": True, + "credential_val": None, + "media": [ + { + "display_order": 0, + "upload_id": UPLOAD_ID, + "filename": "evidence.png", + } + ], + }, + "expected_revision": 4, + "save_session_id": SESSION_ID, + "save_batch_id": str(BATCH_ID), + } + + +def test_commit_maps_structured_conflict(): + client = _Client( + [ + _Response( + { + "detail": { + "error": "NotebookRevisionConflict", + "message": "Reload before saving.", + "details": {"current_revision": 6}, + } + }, + status_code=409, + ) + ] + ) + resource = NotebookCommitsResource(client) + + with pytest.raises(ConflictError) as exc: + resource.commit( + NOTE_ID, + desired_state={ + "note_name": "Evidence", + "text_content": None, + "is_public": True, + "credential_val": None, + "media": [], + }, + expected_revision=4, + idempotency_key="commit-key", + ) + + assert exc.value.code == "NotebookRevisionConflict" + assert exc.value.details["current_revision"] == 6 + + +def test_malformed_typed_result_raises_sdk_error(): + resource = NotebookCommitsResource(_Client([_Response({"state": "active"})])) + + with pytest.raises(OfSpectrumError) as exc: + resource.begin(NOTE_ID, idempotency_key="begin-key") + + assert exc.value.code == "InvalidNotebookCommitResponse" + + +@pytest.mark.parametrize("value", ["", "not-a-uuid"]) +def test_save_session_id_must_be_uuid(value): + resource = NotebookCommitsResource(_Client([])) + + with pytest.raises(ValidationError) as exc: + resource.status(NOTE_ID, value) + + assert exc.value.code == "InvalidNotebookCommitRequest" + assert exc.value.field == "save_session_id" + + +def test_desired_state_requires_contiguous_media_order(): + with pytest.raises(ValueError, match="contiguous"): + NotebookDesiredState( + note_name="Evidence", + text_content=None, + is_public=True, + media=( + NotebookDesiredMedia( + display_order=1, + upload_id=UPLOAD_ID, + filename="evidence.png", + ), + ), + ) + + +def test_staged_desired_media_requires_filename(): + with pytest.raises(ValueError, match="filename is required"): + NotebookDesiredMedia(display_order=0, upload_id=UPLOAD_ID) diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py new file mode 100644 index 0000000..a488b55 --- /dev/null +++ b/tests/test_notebooks.py @@ -0,0 +1,486 @@ +from io import BytesIO +from types import SimpleNamespace + +import pytest + +from ofspectrum.exceptions import ( + OfSpectrumError, + ServiceUnavailableError, + ValidationError, + raise_for_error, +) +from ofspectrum.models.notebook import Notebook +from ofspectrum.models.notebook_version import ( + NotebookCommitMedia, + NotebookCommitResponse, +) +from ofspectrum.resources.notebooks import NotebooksResource + +SESSION_ID = "11111111-1111-4111-8111-111111111111" +UPLOAD_ID = "22222222-2222-4222-8222-222222222222" + + +class _Response: + def __init__(self, data=None, status_code=200): + self._data = ( + data + if data is not None + else {"id": "media-1", "media_type": "image/png"} + ) + self.status_code = status_code + + def json(self): + return self._data + + +class _Client: + def __init__(self, commits=None): + self.notebook_commits = commits + + +class _Commits: + def __init__(self, response=None, *, begin_error=None, commit_error=None): + self.response = response or _commit_response([]) + self.begin_error = begin_error + self.commit_error = commit_error + self.begin_calls = [] + self.stage_calls = [] + self.commit_calls = [] + self.cancel_calls = [] + + def begin(self, note_id, *, idempotency_key): + self.begin_calls.append((note_id, idempotency_key)) + if self.begin_error: + raise self.begin_error + return SimpleNamespace(save_session_id=SESSION_ID) + + def stage( + self, + note_id, + save_session_id, + file, + *, + filename, + content_type, + idempotency_key, + ): + self.stage_calls.append( + { + "note_id": note_id, + "save_session_id": save_session_id, + "file": file, + "filename": filename, + "content_type": content_type, + "idempotency_key": idempotency_key, + } + ) + return SimpleNamespace(upload_id=UPLOAD_ID, filename=filename) + + def commit(self, note_id, **kwargs): + self.commit_calls.append((note_id, kwargs)) + if self.commit_error: + raise self.commit_error + return self.response + + def cancel(self, note_id, save_session_id, *, idempotency_key): + self.cancel_calls.append((note_id, save_session_id, idempotency_key)) + + +def _media(media_id, filename, media_type, display_order, file_size_bytes=10): + return { + "id": media_id, + "filename": filename, + "media_type": media_type, + "file_size_bytes": file_size_bytes, + "display_order": display_order, + } + + +def _projection(*, media=None, revision=7): + return { + "id": "note-1", + "token_id": "token-1", + "note_name": "Private Evidence", + "text_content": "Authoritative text", + "is_public": False, + "credential_val": "secret", + "revision": revision, + "media": media if media is not None else [], + } + + +def _commit_response(media, *, revision=8): + return NotebookCommitResponse( + notebook_id="note-1", + expected_revision=revision - 1, + resulting_revision=revision, + changed=True, + state="committed", + media=tuple( + NotebookCommitMedia( + id=item["id"], + filename=item["filename"], + media_type=item["media_type"], + file_size_bytes=item["file_size_bytes"], + display_order=item["display_order"], + ) + for item in media + ), + ) + + +def test_upload_media_uses_one_session_and_commits_complete_projection(monkeypatch): + current_media = [_media("media-1", "first.wav", "audio/wav", 0)] + committed_media = current_media + [ + _media("media-2", "spoofed.txt", "image/png", 1, file_size_bytes=8) + ] + commits = _Commits(_commit_response(committed_media)) + resource = NotebooksResource(_Client(commits)) + requested = [] + monkeypatch.setattr( + resource, + "_get", + lambda path: requested.append(path) or _Response(_projection(media=current_media)), + ) + monkeypatch.setattr( + resource, + "_post", + lambda *_args, **_kwargs: pytest.fail("legacy upload must not be used"), + ) + content = BytesIO(b"\x89PNG\r\n\x1a\n") + + result = resource.upload_media( + "note-1", + content, + filename="spoofed.txt", + media_type="text/plain", + ) + + assert result["media_type"] == "image/png" + assert result["id"] == "media-2" + assert requested == ["/watermark-notes/note-1"] + assert len(commits.begin_calls) == 1 + assert len(commits.stage_calls) == 1 + assert len(commits.commit_calls) == 1 + assert commits.stage_calls[0]["save_session_id"] == SESSION_ID + assert commits.stage_calls[0]["content_type"] == "text/plain" + _, commit_kwargs = commits.commit_calls[0] + assert commit_kwargs["save_session_id"] == SESSION_ID + desired = commit_kwargs["desired_state"].to_dict() + assert desired == { + "note_name": "Private Evidence", + "text_content": "Authoritative text", + "is_public": False, + "credential_val": "secret", + "media": [ + {"display_order": 0, "media_id": "media-1", "filename": "first.wav"}, + {"display_order": 1, "upload_id": UPLOAD_ID, "filename": "spoofed.txt"}, + ], + } + assert content.tell() == 0 + + +def test_sdk_rejects_obvious_svg_before_upload(monkeypatch): + resource = NotebooksResource(_Client()) + monkeypatch.setattr( + resource, + "_post", + lambda *_args, **_kwargs: pytest.fail("SVG must not be uploaded"), + ) + + with pytest.raises(ValidationError, match="SVG is not supported") as exc: + resource.upload_media( + "note-1", + BytesIO(b' '), + filename="image.svg", + ) + + assert exc.value.code == "UnsupportedNotebookMedia" + assert exc.value.field == "file" + + +def test_sdk_maps_structured_unsupported_media_error(): + with pytest.raises(ValidationError) as exc: + raise_for_error( + { + "detail": { + "error": "UnsupportedNotebookMedia", + "message": "Only server-detected image, audio, and video files are supported.", + } + }, + 415, + ) + + assert exc.value.code == "UnsupportedNotebookMedia" + assert exc.value.status_code == 415 + + +def test_get_uses_current_get_endpoint_and_returns_revision_and_ordered_media(monkeypatch): + resource = NotebooksResource(_Client()) + requested = [] + monkeypatch.setattr( + resource, + "_get", + lambda path: ( + requested.append(path) + or _Response( + { + "id": "note-1", + "token_id": "token-1", + "note_name": "Evidence", + "text_content": "", + "content": "legacy content must not replace an explicit empty value", + "is_public": True, + "credential_val": None, + "revision": 7, + "media": [ + { + "id": "media-2", + "filename": "second.wav", + "media_type": "audio/wav", + "file_size_bytes": 22, + "display_order": 1, + }, + { + "id": "media-1", + "filename": "first.png", + "media_type": "image/png", + "content_type": "legacy/type", + "file_size_bytes": 11, + "file_size": 999, + "display_order": 0, + }, + ], + } + ) + ), + ) + monkeypatch.setattr( + resource, + "_patch", + lambda *_args, **_kwargs: pytest.fail("notebooks.get() must not mutate"), + ) + + notebook = resource.get("note-1") + + assert requested == ["/watermark-notes/note-1"] + assert notebook.revision == 7 + assert notebook.text_content == "" + assert notebook.media is not None + assert [media.id for media in notebook.media] == ["media-1", "media-2"] + assert notebook.media[0].media_type == "image/png" + assert notebook.media[0].file_size_bytes == 11 + + +def test_update_preserves_visibility_credential_name_and_ordered_media(monkeypatch): + current_media = [ + _media("media-2", "second.wav", "audio/wav", 1, file_size_bytes=22), + _media("media-1", "first.png", "image/png", 0, file_size_bytes=11), + ] + committed_media = [current_media[1], current_media[0]] + commits = _Commits(_commit_response(committed_media)) + resource = NotebooksResource(_Client(commits)) + monkeypatch.setattr( + resource, + "_get", + lambda _path: _Response(_projection(media=current_media)), + ) + monkeypatch.setattr( + resource, + "_patch", + lambda *_args, **_kwargs: pytest.fail("legacy update must not be used"), + ) + + updated = resource.update("note-1", text_content="Updated text") + + assert updated.revision == 8 + assert updated.note_name == "Private Evidence" + assert updated.is_public is False + assert updated.credential_val == "secret" + assert [item.id for item in updated.media or []] == ["media-1", "media-2"] + _, commit_kwargs = commits.commit_calls[0] + assert commit_kwargs["desired_state"].to_dict() == { + "note_name": "Private Evidence", + "text_content": "Updated text", + "is_public": False, + "credential_val": "secret", + "media": [ + {"display_order": 0, "media_id": "media-1", "filename": "first.png"}, + {"display_order": 1, "media_id": "media-2", "filename": "second.wav"}, + ], + } + + +def test_delete_media_removes_only_selected_media(monkeypatch): + current_media = [ + _media("media-1", "first.png", "image/png", 0), + _media("media-2", "second.wav", "audio/wav", 1), + _media("media-3", "third.mp4", "video/mp4", 2), + ] + committed_media = [ + current_media[0], + {**current_media[2], "display_order": 1}, + ] + commits = _Commits(_commit_response(committed_media)) + resource = NotebooksResource(_Client(commits)) + monkeypatch.setattr( + resource, + "_get", + lambda _path: _Response(_projection(media=current_media)), + ) + monkeypatch.setattr( + resource, + "_delete", + lambda *_args, **_kwargs: pytest.fail("legacy delete must not be used"), + ) + + assert resource.delete_media("media-2", note_id="note-1") is True + + _, commit_kwargs = commits.commit_calls[0] + desired = commit_kwargs["desired_state"].to_dict() + assert desired["note_name"] == "Private Evidence" + assert desired["text_content"] == "Authoritative text" + assert desired["is_public"] is False + assert desired["credential_val"] == "secret" + assert desired["media"] == [ + {"display_order": 0, "media_id": "media-1", "filename": "first.png"}, + {"display_order": 1, "media_id": "media-3", "filename": "third.mp4"}, + ] + + +def test_delete_media_requires_note_id_before_any_request(): + commits = _Commits() + resource = NotebooksResource(_Client(commits)) + + with pytest.raises(ValidationError) as exc: + resource.delete_media("media-1") + + assert exc.value.code == "InvalidNotebookMediaRequest" + assert exc.value.field == "note_id" + assert commits.begin_calls == [] + + +def test_write_rejects_incomplete_projection_instead_of_committing(monkeypatch): + incomplete = _projection() + incomplete.pop("credential_val") + commits = _Commits() + resource = NotebooksResource(_Client(commits)) + monkeypatch.setattr(resource, "_get", lambda _path: _Response(incomplete)) + monkeypatch.setattr( + resource, + "_patch", + lambda *_args, **_kwargs: pytest.fail("partial legacy update must not run"), + ) + + with pytest.raises(OfSpectrumError) as exc: + resource.update("note-1", text_content="unsafe") + + assert exc.value.code == "InvalidNotebookProjectionResponse" + assert commits.begin_calls == [] + + +@pytest.mark.parametrize( + "disabled_code", + [ + "immutable_writes_disabled", + "NotebookImmutableWritesDisabled", + "IMMUTABLE_WRITES_DISABLED", + ], +) +def test_explicit_immutable_disabled_response_uses_legacy_update( + monkeypatch, disabled_code +): + disabled = OfSpectrumError( + "Immutable writes are disabled", + code=disabled_code, + status_code=409, + ) + commits = _Commits(begin_error=disabled) + resource = NotebooksResource(_Client(commits)) + monkeypatch.setattr(resource, "_get", lambda _path: _Response(_projection())) + recorded = {} + + def patch(path, *, data): + recorded.update(path=path, data=data) + return _Response({**_projection(), "text_content": "Legacy update"}) + + monkeypatch.setattr(resource, "_patch", patch) + + updated = resource.update("note-1", text_content="Legacy update") + + assert updated.text_content == "Legacy update" + assert recorded == { + "path": "/watermark-notes/note-1", + "data": {"text_content": "Legacy update"}, + } + + +def test_generic_atomic_failure_never_falls_back_to_legacy_update(monkeypatch): + commits = _Commits( + begin_error=ServiceUnavailableError( + "Unavailable", + code="NotebookCommitUnavailable", + status_code=503, + ) + ) + resource = NotebooksResource(_Client(commits)) + monkeypatch.setattr(resource, "_get", lambda _path: _Response(_projection())) + monkeypatch.setattr( + resource, + "_patch", + lambda *_args, **_kwargs: pytest.fail("generic failures must not fall back"), + ) + + with pytest.raises(ServiceUnavailableError): + resource.update("note-1", text_content="Do not write partially") + + +def test_create_and_delete_notebook_endpoints_are_unchanged(monkeypatch): + resource = NotebooksResource(_Client()) + calls = [] + monkeypatch.setattr( + resource, + "_post", + lambda path, *, data: calls.append(("POST", path, data)) + or _Response( + { + "id": "note-1", + "token_id": "token-1", + "note_name": "Evidence", + "is_public": True, + } + ), + ) + monkeypatch.setattr( + resource, + "_delete", + lambda path: calls.append(("DELETE", path, None)) + or _Response({"deleted_note_id": "note-1"}), + ) + + created = resource.create("token-1", "Evidence") + deleted = resource.delete("note-1") + + assert created.id == "note-1" + assert deleted is True + assert calls[0][0:2] == ("POST", "/watermark-notes") + assert calls[1] == ("DELETE", "/watermark-notes/note-1", None) + + +def test_notebook_summary_distinguishes_omitted_media_from_empty_current_media(): + summary = Notebook.from_dict( + {"id": "note-1", "token_id": "token-1", "note_name": "Summary"} + ) + current = Notebook.from_dict( + { + "id": "note-1", + "token_id": "token-1", + "note_name": "Current", + "media": [], + "revision": 0, + } + ) + + assert summary.media is None + assert current.media == [] + assert current.revision == 0 diff --git a/tests/test_tokens.py b/tests/test_tokens.py index 8b99949..e5f0d11 100644 --- a/tests/test_tokens.py +++ b/tests/test_tokens.py @@ -1,6 +1,14 @@ +from typing import get_type_hints + import pytest -from ofspectrum.models.token import AiAuthTag, Token, TokenCreateParams, TokenUpdateParams +from ofspectrum.models.token import ( + UNSET, + AiAuthTag, + Token, + TokenCreateParams, + TokenUpdateParams, +) from ofspectrum.resources.tokens import TokensResource @@ -35,12 +43,72 @@ def test_token_parses_ai_auth_fields(): assert token.max_private_notes == 1 -def test_token_maps_unlimited_private_notebooks_to_none(): +def test_token_parses_notebook_settings_without_coercing_invalid_values(): + token = Token.from_dict( + { + "id": "token-1", + "name": "Voice", + "token_type": "pro", + "version_control_override": None, + "storage_auto_expand_override": False, + "version_control_enabled": True, + "storage_auto_expand_enabled": "invalid", + "storage_entitlement_bytes": "1024", + } + ) + + assert token.version_control_override is None + assert token.storage_auto_expand_override is False + assert token.version_control_enabled is True + assert token.storage_auto_expand_enabled is False + assert token.storage_entitlement_bytes == 1024 + + +def test_token_exposes_pro_private_notebook_limit(): token = Token.from_dict( { "id": "token-1", "name": "Voice", "token_type": "pro", + "max_private_notes": 5, + } + ) + + assert token.max_private_notes == 5 + + +def test_token_exposes_standard_zero_new_private_notebook_limit(): + token = Token.from_dict( + { + "id": "token-standard", + "name": "Standard Voice", + "token_type": "standard", + "max_private_notes": 0, + } + ) + + assert token.max_private_notes == 0 + + +def test_token_exposes_enterprise_private_notebook_limit(): + token = Token.from_dict( + { + "id": "token-1", + "name": "Enterprise Voice", + "token_type": "enterprise", + "max_private_notes": 10, + } + ) + + assert token.max_private_notes == 10 + + +def test_token_preserves_legacy_unlimited_private_notebook_compatibility(): + token = Token.from_dict( + { + "id": "token-legacy", + "name": "Legacy Token", + "token_type": "enterprise", "max_private_notes": -1, } ) @@ -92,6 +160,101 @@ def test_update_params_include_upgrade_and_ai_auth_configuration(): } +@pytest.mark.parametrize("value", [None, False, True]) +@pytest.mark.parametrize("params_type", [TokenCreateParams, TokenUpdateParams]) +def test_token_params_preserve_explicit_override_states(params_type, value): + params = params_type( + name="Voice", + version_control_override=value, + storage_auto_expand_override=value, + ) + + assert params.to_dict()["version_control_override"] is value + assert params.to_dict()["storage_auto_expand_override"] is value + + +@pytest.mark.parametrize("params_type", [TokenCreateParams, TokenUpdateParams]) +def test_token_params_omit_unset_overrides(params_type): + params = params_type(name="Voice") + + assert params.version_control_override is UNSET + assert params.storage_auto_expand_override is UNSET + assert "version_control_override" not in params.to_dict() + assert "storage_auto_expand_override" not in params.to_dict() + + +@pytest.mark.parametrize("params_type", [TokenCreateParams, TokenUpdateParams]) +def test_token_params_reject_non_boolean_overrides(params_type): + with pytest.raises(ValueError, match="version_control_override"): + params_type(name="Voice", version_control_override="true") + + +def test_token_override_annotations_are_not_any(): + create_hints = get_type_hints(TokenCreateParams) + update_hints = get_type_hints(TokenUpdateParams) + + assert create_hints["version_control_override"] is not object + assert update_hints["storage_auto_expand_override"] is not object + + +def test_pro_creation_requires_an_explicit_integer_public_key(): + with pytest.raises(ValueError, match="public_key is required"): + TokenCreateParams(name="Pro", token_type="pro") + + with pytest.raises(ValueError, match="public_key must be an integer"): + TokenCreateParams(name="Pro", token_type="pro", public_key=True) + + +def test_token_resource_sends_both_explicit_overrides(monkeypatch): + resource = TokensResource(client=None) + request = {} + + def fake_post(path, json): + request.update(path=path, json=json) + return _Response( + { + "id": "token-1", + "name": "Pro", + "token_type": "pro", + "public_key": json["public_key"], + } + ) + + monkeypatch.setattr(resource, "_post", fake_post) + + token = resource.create( + "Pro", + "pro", + 123, + version_control_override=None, + storage_auto_expand_override=True, + ) + + assert token.public_key == 123 + assert request == { + "path": "/tokens/", + "json": { + "name": "Pro", + "token_type": "pro", + "public_key": 123, + "version_control_override": None, + "storage_auto_expand_override": True, + }, + } + + +def test_token_resource_rejects_pro_without_request(monkeypatch): + resource = TokensResource(client=None) + monkeypatch.setattr( + resource, + "_post", + lambda *_args, **_kwargs: pytest.fail("invalid Pro token must not be sent"), + ) + + with pytest.raises(ValueError, match="public_key is required"): + resource.create("Pro", "pro") + + def test_list_ai_auth_tags_returns_models(monkeypatch): resource = TokensResource(client=None) monkeypatch.setattr( diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..6b11f1a --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,31 @@ +from pathlib import Path + +import ofspectrum +from ofspectrum import __version__ + + +def test_package_versions_are_1_2_0(): + pyproject = (Path(__file__).parents[1] / "pyproject.toml").read_text( + encoding="utf-8" + ) + + assert __version__ == "1.2.0" + assert 'version = "1.2.0"' in pyproject + + +def test_1_2_0_public_models_and_exceptions_are_exported(): + expected_exports = { + "ConflictError", + "PaymentRequiredError", + "NotebookDesiredMedia", + "NotebookDesiredState", + "NotebookSaveSession", + "NotebookSaveSessionStatus", + "NotebookSaveSessionCancellation", + "NotebookStagedUpload", + "NotebookCommitMedia", + "NotebookCommitResponse", + } + + assert expected_exports <= set(ofspectrum.__all__) + assert all(hasattr(ofspectrum, name) for name in expected_exports)