diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13d4dbb..e7e63bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: # Check version synchronization version-check: @@ -17,7 +20,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.14" + python-version: "3.12" - name: Check version sync run: python scripts/sync-versions.py --check @@ -32,15 +35,18 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.14" + python-version: "3.12" - name: Check feature parity run: python scripts/check-feature-parity.py # TypeScript CI typescript: - name: TypeScript + name: TypeScript (Node ${{ matrix.node-version }}) runs-on: ubuntu-latest + strategy: + matrix: + node-version: ["18", "20", "22"] defaults: run: working-directory: ts @@ -50,7 +56,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: ${{ matrix.node-version }} cache: "npm" cache-dependency-path: ts/package-lock.json @@ -72,7 +78,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.14"] + python-version: ["3.11", "3.12", "3.13"] defaults: run: working-directory: python @@ -96,7 +102,7 @@ jobs: run: mypy numbersprotocol_capture --ignore-missing-imports - name: Run tests - run: pytest -v + run: pytest -v --cov-fail-under=75 # All checks passed ci-success: diff --git a/python/numbersprotocol_capture/client.py b/python/numbersprotocol_capture/client.py index 529391a..44ffd7b 100644 --- a/python/numbersprotocol_capture/client.py +++ b/python/numbersprotocol_capture/client.py @@ -175,7 +175,7 @@ def _request( *, data: dict[str, Any] | None = None, files: dict[str, Any] | None = None, - json_body: dict[str, Any] | None = None, + json_body: dict[str, Any] | list[Any] | None = None, nid: str | None = None, ) -> dict[str, Any]: """Makes an authenticated API request.""" @@ -445,25 +445,7 @@ def get_history(self, nid: str) -> list[Commit]: params["testnet"] = "true" url = f"{HISTORY_API_URL}?{urlencode(params)}" - - headers = { - "Content-Type": "application/json", - "Authorization": f"token {self._token}", - } - - try: - response = self._client.get(url, headers=headers) - except httpx.RequestError as e: - raise create_api_error(0, f"Network error: {e}", nid) from e - - if not response.is_success: - raise create_api_error( - response.status_code, - "Failed to fetch asset history", - nid, - ) - - data = response.json() + data = self._request("GET", url, nid=nid) return [ Commit( @@ -511,28 +493,7 @@ def get_asset_tree(self, nid: str) -> AssetTree: for c in commits ] - headers = { - "Content-Type": "application/json", - "Authorization": f"token {self._token}", - } - - try: - response = self._client.post( - MERGE_TREE_API_URL, - headers=headers, - json=commit_data, - ) - except httpx.RequestError as e: - raise create_api_error(0, f"Network error: {e}", nid) from e - - if not response.is_success: - raise create_api_error( - response.status_code, - "Failed to merge asset trees", - nid, - ) - - data = response.json() + data = self._request("POST", MERGE_TREE_API_URL, json_body=commit_data, nid=nid) merged = data.get("mergedAssetTree", data) # Map known fields and put the rest in extra @@ -675,51 +636,24 @@ def search_asset( form_data["sample_count"] = str(options.sample_count) # Verify Engine API requires token in Authorization header, not form data - headers = {"Authorization": f"token {self._token}"} - - try: - if files_data: - response = self._client.post( - ASSET_SEARCH_API_URL, - headers=headers, - data=form_data, - files=files_data, - ) - else: - response = self._client.post( - ASSET_SEARCH_API_URL, - headers=headers, - data=form_data, - ) - except httpx.RequestError as e: - raise create_api_error(0, f"Network error: {e}") from e - - if not response.is_success: - message = f"Asset search failed with status {response.status_code}" - try: - error_data = response.json() - message = ( - error_data.get("message") - or error_data.get("error") - or message - ) - except Exception: - pass - raise create_api_error(response.status_code, message) - - data = response.json() + response_data = self._request( + "POST", + ASSET_SEARCH_API_URL, + data=form_data, + files=files_data, + ) # Map response to our type similar_matches = [ SimilarMatch(nid=m["nid"], distance=m["distance"]) - for m in data.get("similar_matches", []) + for m in response_data.get("similar_matches", []) ] return AssetSearchResult( - precise_match=data.get("precise_match", ""), - input_file_mime_type=data.get("input_file_mime_type", ""), + precise_match=response_data.get("precise_match", ""), + input_file_mime_type=response_data.get("input_file_mime_type", ""), similar_matches=similar_matches, - order_id=data.get("order_id", ""), + order_id=response_data.get("order_id", ""), ) def search_nft(self, nid: str) -> NftSearchResult: @@ -740,34 +674,7 @@ def search_nft(self, nid: str) -> NftSearchResult: if not nid: raise ValidationError("nid is required for NFT search") - headers = { - "Content-Type": "application/json", - "Authorization": f"token {self._token}", - } - - try: - response = self._client.post( - NFT_SEARCH_API_URL, - headers=headers, - json={"nid": nid}, - ) - except httpx.RequestError as e: - raise create_api_error(0, f"Network error: {e}", nid) from e - - if not response.is_success: - message = f"NFT search failed with status {response.status_code}" - try: - error_data = response.json() - message = ( - error_data.get("message") - or error_data.get("error") - or message - ) - except Exception: - pass - raise create_api_error(response.status_code, message, nid) - - data = response.json() + data = self._request("POST", NFT_SEARCH_API_URL, json_body={"nid": nid}, nid=nid) # Map response to our type records = [ diff --git a/python/pyproject.toml b/python/pyproject.toml index 5c4a629..ee0b47e 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -8,7 +8,7 @@ version = "0.2.1" description = "Python SDK for Numbers Protocol Capture API" readme = "README.md" license = "MIT" -requires-python = ">=3.14" +requires-python = ">=3.11" authors = [ { name = "Numbers Protocol" } ] @@ -26,7 +26,9 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] @@ -61,7 +63,7 @@ packages = ["numbersprotocol_capture"] [tool.ruff] line-length = 100 -target-version = "py313" # Use py313 until ruff supports py314 +target-version = "py311" [tool.ruff.lint] select = [ @@ -81,7 +83,7 @@ ignore = [ known-first-party = ["numbersprotocol_capture"] [tool.mypy] -python_version = "3.14" +python_version = "3.11" strict = true warn_return_any = true warn_unused_ignores = true diff --git a/scripts/check-feature-parity.py b/scripts/check-feature-parity.py index af32cae..923f332 100644 --- a/scripts/check-feature-parity.py +++ b/scripts/check-feature-parity.py @@ -10,6 +10,7 @@ """ import re +import sys from dataclasses import dataclass from pathlib import Path @@ -140,8 +141,8 @@ def check_py_features() -> None: feature.py_implemented = True -def print_report() -> None: - """Print feature parity report.""" +def print_report() -> bool: + """Print feature parity report. Returns True if full parity achieved.""" print("=" * 60) print("Feature Parity Report") print("=" * 60) @@ -204,15 +205,19 @@ def print_report() -> None: if parity_count == total_features: print("\n✓ Full feature parity achieved!") + return True else: missing = total_features - parity_count print(f"\n✗ {missing} feature(s) missing parity") + return False def main() -> None: check_ts_features() check_py_features() - print_report() + all_parity = print_report() + if not all_parity: + sys.exit(1) if __name__ == "__main__": diff --git a/ts/src/client.ts b/ts/src/client.ts index c86440e..5653704 100644 --- a/ts/src/client.ts +++ b/ts/src/client.ts @@ -142,6 +142,8 @@ export class Capture { private readonly token: string private readonly baseUrl: string private readonly testnet: boolean + private readonly signal?: AbortSignal + private readonly fetchImplementation?: typeof fetch constructor(options: CaptureOptions) { if (!options.token) { @@ -150,6 +152,8 @@ export class Capture { this.token = options.token this.testnet = options.testnet ?? false this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL + this.signal = options.signal + this.fetchImplementation = options.fetchImplementation } /** @@ -158,7 +162,7 @@ export class Capture { private async request( method: string, url: string, - body?: FormData | Record, + body?: FormData | Record | unknown[], nid?: string ): Promise { const headers: Record = { @@ -173,10 +177,12 @@ export class Capture { requestBody = JSON.stringify(body) } - const response = await fetch(url, { + const fetchFn = this.fetchImplementation ?? fetch + const response = await fetchFn(url, { method, headers, body: requestBody, + signal: this.signal, }) if (!response.ok) { @@ -371,19 +377,7 @@ export class Capture { url.searchParams.set('testnet', 'true') } - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `token ${this.token}`, - }, - }) - - if (!response.ok) { - throw createApiError(response.status, 'Failed to fetch asset history', nid) - } - - const data = (await response.json()) as HistoryApiResponse + const data = await this.request('GET', url.toString(), undefined, nid) return data.commits.map((c) => ({ assetTreeCid: c.assetTreeCid, @@ -427,20 +421,12 @@ export class Capture { timestampCreated: c.timestamp, })) - const response = await fetch(MERGE_TREE_API_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `token ${this.token}`, - }, - body: JSON.stringify(commitData), - }) - - if (!response.ok) { - throw createApiError(response.status, 'Failed to merge asset trees', nid) - } - - const data = await response.json() + const data = await this.request>( + 'POST', + MERGE_TREE_API_URL, + commitData, + nid + ) // The API returns { mergedAssetTree: {...}, assetTrees: [...] } // We return the merged tree @@ -517,26 +503,12 @@ export class Capture { } // Verify Engine API requires token in Authorization header, not form data - const response = await fetch(ASSET_SEARCH_API_URL, { - method: 'POST', - headers: { - Authorization: `token ${this.token}`, - }, - body: formData, - }) - - if (!response.ok) { - let message = `Asset search failed with status ${response.status}` - try { - const errorData = await response.json() - message = errorData.message || errorData.error || message - } catch { - // Use default message - } - throw createApiError(response.status, message) - } - - const data = await response.json() + const data = await this.request<{ + precise_match?: string + input_file_mime_type?: string + similar_matches?: Array<{ nid: string; distance: number }> + order_id?: string + }>('POST', ASSET_SEARCH_API_URL, formData) // Map response to our type const similarMatches: SimilarMatch[] = (data.similar_matches || []).map( @@ -573,27 +545,15 @@ export class Capture { throw new ValidationError('nid is required for NFT search') } - const response = await fetch(NFT_SEARCH_API_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `token ${this.token}`, - }, - body: JSON.stringify({ nid }), - }) - - if (!response.ok) { - let message = `NFT search failed with status ${response.status}` - try { - const errorData = await response.json() - message = errorData.message || errorData.error || message - } catch { - // Use default message - } - throw createApiError(response.status, message, nid) - } - - const data = await response.json() + const data = await this.request<{ + records?: Array<{ + token_id: string + contract: string + network: string + owner?: string + }> + order_id?: string + }>('POST', NFT_SEARCH_API_URL, { nid }, nid) // Map response to our type const records: NftRecord[] = (data.records || []).map( diff --git a/ts/src/types.ts b/ts/src/types.ts index bc57312..37ce71a 100644 --- a/ts/src/types.ts +++ b/ts/src/types.ts @@ -18,6 +18,10 @@ export interface CaptureOptions { testnet?: boolean /** Custom base URL (overrides testnet setting) */ baseUrl?: string + /** AbortSignal to cancel in-flight requests */ + signal?: AbortSignal + /** Custom fetch implementation (e.g. for proxies or custom TLS) */ + fetchImplementation?: typeof fetch } /**