nohead 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nohead/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """The official Python SDK for the Nohead API.
2
+
3
+ from nohead import Nohead
4
+
5
+ nohead = Nohead() # NOHEAD_API_KEY
6
+ for post in nohead.records.list("posts", filter={"status": "published"}):
7
+ print(post.data["title"])
8
+ """
9
+
10
+ from . import models, params, types, webhooks
11
+ from ._async._nohead import AsyncNohead
12
+ from ._base import DEFAULT_BASE_URL, NoheadWarning
13
+ from ._errors import (
14
+ APIConnectionError,
15
+ APIError,
16
+ APITimeoutError,
17
+ AuthenticationError,
18
+ AuthorizationError,
19
+ ConflictError,
20
+ InternalServerError,
21
+ InvalidRequestError,
22
+ NoheadError,
23
+ NotFoundError,
24
+ PlanLimitExceededError,
25
+ PreconditionFailedError,
26
+ RateLimitError,
27
+ ServiceUnavailableError,
28
+ UploadError,
29
+ ValidationError,
30
+ WebhookVerificationError,
31
+ )
32
+ from ._pagination import AsyncPage, AsyncPaginator, Page
33
+ from ._sync._nohead import Nohead
34
+ from ._uploads import Uploadable
35
+ from ._version import __version__
36
+ from .models import Asset, Collection, Field, Record, Schema, Webhook
37
+ from .webhooks import WebhookEvent
38
+
39
+ __all__ = [
40
+ "DEFAULT_BASE_URL",
41
+ "APIConnectionError",
42
+ "APIError",
43
+ "APITimeoutError",
44
+ "Asset",
45
+ "AsyncNohead",
46
+ "AsyncPage",
47
+ "AsyncPaginator",
48
+ "AuthenticationError",
49
+ "AuthorizationError",
50
+ "Collection",
51
+ "ConflictError",
52
+ "Field",
53
+ "InternalServerError",
54
+ "InvalidRequestError",
55
+ "Nohead",
56
+ "NoheadError",
57
+ "NoheadWarning",
58
+ "NotFoundError",
59
+ "Page",
60
+ "PlanLimitExceededError",
61
+ "PreconditionFailedError",
62
+ "RateLimitError",
63
+ "Record",
64
+ "Schema",
65
+ "ServiceUnavailableError",
66
+ "UploadError",
67
+ "Uploadable",
68
+ "ValidationError",
69
+ "Webhook",
70
+ "WebhookEvent",
71
+ "WebhookVerificationError",
72
+ "__version__",
73
+ "models",
74
+ "params",
75
+ "types",
76
+ "webhooks",
77
+ ]
File without changes
@@ -0,0 +1,184 @@
1
+ # pyright: reportPrivateUsage=false
2
+ """The HTTP core of the async client: requests, retries, errors and pages.
3
+
4
+ src/nohead/_sync is generated from this package by scripts/unasync.py: edit
5
+ the async code and run `uv run python scripts/unasync.py`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from collections.abc import Mapping
12
+ from typing import Any, TypeVar
13
+
14
+ import httpx
15
+
16
+ from .. import _base
17
+ from .._errors import APIConnectionError, APITimeoutError, NoheadError, api_error
18
+ from .._generated.operations import OPERATIONS
19
+ from .._pagination import AsyncPage, AsyncPaginator, start_async_pages
20
+ from .._uploads import UploadSource, aiter_file
21
+ from ..models import ListMeta, Principal
22
+ from ..types import IfMatch
23
+
24
+ T = TypeVar("T")
25
+ M = TypeVar("M")
26
+
27
+
28
+ class AsyncAPIClient:
29
+ def __init__(self, settings: _base.Settings, http: httpx.AsyncClient | None) -> None:
30
+ self.settings = settings
31
+ self.http = http or httpx.AsyncClient()
32
+ self.owns_http = http is None
33
+ self._project_id = settings.project_id
34
+
35
+ def copy(self, settings: _base.Settings) -> AsyncAPIClient:
36
+ """The same connection pool and project, other settings."""
37
+ client = AsyncAPIClient(settings, self.http)
38
+ client.owns_http = False
39
+ client._project_id = settings.project_id or self._project_id
40
+ return client
41
+
42
+ async def close(self) -> None:
43
+ if self.owns_http:
44
+ await self.http.aclose()
45
+
46
+ async def project_id(self) -> str:
47
+ """The API key's project, from `GET /v1/me` the first time."""
48
+ if self._project_id is None:
49
+ me = await self.request("me_get", cast=Principal)
50
+ if me.api_key is None:
51
+ raise NoheadError("The credentials are not a project API key")
52
+ self._project_id = me.api_key.project_id
53
+ return self._project_id
54
+
55
+ async def request(
56
+ self,
57
+ operation: str,
58
+ *,
59
+ cast: type[T] | Any,
60
+ path: Mapping[str, str | int] | None = None,
61
+ query: Mapping[str, Any] | None = None,
62
+ body: Any = None,
63
+ idempotency_key: str | None = None,
64
+ change_note: str | None = None,
65
+ if_match: IfMatch | None = None,
66
+ ) -> T:
67
+ data = await self.send(
68
+ operation,
69
+ path=path,
70
+ query=query,
71
+ body=body,
72
+ idempotency_key=idempotency_key,
73
+ change_note=change_note,
74
+ if_match=if_match,
75
+ )
76
+ return _base.validate(cast, data)
77
+
78
+ def paginate(
79
+ self,
80
+ operation: str,
81
+ *,
82
+ item: type[T],
83
+ meta: type[M] = ListMeta,
84
+ path: Mapping[str, str | int] | None = None,
85
+ query: Mapping[str, Any] | None = None,
86
+ ) -> AsyncPaginator[T, Any]:
87
+ """A list operation as pages of `item`."""
88
+ params = dict(query or {})
89
+
90
+ async def fetch(cursor: str | None) -> AsyncPage[T, Any]:
91
+ data = await self.send(operation, path=path, query={**params, "cursor": cursor})
92
+ items: list[T] = [_base.validate(item, raw) for raw in data["data"]]
93
+ return AsyncPage(items, _base.validate(meta, data["meta"]), fetch)
94
+
95
+ return start_async_pages(fetch, params.pop("cursor", None))
96
+
97
+ async def send(
98
+ self,
99
+ operation: str,
100
+ *,
101
+ path: Mapping[str, str | int] | None = None,
102
+ query: Mapping[str, Any] | None = None,
103
+ body: Any = None,
104
+ idempotency_key: str | None = None,
105
+ change_note: str | None = None,
106
+ if_match: IfMatch | None = None,
107
+ ) -> Any:
108
+ """Sends one operation, retrying as the README describes; returns the JSON."""
109
+ method, template = OPERATIONS[operation]
110
+ values = dict(path or {})
111
+ if "{project_id}" in template and "project_id" not in values:
112
+ values["project_id"] = await self.project_id()
113
+ settings = self.settings
114
+ url = _base.url(settings, template, values)
115
+ headers = _base.headers(
116
+ settings,
117
+ method,
118
+ has_body=body is not None,
119
+ idempotency_key=idempotency_key,
120
+ change_note=change_note,
121
+ if_match=if_match,
122
+ )
123
+ content = None if body is None else _base.encode(body)
124
+ params = _base.query(query)
125
+
126
+ attempt = 0
127
+ while True:
128
+ try:
129
+ response = await self.http.request(
130
+ method,
131
+ url,
132
+ params=params,
133
+ headers=headers,
134
+ content=content,
135
+ timeout=settings.timeout,
136
+ )
137
+ except httpx.TransportError as error:
138
+ failure = _connection_error(error, settings.timeout, url)
139
+ if attempt >= settings.max_retries:
140
+ raise failure from error
141
+ await asyncio.sleep(_base.backoff(attempt))
142
+ attempt += 1
143
+ continue
144
+
145
+ _base.warn(settings, operation, response)
146
+ if response.is_success:
147
+ return _base.parse(response)
148
+ error = api_error(response.status_code, _base.parse(response), response.headers)
149
+ delay = _base.retry_delay(error, attempt, settings.max_retries)
150
+ if delay is None:
151
+ raise error
152
+ await asyncio.sleep(delay)
153
+ attempt += 1
154
+
155
+ async def put_upload(
156
+ self, url: str, method: str, headers: Mapping[str, str], source: UploadSource
157
+ ) -> httpx.Response:
158
+ """Sends a file to a presigned storage URL (no API credentials)."""
159
+ settings = self.settings
160
+ retries = settings.max_retries if source.replayable else 0
161
+ attempt = 0
162
+ while True:
163
+ try:
164
+ response = await self.http.request(
165
+ method,
166
+ url,
167
+ headers={**headers, "Content-Length": str(source.byte_size)},
168
+ content=aiter_file(source),
169
+ timeout=settings.timeout,
170
+ )
171
+ if response.status_code not in _base.RETRYABLE_STATUSES or attempt >= retries:
172
+ return response
173
+ except httpx.TransportError as error:
174
+ if attempt >= retries:
175
+ raise _connection_error(error, settings.timeout, url) from error
176
+ await asyncio.sleep(_base.backoff(attempt))
177
+ attempt += 1
178
+
179
+
180
+ def _connection_error(error: httpx.TransportError, timeout: float, url: str) -> NoheadError:
181
+ host = httpx.URL(url).host
182
+ if isinstance(error, httpx.TimeoutException):
183
+ return APITimeoutError(f"The request to {host} timed out after {timeout} s")
184
+ return APIConnectionError(f"Could not reach {host}: {error}")
@@ -0,0 +1,147 @@
1
+ # pyright: reportPrivateUsage=false
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Mapping, Sequence
5
+ from types import TracebackType
6
+ from typing import Literal, Self
7
+
8
+ import httpx
9
+
10
+ from .. import _base
11
+ from .._pagination import AsyncPaginator
12
+ from ..models import Record, SearchResultsMeta
13
+ from ._client import AsyncAPIClient
14
+ from .resources.assets import AsyncAssets
15
+ from .resources.other import AsyncAuditEvents, AsyncFeatureFlags, AsyncHealth, AsyncMe
16
+ from .resources.records import AsyncRecords
17
+ from .resources.schema import AsyncCollections, AsyncFields, AsyncMigrations
18
+ from .resources.webhooks import AsyncWebhooks
19
+
20
+
21
+ class AsyncNohead:
22
+ """An async client for the Nohead API, authenticated with a project API key.
23
+
24
+ async with AsyncNohead() as nohead: # NOHEAD_API_KEY
25
+ async for post in nohead.records.list("posts"):
26
+ print(post.data["title"])
27
+ """
28
+
29
+ records: AsyncRecords
30
+ collections: AsyncCollections
31
+ fields: AsyncFields
32
+ migrations: AsyncMigrations
33
+ assets: AsyncAssets
34
+ webhooks: AsyncWebhooks
35
+ audit_events: AsyncAuditEvents
36
+ feature_flags: AsyncFeatureFlags
37
+ me: AsyncMe
38
+ health: AsyncHealth
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ api_key: str | None = None,
44
+ base_url: str | None = None,
45
+ project_id: str | None = None,
46
+ max_retries: int = 2,
47
+ timeout: float = 60.0,
48
+ headers: Mapping[str, str] | None = None,
49
+ warnings: bool = True,
50
+ http_client: httpx.AsyncClient | None = None,
51
+ ) -> None:
52
+ """
53
+ Args:
54
+ api_key: A project API key (`sk_live_...`). Defaults to NOHEAD_API_KEY.
55
+ base_url: Defaults to NOHEAD_API_URL, else https://api.nohead.io.
56
+ project_id: The key's project. Looked up once with GET /v1/me when omitted.
57
+ max_retries: Retries of failed requests (see the README, "Retries").
58
+ timeout: Seconds per attempt.
59
+ headers: Headers added to every request.
60
+ warnings: Warn about deprecated operations and plan usage (once each).
61
+ http_client: An httpx client to send requests with (proxies, transports).
62
+ """
63
+ settings = _base.settings(
64
+ api_key=api_key,
65
+ base_url=base_url,
66
+ project_id=project_id,
67
+ max_retries=max_retries,
68
+ timeout=timeout,
69
+ headers=headers,
70
+ warnings=warnings,
71
+ )
72
+ self._setup(AsyncAPIClient(settings, http_client))
73
+
74
+ def _setup(self, client: AsyncAPIClient) -> None:
75
+ self._client = client
76
+ self.records = AsyncRecords(client)
77
+ self.collections = AsyncCollections(client)
78
+ self.fields = AsyncFields(client)
79
+ self.migrations = AsyncMigrations(client)
80
+ self.assets = AsyncAssets(client)
81
+ self.webhooks = AsyncWebhooks(client)
82
+ self.audit_events = AsyncAuditEvents(client)
83
+ self.feature_flags = AsyncFeatureFlags(client)
84
+ self.me = AsyncMe(client)
85
+ self.health = AsyncHealth(client)
86
+
87
+ def with_options(
88
+ self,
89
+ *,
90
+ timeout: float | None = None,
91
+ max_retries: int | None = None,
92
+ headers: Mapping[str, str] | None = None,
93
+ ) -> Self:
94
+ """A client with other settings that shares this one's connections:
95
+ `nohead.with_options(timeout=5).records.get(id)`."""
96
+ current = self._client.settings
97
+ settings = _base.Settings(
98
+ api_key=current.api_key,
99
+ base_url=current.base_url,
100
+ project_id=current.project_id,
101
+ max_retries=current.max_retries if max_retries is None else max_retries,
102
+ timeout=current.timeout if timeout is None else timeout,
103
+ headers={**current.headers, **(headers or {})},
104
+ warnings=current.warnings,
105
+ )
106
+ copy = object.__new__(type(self))
107
+ copy._setup(self._client.copy(settings))
108
+ return copy
109
+
110
+ def search(
111
+ self,
112
+ query: str,
113
+ *,
114
+ collections: Sequence[str] | None = None,
115
+ status: Literal["draft", "published"] | None = None,
116
+ limit: int | None = None,
117
+ cursor: str | None = None,
118
+ ) -> AsyncPaginator[Record, SearchResultsMeta]:
119
+ """Full-text search across the key's project (or some of its collections),
120
+ most relevant first, through the first 1,000 hits."""
121
+ return self._client.paginate(
122
+ "projects_search",
123
+ item=Record,
124
+ meta=SearchResultsMeta,
125
+ query={
126
+ "q": query,
127
+ "collections": collections,
128
+ "filter": {"status": status},
129
+ "limit": limit,
130
+ "cursor": cursor,
131
+ },
132
+ )
133
+
134
+ async def close(self) -> None:
135
+ """Closes the connections (unless an `http_client` was passed in)."""
136
+ await self._client.close()
137
+
138
+ async def __aenter__(self) -> Self:
139
+ return self
140
+
141
+ async def __aexit__(
142
+ self,
143
+ exc_type: type[BaseException] | None,
144
+ exc: BaseException | None,
145
+ tb: TracebackType | None,
146
+ ) -> None:
147
+ await self.close()
File without changes
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from .._client import AsyncAPIClient
4
+
5
+
6
+ class AsyncResource:
7
+ def __init__(self, client: AsyncAPIClient) -> None:
8
+ self._client = client
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from typing_extensions import Unpack
6
+
7
+ from ..._errors import UploadError
8
+ from ..._pagination import AsyncPaginator
9
+ from ..._uploads import Uploadable, open_upload
10
+ from ...models import Asset, AssetUpload, DownloadUrl, ImageUrl
11
+ from ...params import AssetUploadCreate
12
+ from ._resource import AsyncResource
13
+
14
+
15
+ class AsyncAssets(AsyncResource):
16
+ """Files of the key's project. `asset` is an asset ID (`ast_...`)."""
17
+
18
+ async def upload(
19
+ self,
20
+ file: Uploadable,
21
+ *,
22
+ filename: str | None = None,
23
+ content_type: str | None = None,
24
+ byte_size: int | None = None,
25
+ idempotency_key: str | None = None,
26
+ ) -> Asset:
27
+ """Uploads a file and returns the `ready` asset: creates the upload, sends the
28
+ bytes straight to storage, then completes it (which checks the file).
29
+
30
+ `file` is bytes, a path, or a file opened in binary mode. Raises UploadError if
31
+ storage refuses the bytes, and ValidationError if the file fails the checks.
32
+ """
33
+ source = open_upload(file, filename, content_type, byte_size)
34
+ try:
35
+ created = await self.create_upload(
36
+ filename=source.filename,
37
+ content_type=source.content_type,
38
+ byte_size=source.byte_size,
39
+ idempotency_key=idempotency_key,
40
+ )
41
+ response = await self._client.put_upload(
42
+ created.upload.url, created.upload.method, created.upload.headers, source
43
+ )
44
+ finally:
45
+ source.close()
46
+ if not response.is_success:
47
+ raise UploadError(
48
+ f"Storage refused the upload of {source.filename} ({response.status_code})",
49
+ response.status_code,
50
+ )
51
+ return await self.complete(created.asset.id)
52
+
53
+ async def create_upload(
54
+ self, *, idempotency_key: str | None = None, **params: Unpack[AssetUploadCreate]
55
+ ) -> AssetUpload:
56
+ """The first step of an upload: a `pending` asset and a presigned URL to PUT the
57
+ bytes to (for example from a browser). Then `complete` it."""
58
+ return await self._client.request(
59
+ "assets_upload", cast=AssetUpload, body=params, idempotency_key=idempotency_key
60
+ )
61
+
62
+ async def complete(self, asset: str, *, idempotency_key: str | None = None) -> Asset:
63
+ """Checks an uploaded file and marks the asset `ready`."""
64
+ return await self._client.request(
65
+ "assets_complete",
66
+ cast=Asset,
67
+ path={"asset_id": asset},
68
+ idempotency_key=idempotency_key,
69
+ )
70
+
71
+ def list(
72
+ self, *, deleted: bool | None = None, limit: int | None = None, cursor: str | None = None
73
+ ) -> AsyncPaginator[Asset]:
74
+ return self._client.paginate(
75
+ "assets_list",
76
+ item=Asset,
77
+ query={"deleted": deleted, "limit": limit, "cursor": cursor},
78
+ )
79
+
80
+ async def get(self, asset: str) -> Asset:
81
+ return await self._client.request("assets_get", cast=Asset, path={"asset_id": asset})
82
+
83
+ async def delete(self, asset: str, *, idempotency_key: str | None = None) -> Asset:
84
+ """Soft-deletes the asset; the file is purged after 30 days."""
85
+ return await self._client.request(
86
+ "assets_delete",
87
+ cast=Asset,
88
+ path={"asset_id": asset},
89
+ idempotency_key=idempotency_key,
90
+ )
91
+
92
+ async def restore(self, asset: str, *, idempotency_key: str | None = None) -> Asset:
93
+ return await self._client.request(
94
+ "assets_restore",
95
+ cast=Asset,
96
+ path={"asset_id": asset},
97
+ idempotency_key=idempotency_key,
98
+ )
99
+
100
+ async def image_url(
101
+ self,
102
+ asset: str,
103
+ *,
104
+ width: int | None = None,
105
+ height: int | None = None,
106
+ fit: Literal["contain", "cover", "fill"] | None = None,
107
+ format: Literal["original", "webp", "avif", "jpeg", "png"] | None = None,
108
+ quality: int | None = None,
109
+ ) -> ImageUrl:
110
+ """A signed, cacheable URL of an image rendition (`url`)."""
111
+ return await self._client.request(
112
+ "assets_image_url",
113
+ cast=ImageUrl,
114
+ path={"asset_id": asset},
115
+ query={
116
+ "width": width,
117
+ "height": height,
118
+ "fit": fit,
119
+ "format": format,
120
+ "quality": quality,
121
+ },
122
+ )
123
+
124
+ async def download_url(self, asset: str) -> DownloadUrl:
125
+ """A 15-minute link to download the original file (`url`)."""
126
+ return await self._client.request(
127
+ "assets_download_url", cast=DownloadUrl, path={"asset_id": asset}
128
+ )
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from ..._pagination import AsyncPaginator
6
+ from ...models import AuditEvent, Principal
7
+ from ...models import FeatureFlags as FeatureFlagValues
8
+ from ...models import Health as HealthStatus
9
+ from ...types import RecordFilter
10
+ from ._resource import AsyncResource
11
+
12
+
13
+ class AsyncAuditEvents(AsyncResource):
14
+ """The project's activity log, newest first. Needs the `audit:read` scope."""
15
+
16
+ def list(
17
+ self,
18
+ *,
19
+ filter: RecordFilter | None = None,
20
+ sort: Literal["id", "-id"] | None = None,
21
+ limit: int | None = None,
22
+ cursor: str | None = None,
23
+ ) -> AsyncPaginator[AuditEvent]:
24
+ """`filter` takes `action` (exact, or a `record.*` prefix), `resource_type`,
25
+ `resource_id`, `actor_type`, `actor_id`, `since` and `until`."""
26
+ return self._client.paginate(
27
+ "audit_events_list_for_project",
28
+ item=AuditEvent,
29
+ query={"filter": filter, "sort": sort, "limit": limit, "cursor": cursor},
30
+ )
31
+
32
+
33
+ class AsyncFeatureFlags(AsyncResource):
34
+ async def list(self) -> FeatureFlagValues:
35
+ """Flags evaluated for the key's project."""
36
+ return await self._client.request("feature_flags_list", cast=FeatureFlagValues)
37
+
38
+
39
+ class AsyncMe(AsyncResource):
40
+ async def get(self) -> Principal:
41
+ """The API key: its project, scopes, `published_only` and expiry."""
42
+ return await self._client.request("me_get", cast=Principal)
43
+
44
+
45
+ class AsyncHealth(AsyncResource):
46
+ async def check(self) -> HealthStatus:
47
+ return await self._client.request("health_check", cast=HealthStatus)