forgefile 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.
forgefile/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """Python client for the ForgeFile REST API.
2
+
3
+ from forgefile import ForgeFile
4
+
5
+ with ForgeFile("your-token") as api:
6
+ job = api.jobs.translate("contract.pdf", target_language="es")
7
+ api.files.wait(job.uuid)
8
+ api.files.download(job.uuid, "contract.es.pdf")
9
+ """
10
+
11
+ from .client import ForgeFile
12
+ from .config import ClientConfig
13
+ from .errors import (
14
+ APIError,
15
+ AuthenticationError,
16
+ ForbiddenError,
17
+ ForgeFileError,
18
+ NotFoundError,
19
+ RateLimitError,
20
+ ServerError,
21
+ TransportError,
22
+ ValidationError,
23
+ )
24
+ from .models import Country, CreditsAndPlan, Currency, FileJob, JobStatus, Language
25
+ from .resources import JobTimeoutError
26
+ from .transport import HTTPTransport, HttpxTransport
27
+
28
+ __version__ = "0.1.0"
29
+
30
+ __all__ = [
31
+ "APIError",
32
+ "AuthenticationError",
33
+ "ClientConfig",
34
+ "Country",
35
+ "CreditsAndPlan",
36
+ "Currency",
37
+ "FileJob",
38
+ "ForbiddenError",
39
+ "ForgeFile",
40
+ "ForgeFileError",
41
+ "HTTPTransport",
42
+ "HttpxTransport",
43
+ "JobStatus",
44
+ "JobTimeoutError",
45
+ "Language",
46
+ "NotFoundError",
47
+ "RateLimitError",
48
+ "ServerError",
49
+ "TransportError",
50
+ "ValidationError",
51
+ "__version__",
52
+ ]
forgefile/client.py ADDED
@@ -0,0 +1,88 @@
1
+ """Entry point of the client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import cached_property
6
+ from types import TracebackType
7
+ from typing import Self
8
+
9
+ import httpx
10
+
11
+ from .config import DEFAULT_TIMEOUT, ClientConfig
12
+ from .resources import Account, Files, Jobs, Public, System
13
+ from .transport import HTTPTransport, HttpxTransport
14
+
15
+
16
+ class ForgeFile:
17
+ """Client for the ForgeFile REST API.
18
+
19
+ The token is read from ``FORGEFILE_API_KEY`` when not passed explicitly;
20
+ public endpoints work without one.
21
+
22
+ >>> with ForgeFile() as api:
23
+ ... languages = api.public.languages()
24
+
25
+ Pass ``transport=`` to replace the HTTP layer entirely — the resources
26
+ depend on the :class:`~forgefile.transport.HTTPTransport` protocol, not on
27
+ httpx. Pass ``http_client=`` to keep httpx but supply your own configured
28
+ client; the two are mutually exclusive.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ api_key: str | None = None,
34
+ *,
35
+ base_url: str | None = None,
36
+ timeout: float = DEFAULT_TIMEOUT,
37
+ http_client: httpx.Client | None = None,
38
+ transport: HTTPTransport | None = None,
39
+ ) -> None:
40
+ if transport is not None and http_client is not None:
41
+ raise ValueError(
42
+ "Pass either transport= or http_client=, not both: a custom transport "
43
+ "already decides how requests are sent."
44
+ )
45
+ self.config = ClientConfig.from_env(api_key, base_url, timeout)
46
+ self._transport: HTTPTransport = transport or HttpxTransport(self.config, http_client)
47
+
48
+ @cached_property
49
+ def system(self) -> System:
50
+ """Liveness probes."""
51
+ return System(self._transport)
52
+
53
+ @cached_property
54
+ def public(self) -> Public:
55
+ """Reference data that needs no token."""
56
+ return Public(self._transport)
57
+
58
+ @cached_property
59
+ def account(self) -> Account:
60
+ """Credits, plan and notifications."""
61
+ return Account(self._transport)
62
+
63
+ @cached_property
64
+ def files(self) -> Files:
65
+ """The file lifecycle."""
66
+ return Files(self._transport)
67
+
68
+ @cached_property
69
+ def jobs(self) -> Jobs:
70
+ """Processing endpoints."""
71
+ return Jobs(self._transport)
72
+
73
+ def close(self) -> None:
74
+ """Release the underlying connections."""
75
+ self._transport.close()
76
+
77
+ def __enter__(self) -> Self:
78
+ """Enter a context that closes the client on exit."""
79
+ return self
80
+
81
+ def __exit__(
82
+ self,
83
+ exc_type: type[BaseException] | None,
84
+ exc: BaseException | None,
85
+ tb: TracebackType | None,
86
+ ) -> None:
87
+ """Close the client."""
88
+ self.close()
forgefile/config.py ADDED
@@ -0,0 +1,47 @@
1
+ """Client configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+ DEFAULT_BASE_URL = "https://forgefile.com/api/v1"
9
+ DEFAULT_TIMEOUT = 60.0
10
+
11
+ ENV_API_KEY = "FORGEFILE_API_KEY"
12
+ ENV_BASE_URL = "FORGEFILE_BASE_URL"
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class ClientConfig:
17
+ """Everything the transport needs to reach the API.
18
+
19
+ Immutable on purpose: a configured client cannot drift half-way through
20
+ a session.
21
+ """
22
+
23
+ api_key: str | None = None
24
+ base_url: str = DEFAULT_BASE_URL
25
+ timeout: float = DEFAULT_TIMEOUT
26
+
27
+ @classmethod
28
+ def from_env(
29
+ cls,
30
+ api_key: str | None = None,
31
+ base_url: str | None = None,
32
+ timeout: float = DEFAULT_TIMEOUT,
33
+ ) -> ClientConfig:
34
+ """Build a config, falling back to environment variables."""
35
+ return cls(
36
+ api_key=api_key or os.environ.get(ENV_API_KEY),
37
+ base_url=(base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)).rstrip("/"),
38
+ timeout=timeout,
39
+ )
40
+
41
+ @property
42
+ def headers(self) -> dict[str, str]:
43
+ """Headers sent with every request."""
44
+ headers = {"Accept": "application/json", "User-Agent": "forgefile-python/0.1.0"}
45
+ if self.api_key:
46
+ headers["Authorization"] = f"Bearer {self.api_key}"
47
+ return headers
forgefile/envelope.py ADDED
@@ -0,0 +1,32 @@
1
+ """The API's response envelope.
2
+
3
+ Most endpoints answer ``{"success": ..., "message": ..., "data": {...}}``, but
4
+ the liveness probes answer with a bare object. One function knows the
5
+ difference so nothing else has to.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from typing import Any
12
+
13
+ _ENVELOPE_KEYS = frozenset({"success", "data"})
14
+
15
+
16
+ def unwrap(body: Any) -> Any:
17
+ """Return ``data`` when ``body`` is an envelope, otherwise ``body``."""
18
+ if isinstance(body, Mapping) and body.keys() >= _ENVELOPE_KEYS:
19
+ return body["data"]
20
+ return body
21
+
22
+
23
+ def collection(payload: Any, key: str) -> list[Any]:
24
+ """Return a list from ``payload``, which may nest it under ``key``.
25
+
26
+ ``/public/languages`` returns ``{"languages": [...]}`` while other
27
+ endpoints return the list directly.
28
+ """
29
+ if isinstance(payload, Mapping):
30
+ nested = payload.get(key)
31
+ return list(nested) if isinstance(nested, list) else []
32
+ return list(payload) if isinstance(payload, list) else []
forgefile/errors.py ADDED
@@ -0,0 +1,146 @@
1
+ """Exceptions raised by the client.
2
+
3
+ Every failed response is turned into an exception by :func:`build_error`, which
4
+ is the single place that knows how to read the API's error envelope. Subclasses
5
+ customise their own construction through :meth:`APIError.from_envelope`, so
6
+ adding a status-specific error needs no change to the transport.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from typing import Any, Self
13
+
14
+
15
+ class ForgeFileError(Exception):
16
+ """Base class for every error raised by this client."""
17
+
18
+
19
+ class TransportError(ForgeFileError):
20
+ """The request never produced an HTTP response (network, DNS, timeout)."""
21
+
22
+
23
+ class APIError(ForgeFileError):
24
+ """The API answered with a non-success status."""
25
+
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ *,
30
+ status_code: int,
31
+ error_code: str | None = None,
32
+ context: Mapping[str, Any] | None = None,
33
+ ) -> None:
34
+ super().__init__(message)
35
+ self.message = message
36
+ self.status_code = status_code
37
+ self.error_code = error_code
38
+ self.context: Mapping[str, Any] = context or {}
39
+
40
+ @classmethod
41
+ def from_envelope(
42
+ cls,
43
+ message: str,
44
+ *,
45
+ status_code: int,
46
+ error_code: str | None,
47
+ context: Mapping[str, Any],
48
+ headers: Mapping[str, str],
49
+ ) -> Self:
50
+ """Build the exception. Subclasses may read extra headers."""
51
+ del headers
52
+ return cls(message, status_code=status_code, error_code=error_code, context=context)
53
+
54
+ def __str__(self) -> str:
55
+ """Render as ``[status error_code] message``."""
56
+ return f"[{self.status_code} {self.error_code or 'http_error'}] {self.message}"
57
+
58
+
59
+ class AuthenticationError(APIError):
60
+ """No token was sent, or the token is not valid (HTTP 401)."""
61
+
62
+
63
+ class ForbiddenError(APIError):
64
+ """The token is valid but not allowed to perform this action (HTTP 403)."""
65
+
66
+
67
+ class NotFoundError(APIError):
68
+ """The addressed resource does not exist (HTTP 404)."""
69
+
70
+
71
+ class ValidationError(APIError):
72
+ """The payload was rejected (HTTP 422). Field errors live in ``context``."""
73
+
74
+
75
+ class ServerError(APIError):
76
+ """ForgeFile failed to handle the request (HTTP 5xx)."""
77
+
78
+
79
+ class RateLimitError(APIError):
80
+ """The rate limit was exhausted (HTTP 429)."""
81
+
82
+ def __init__(self, *args: Any, retry_after: int | None = None, **kwargs: Any) -> None:
83
+ """Record how long the caller was asked to wait, when the API said so."""
84
+ super().__init__(*args, **kwargs)
85
+ self.retry_after = retry_after
86
+
87
+ @classmethod
88
+ def from_envelope(
89
+ cls,
90
+ message: str,
91
+ *,
92
+ status_code: int,
93
+ error_code: str | None,
94
+ context: Mapping[str, Any],
95
+ headers: Mapping[str, str],
96
+ ) -> Self:
97
+ """Build the error, reading ``Retry-After`` from the response headers."""
98
+ return cls(
99
+ message,
100
+ status_code=status_code,
101
+ error_code=error_code,
102
+ context=context,
103
+ retry_after=_positive_int(headers.get("retry-after")),
104
+ )
105
+
106
+
107
+ _BY_STATUS: dict[int, type[APIError]] = {
108
+ 401: AuthenticationError,
109
+ 403: ForbiddenError,
110
+ 404: NotFoundError,
111
+ 422: ValidationError,
112
+ 429: RateLimitError,
113
+ }
114
+
115
+
116
+ def build_error(
117
+ *,
118
+ status_code: int,
119
+ body: Any,
120
+ headers: Mapping[str, str],
121
+ ) -> APIError:
122
+ """Turn a failed response into the most specific exception available."""
123
+ message = f"HTTP {status_code}"
124
+ error_code: str | None = None
125
+ context: Mapping[str, Any] = {}
126
+
127
+ if isinstance(body, Mapping):
128
+ message = str(body.get("message") or message)
129
+ raw_code = body.get("error_code")
130
+ error_code = str(raw_code) if raw_code is not None else None
131
+ raw_context = body.get("context")
132
+ if isinstance(raw_context, Mapping):
133
+ context = raw_context
134
+
135
+ cls = ServerError if status_code >= 500 else _BY_STATUS.get(status_code, APIError)
136
+ return cls.from_envelope(
137
+ message,
138
+ status_code=status_code,
139
+ error_code=error_code,
140
+ context=context,
141
+ headers=headers,
142
+ )
143
+
144
+
145
+ def _positive_int(value: str | None) -> int | None:
146
+ return int(value) if value and value.isdigit() else None
forgefile/models.py ADDED
@@ -0,0 +1,97 @@
1
+ """Response models.
2
+
3
+ Only fields observed on the live API are typed explicitly. Every model accepts
4
+ extra keys, which stay reachable through ``model_extra`` — a server-side
5
+ addition reaches the caller instead of raising.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import StrEnum
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict
14
+
15
+
16
+ class _Model(BaseModel):
17
+ model_config = ConfigDict(extra="allow", frozen=True)
18
+
19
+
20
+ class JobStatus(StrEnum):
21
+ """States this client recognises.
22
+
23
+ The API may report others, so branch on :attr:`FileJob.is_finished` rather
24
+ than on membership of this enum.
25
+ """
26
+
27
+ PENDING = "pending"
28
+ QUEUED = "queued"
29
+ PROCESSING = "processing"
30
+ COMPLETED = "completed"
31
+ FAILED = "failed"
32
+ CANCELLED = "cancelled"
33
+
34
+ @property
35
+ def is_terminal(self) -> bool:
36
+ """True for states that will not change again."""
37
+ return self in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}
38
+
39
+
40
+ class FileJob(_Model):
41
+ """A file submitted for processing."""
42
+
43
+ uuid: str
44
+ status: str | None = None
45
+ original_name: str | None = None
46
+ created_at: str | None = None
47
+
48
+ @property
49
+ def state(self) -> JobStatus | None:
50
+ """``status`` as an enum member, or ``None`` if absent or unrecognised."""
51
+ if self.status is None:
52
+ return None
53
+ try:
54
+ return JobStatus(self.status)
55
+ except ValueError:
56
+ return None
57
+
58
+ @property
59
+ def is_finished(self) -> bool:
60
+ """True once the job reached a state that will not change again."""
61
+ state = self.state
62
+ return state is not None and state.is_terminal
63
+
64
+ @property
65
+ def succeeded(self) -> bool:
66
+ """True when the job finished and produced a result."""
67
+ return self.state is JobStatus.COMPLETED
68
+
69
+
70
+ class Language(_Model):
71
+ """A language offered for translation."""
72
+
73
+ code: str
74
+ name: str
75
+ native: str | None = None
76
+
77
+
78
+ class Country(_Model):
79
+ """A country the API recognises."""
80
+
81
+ code: str | None = None
82
+ name: str | None = None
83
+ native: str | None = None
84
+
85
+
86
+ class Currency(_Model):
87
+ """A currency available for billing."""
88
+
89
+ code: str | None = None
90
+ name: str | None = None
91
+
92
+
93
+ class CreditsAndPlan(_Model):
94
+ """Remaining credits and the plan they belong to."""
95
+
96
+ credits: int | None = None
97
+ plan: dict[str, Any] | None = None
forgefile/py.typed ADDED
File without changes
@@ -0,0 +1,9 @@
1
+ """Resource groups exposed on :class:`forgefile.ForgeFile`."""
2
+
3
+ from .account import Account
4
+ from .files import Files, JobTimeoutError
5
+ from .jobs import Jobs
6
+ from .public import Public
7
+ from .system import System
8
+
9
+ __all__ = ["Account", "Files", "JobTimeoutError", "Jobs", "Public", "System"]
@@ -0,0 +1,52 @@
1
+ """Shared plumbing for resource groups.
2
+
3
+ Reading a model, reading a collection and posting a multipart upload are the
4
+ three shapes every resource needs. They live here once.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from pathlib import Path
11
+ from typing import Any, TypeVar
12
+
13
+ from pydantic import BaseModel
14
+
15
+ from ..envelope import collection
16
+ from ..transport import HTTPTransport
17
+
18
+ ModelT = TypeVar("ModelT", bound=BaseModel)
19
+
20
+
21
+ class Resource:
22
+ """Base for every resource group, holding the transport seam."""
23
+
24
+ def __init__(self, transport: HTTPTransport) -> None:
25
+ self._transport = transport
26
+
27
+ def _fetch(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
28
+ """Raw payload of a GET."""
29
+ return self._transport.request("GET", path, params=params)
30
+
31
+ def _fetch_one(self, model: type[ModelT], path: str) -> ModelT:
32
+ """GET a single object and validate it."""
33
+ return model.model_validate(self._fetch(path))
34
+
35
+ def _fetch_many(self, model: type[ModelT], path: str, *, key: str) -> list[ModelT]:
36
+ """GET a collection — nested under ``key`` or returned bare — and validate it."""
37
+ return [model.model_validate(item) for item in collection(self._fetch(path), key)]
38
+
39
+ def _upload(
40
+ self,
41
+ model: type[ModelT],
42
+ path: str,
43
+ source: str | Path,
44
+ data: Mapping[str, Any] | None = None,
45
+ ) -> ModelT:
46
+ """POST a file as multipart and validate the returned object."""
47
+ file = Path(source)
48
+ with file.open("rb") as handle:
49
+ payload = self._transport.request(
50
+ "POST", path, data=data, files={"file": (file.name, handle)}
51
+ )
52
+ return model.model_validate(payload)
@@ -0,0 +1,28 @@
1
+ """The authenticated user's account."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..models import CreditsAndPlan
8
+ from ._base import Resource
9
+
10
+
11
+ class Account(Resource):
12
+ """Credits, plan and notifications of the token holder."""
13
+
14
+ def credits_and_plan(self) -> CreditsAndPlan:
15
+ """Remaining credits together with the current plan."""
16
+ return self._fetch_one(CreditsAndPlan, "/user/credits-and-plan")
17
+
18
+ def notifications(self) -> Any:
19
+ """Notifications raised for the account."""
20
+ return self._fetch("/notifications")
21
+
22
+ def referral_stats(self) -> Any:
23
+ """Referral totals for the account."""
24
+ return self._fetch("/referrals/stats")
25
+
26
+ def subscription_plans(self) -> Any:
27
+ """Plans available to this account."""
28
+ return self._fetch("/subscription/plans")
@@ -0,0 +1,96 @@
1
+ """Stored files: upload, inspect, wait, download, delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable, Iterator
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ..errors import ForgeFileError
11
+ from ..models import FileJob
12
+ from ._base import Resource
13
+
14
+ Sleep = Callable[[float], None]
15
+ Clock = Callable[[], float]
16
+
17
+
18
+ class JobTimeoutError(ForgeFileError):
19
+ """A job did not reach a terminal state within the allotted time."""
20
+
21
+
22
+ class Files(Resource):
23
+ """The file lifecycle, independent of what is being done to the file."""
24
+
25
+ def upload(self, source: str | Path) -> FileJob:
26
+ """Upload a file without starting any processing.
27
+
28
+ The API caps uploads at 10 MB.
29
+ """
30
+ return self._upload(FileJob, "/files", source)
31
+
32
+ def list(self) -> list[FileJob]:
33
+ """Files belonging to the token holder."""
34
+ return self._fetch_many(FileJob, "/files", key="files")
35
+
36
+ def get(self, uuid: str) -> FileJob:
37
+ """Current state of one file, including its job status."""
38
+ return self._fetch_one(FileJob, f"/files/{uuid}")
39
+
40
+ def result(self, uuid: str) -> Any:
41
+ """Structured output of a finished job — text, segments, metadata."""
42
+ return self._fetch(f"/files/{uuid}/result")
43
+
44
+ def download(self, uuid: str, destination: str | Path) -> Path:
45
+ """Stream the produced file to ``destination``."""
46
+ return self._transport.download(f"/files/{uuid}/download", Path(destination))
47
+
48
+ def delete(self, uuid: str) -> None:
49
+ """Remove a file and everything derived from it."""
50
+ self._transport.request("DELETE", f"/files/{uuid}")
51
+
52
+ def wait(
53
+ self,
54
+ uuid: str,
55
+ *,
56
+ timeout: float = 600.0,
57
+ interval: float = 3.0,
58
+ sleep: Sleep = time.sleep,
59
+ clock: Clock = time.monotonic,
60
+ ) -> FileJob:
61
+ """Block until the job finishes and return its final state.
62
+
63
+ Raises :class:`JobTimeoutError` if ``timeout`` elapses first. The job
64
+ keeps running server-side, so calling again resumes waiting.
65
+ """
66
+ deadline = clock() + timeout
67
+ while True:
68
+ job = self.get(uuid)
69
+ if job.is_finished:
70
+ return job
71
+ if clock() >= deadline:
72
+ raise JobTimeoutError(
73
+ f"File {uuid} still reports status {job.status!r} after {timeout:g}s"
74
+ )
75
+ sleep(interval)
76
+
77
+ def track(
78
+ self,
79
+ uuid: str,
80
+ *,
81
+ interval: float = 3.0,
82
+ sleep: Sleep = time.sleep,
83
+ ) -> Iterator[FileJob]:
84
+ """Yield every observed state until the job finishes.
85
+
86
+ Use it to report progress instead of blocking silently:
87
+
88
+ for job in api.files.track(uuid):
89
+ print(job.status)
90
+ """
91
+ while True:
92
+ job = self.get(uuid)
93
+ yield job
94
+ if job.is_finished:
95
+ return
96
+ sleep(interval)
@@ -0,0 +1,56 @@
1
+ """Processing endpoints — one call uploads the file and starts the work.
2
+
3
+ Each method is a thin, self-documenting wrapper over the shared multipart
4
+ upload; the differences between them are the endpoint and the extra fields.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from ..models import FileJob
12
+ from ._base import Resource
13
+
14
+
15
+ class Jobs(Resource):
16
+ """Translate, transcribe, convert, OCR, summarize, rewrite and compress."""
17
+
18
+ def translate(
19
+ self,
20
+ source: str | Path,
21
+ *,
22
+ target_language: str,
23
+ source_language: str | None = None,
24
+ ) -> FileJob:
25
+ """Translate a document, keeping its original layout.
26
+
27
+ Omit ``source_language`` to let ForgeFile detect it.
28
+ """
29
+ data = {"target_language": target_language}
30
+ if source_language is not None:
31
+ data["source_language"] = source_language
32
+ return self._upload(FileJob, "/files/translate", source, data)
33
+
34
+ def transcribe(self, source: str | Path) -> FileJob:
35
+ """Transcribe audio or video into timecoded text."""
36
+ return self._upload(FileJob, "/files/transcribe", source)
37
+
38
+ def convert(self, source: str | Path, *, to_format: str) -> FileJob:
39
+ """Convert a file to ``to_format`` (for example ``"pdf"``)."""
40
+ return self._upload(FileJob, "/files/convert", source, {"to_format": to_format})
41
+
42
+ def ocr(self, source: str | Path) -> FileJob:
43
+ """Extract text from an image."""
44
+ return self._upload(FileJob, "/files/ocr", source)
45
+
46
+ def summarize(self, source: str | Path) -> FileJob:
47
+ """Summarize a document."""
48
+ return self._upload(FileJob, "/files/summarize", source)
49
+
50
+ def rewrite(self, source: str | Path) -> FileJob:
51
+ """Rewrite the contents of a document."""
52
+ return self._upload(FileJob, "/files/rewrite", source)
53
+
54
+ def compress(self, source: str | Path) -> FileJob:
55
+ """Reduce the size of a document or media file."""
56
+ return self._upload(FileJob, "/files/compress", source)
@@ -0,0 +1,32 @@
1
+ """Reference data. No authentication required."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..models import Country, Currency, Language
8
+ from ._base import Resource
9
+
10
+
11
+ class Public(Resource):
12
+ """Catalogues of languages, countries, currencies and plans."""
13
+
14
+ def languages(self) -> list[Language]:
15
+ """Every language available for translation."""
16
+ return self._fetch_many(Language, "/public/languages", key="languages")
17
+
18
+ def countries(self) -> list[Country]:
19
+ """Countries known to the API."""
20
+ return self._fetch_many(Country, "/public/countries", key="countries")
21
+
22
+ def currencies(self) -> list[Currency]:
23
+ """Currencies available for billing."""
24
+ return self._fetch_many(Currency, "/public/currencies", key="currencies")
25
+
26
+ def timezones(self) -> Any:
27
+ """Timezones the API accepts. Shape is passed through unmodified."""
28
+ return self._fetch("/public/timezones")
29
+
30
+ def translation_formats(self) -> Any:
31
+ """File formats accepted by the translation endpoints."""
32
+ return self._fetch("/public/translation/supported-formats")
@@ -0,0 +1,23 @@
1
+ """Service health and metadata. No token required."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ._base import Resource
8
+
9
+
10
+ class System(Resource):
11
+ """Liveness probes. These answer without the usual envelope."""
12
+
13
+ def health(self) -> Any:
14
+ """``{"status": "ok"}`` when the API is serving."""
15
+ return self._fetch("/health")
16
+
17
+ def ping(self) -> Any:
18
+ """``{"pong": true}``."""
19
+ return self._fetch("/ping")
20
+
21
+ def status(self) -> Any:
22
+ """Service status detail."""
23
+ return self._fetch("/status")
forgefile/transport.py ADDED
@@ -0,0 +1,133 @@
1
+ """HTTP transport.
2
+
3
+ :class:`HTTPTransport` is the seam the resources depend on. Swap in your own
4
+ implementation for tests, recording proxies or a different HTTP library —
5
+ nothing above this module knows about httpx.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from pathlib import Path
12
+ from typing import Any, BinaryIO, Protocol, runtime_checkable
13
+
14
+ import httpx
15
+
16
+ from .config import ClientConfig
17
+ from .envelope import unwrap
18
+ from .errors import APIError, TransportError, build_error
19
+
20
+ Files = Mapping[str, tuple[str, BinaryIO]]
21
+
22
+
23
+ @runtime_checkable
24
+ class HTTPTransport(Protocol):
25
+ """Everything the resource layer needs from an HTTP client."""
26
+
27
+ def request(
28
+ self,
29
+ method: str,
30
+ path: str,
31
+ *,
32
+ params: Mapping[str, Any] | None = None,
33
+ data: Mapping[str, Any] | None = None,
34
+ files: Files | None = None,
35
+ ) -> Any:
36
+ """Perform a request and return the unwrapped payload."""
37
+ ...
38
+
39
+ def download(self, path: str, destination: Path) -> Path:
40
+ """Stream a binary response to ``destination``."""
41
+ ...
42
+
43
+ def close(self) -> None:
44
+ """Release underlying connections."""
45
+ ...
46
+
47
+
48
+ class HttpxTransport:
49
+ """:class:`HTTPTransport` backed by :mod:`httpx`."""
50
+
51
+ def __init__(self, config: ClientConfig, client: httpx.Client | None = None) -> None:
52
+ """Build a transport, optionally reusing a configured httpx client."""
53
+ self._client = client or httpx.Client(
54
+ base_url=config.base_url,
55
+ headers=config.headers,
56
+ timeout=config.timeout,
57
+ follow_redirects=True,
58
+ )
59
+
60
+ def request(
61
+ self,
62
+ method: str,
63
+ path: str,
64
+ *,
65
+ params: Mapping[str, Any] | None = None,
66
+ data: Mapping[str, Any] | None = None,
67
+ files: Files | None = None,
68
+ ) -> Any:
69
+ """Send a request and return the unwrapped payload."""
70
+ try:
71
+ response = self._client.request(method, path, params=params, data=data, files=files)
72
+ except httpx.HTTPError as exc:
73
+ raise TransportError(f"Request to {path} failed: {exc}") from exc
74
+ return unwrap(self._payload(response))
75
+
76
+ def download(self, path: str, destination: Path) -> Path:
77
+ """Stream to a sibling ``.part`` file, then rename into place.
78
+
79
+ A failure mid-stream must not leave a truncated file that looks
80
+ complete, so ``destination`` only appears once every byte has arrived.
81
+ """
82
+ try:
83
+ with self._client.stream("GET", path) as response:
84
+ if response.is_error:
85
+ response.read()
86
+ raise build_error(
87
+ status_code=response.status_code,
88
+ body=_json_or_none(response),
89
+ headers=response.headers,
90
+ )
91
+ destination.parent.mkdir(parents=True, exist_ok=True)
92
+ partial = destination.with_name(f"{destination.name}.part")
93
+ try:
94
+ with partial.open("wb") as handle:
95
+ for chunk in response.iter_bytes():
96
+ handle.write(chunk)
97
+ partial.replace(destination)
98
+ except BaseException:
99
+ partial.unlink(missing_ok=True)
100
+ raise
101
+ except httpx.HTTPError as exc:
102
+ raise TransportError(f"Download of {path} failed: {exc}") from exc
103
+ return destination
104
+
105
+ def close(self) -> None:
106
+ """Close the underlying httpx client."""
107
+ self._client.close()
108
+
109
+ @staticmethod
110
+ def _payload(response: httpx.Response) -> Any:
111
+ """Decoded body, or the mapped exception when the response is an error."""
112
+ if response.is_error:
113
+ raise build_error(
114
+ status_code=response.status_code,
115
+ body=_json_or_none(response),
116
+ headers=response.headers,
117
+ )
118
+ if not response.content:
119
+ return None
120
+ try:
121
+ return response.json()
122
+ except ValueError as exc:
123
+ raise APIError("Response was not JSON", status_code=response.status_code) from exc
124
+
125
+
126
+ def _json_or_none(response: httpx.Response) -> Any:
127
+ """Parsed body, or ``None`` when it is absent or not JSON."""
128
+ if not response.content:
129
+ return None
130
+ try:
131
+ return response.json()
132
+ except ValueError:
133
+ return None
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.5
2
+ Name: forgefile
3
+ Version: 0.1.0
4
+ Summary: Python client for the ForgeFile REST API — translate, transcribe, convert, OCR, summarize and rewrite files.
5
+ Project-URL: Homepage, https://forgefile.com
6
+ Project-URL: Documentation, https://forgefile.com/docs
7
+ Project-URL: Source, https://github.com/ForgeFile/forgefile-python
8
+ Project-URL: Issues, https://github.com/ForgeFile/forgefile-python/issues
9
+ Project-URL: Changelog, https://github.com/ForgeFile/forgefile-python/blob/main/CHANGELOG.md
10
+ Author-email: ForgeFile <support@forgefile.com>
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: api-client,file-conversion,forgefile,ocr,transcription,translation
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: Implementation :: CPython
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: Text Processing :: Linguistic
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.11
26
+ Requires-Dist: httpx>=0.27
27
+ Requires-Dist: pydantic>=2.7
28
+ Description-Content-Type: text/markdown
29
+
30
+ # forgefile-python
31
+
32
+ Python client for the [ForgeFile](https://forgefile.com) REST API — translate, transcribe,
33
+ convert, OCR, summarize and rewrite files.
34
+
35
+ Requires Python 3.11+.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install forgefile
41
+ ```
42
+
43
+ ## Quick start
44
+
45
+ ```python
46
+ from forgefile import ForgeFile
47
+
48
+ with ForgeFile("your-token") as api:
49
+ job = api.jobs.translate("contract.pdf", target_language="es")
50
+ api.files.wait(job.uuid)
51
+ api.files.download(job.uuid, "contract.es.pdf")
52
+ ```
53
+
54
+ The token is read from `FORGEFILE_API_KEY` when the first argument is omitted.
55
+ Public endpoints need no token at all:
56
+
57
+ ```python
58
+ with ForgeFile() as api:
59
+ for language in api.public.languages():
60
+ print(language.code, language.name)
61
+ ```
62
+
63
+ ## Processing a file
64
+
65
+ Every job endpoint uploads the file and starts the work in one call, returning a `FileJob`
66
+ whose `uuid` identifies it from then on.
67
+
68
+ ```python
69
+ api.jobs.translate("contract.pdf", target_language="de") # source_language is optional
70
+ api.jobs.transcribe("interview.mp3")
71
+ api.jobs.convert("report.docx", to_format="pdf")
72
+ api.jobs.ocr("receipt.jpg")
73
+ api.jobs.summarize("paper.pdf")
74
+ api.jobs.rewrite("draft.docx")
75
+ api.jobs.compress("scan.pdf")
76
+ ```
77
+
78
+ Uploads are capped at 10 MB by the API.
79
+
80
+ ### Waiting for the result
81
+
82
+ `wait()` blocks until the job reaches a terminal state and raises `JobTimeoutError` if it
83
+ does not. The job keeps running server-side, so calling again resumes waiting.
84
+
85
+ ```python
86
+ finished = api.files.wait(job.uuid, timeout=900, interval=5)
87
+
88
+ if finished.succeeded:
89
+ transcript = api.files.result(job.uuid) # structured output
90
+ api.files.download(job.uuid, "interview.srt")
91
+ ```
92
+
93
+ `track()` yields every observed state instead, for progress reporting:
94
+
95
+ ```python
96
+ for state in api.files.track(job.uuid, interval=5):
97
+ print(state.status)
98
+ ```
99
+
100
+ Branch on `job.is_finished` and `job.succeeded` rather than comparing status strings — a
101
+ status this client does not recognise is never treated as finished, so a wait loop cannot
102
+ end early on a state it has not seen.
103
+
104
+ ## Errors
105
+
106
+ Every failure raises a subclass of `ForgeFileError` carrying the API's stable `error_code`,
107
+ so you can branch on the code rather than on message text.
108
+
109
+ ```python
110
+ from forgefile import RateLimitError, ValidationError
111
+
112
+ try:
113
+ api.jobs.translate("contract.pdf", target_language="es")
114
+ except ValidationError as exc:
115
+ print(exc.context) # field errors
116
+ except RateLimitError as exc:
117
+ print(f"retry in {exc.retry_after}s")
118
+ ```
119
+
120
+ | Exception | Raised when |
121
+ |---|---|
122
+ | `AuthenticationError` | 401 — no token, or rejected |
123
+ | `ForbiddenError` | 403 — token lacks the right |
124
+ | `NotFoundError` | 404 |
125
+ | `ValidationError` | 422 — field errors in `context` |
126
+ | `RateLimitError` | 429 — `retry_after` in seconds |
127
+ | `ServerError` | 5xx — safe to retry with backoff |
128
+ | `TransportError` | no response at all: DNS, TLS, timeout |
129
+ | `JobTimeoutError` | `wait()` gave up; the job still runs |
130
+
131
+ The API allows 60 requests per minute.
132
+
133
+ ## Configuration
134
+
135
+ | Argument | Environment variable | Default |
136
+ |---|---|---|
137
+ | `api_key` | `FORGEFILE_API_KEY` | none — public endpoints only |
138
+ | `base_url` | `FORGEFILE_BASE_URL` | `https://forgefile.com/api/v1` |
139
+ | `timeout` | — | 60 seconds |
140
+
141
+ ## Examples
142
+
143
+ Runnable scripts in [`examples/`](examples):
144
+
145
+ | File | Shows |
146
+ |---|---|
147
+ | `01_public_data.py` | reference data without a token |
148
+ | `02_translate_document.py` | submit, wait, download |
149
+ | `03_transcribe_with_progress.py` | streaming progress with `track()` |
150
+ | `04_convert_a_folder.py` | batching — submit all, then collect |
151
+ | `05_handling_errors.py` | every failure mode and its remedy |
152
+ | `06_custom_transport.py` | replacing the HTTP layer |
153
+
154
+ ## Architecture
155
+
156
+ | Module | Responsibility |
157
+ |---|---|
158
+ | `config` | where to connect and with what headers |
159
+ | `envelope` | the API's `{success, message, data}` wrapper — the only place that knows it |
160
+ | `errors` | turning a failed response into the right exception |
161
+ | `transport` | HTTP, as a `Protocol` plus an httpx implementation |
162
+ | `resources/` | one class per endpoint group: `system`, `public`, `account`, `files`, `jobs` |
163
+
164
+ Resources depend on the `HTTPTransport` protocol, never on httpx, so the HTTP layer can be
165
+ replaced with a recorded fixture, a proxy or a different library — see
166
+ `examples/06_custom_transport.py`.
167
+
168
+ Response models accept unknown fields, which stay reachable through `model_extra`. Only
169
+ fields observed against the live API are typed explicitly; authenticated endpoints could not
170
+ be inspected without a token while this client was written, so their payloads are permissive
171
+ rather than guessed.
172
+
173
+ The package ships a `py.typed` marker, so your type checker sees these signatures.
174
+
175
+ ## Development
176
+
177
+ ```bash
178
+ git clone https://github.com/ForgeFile/forgefile-python
179
+ cd forgefile-python
180
+ uv sync
181
+
182
+ uv run ruff check .
183
+ uv run ruff format --check .
184
+ uv run mypy
185
+ uv run pytest
186
+ ```
187
+
188
+ Tests make no network calls: the resource layer runs against a fake transport, the httpx
189
+ layer against `respx`. CI runs these commands on Python 3.11, 3.12 and 3.13.
190
+
191
+ ## Links
192
+
193
+ - [ForgeFile](https://forgefile.com) — the product
194
+ - [API reference](https://forgefile.com/docs)
195
+ - [Source and issues](https://github.com/ForgeFile/forgefile-python)
196
+ - [ForgeFile on GitHub](https://github.com/ForgeFile)
197
+ - Support — <support@forgefile.com>
198
+
199
+ MIT licensed. See [LICENSE](LICENSE).
@@ -0,0 +1,19 @@
1
+ forgefile/__init__.py,sha256=F9Tatuipb_QCOhJFXCBnS33176STFWhwhUMswPKh7Co,1163
2
+ forgefile/client.py,sha256=qtpy0rNdmhjqu73akhPZjlflWh93Q365z4OOICbpu0k,2686
3
+ forgefile/config.py,sha256=w7rSmCcT8xL8KrqlPKtS45aO_rxzPQvnNGUbpRQNcv8,1348
4
+ forgefile/envelope.py,sha256=ftOgjOYe1-jfhijj9suVvMSMpAnUHyB5945ZMLmPrJ8,1030
5
+ forgefile/errors.py,sha256=1tRGnIiea1lCm3RvAIDm1QraYa7Qfg6CIFTKI3htoDc,4284
6
+ forgefile/models.py,sha256=UEBFaqNl2Df93VP-R92j2z3dYIsKDhiCukDjjq-sDdA,2403
7
+ forgefile/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ forgefile/transport.py,sha256=8Pk6k-08RQnAKrGdNqIJMft6HymaiZjftu7MmU6z5HU,4499
9
+ forgefile/resources/__init__.py,sha256=VmCMo-mEPxg4cwHf8_9jIzJ1RE90eF9U0R3dgRmP728,291
10
+ forgefile/resources/_base.py,sha256=WSKSSIj1Do3NTMJOCNDxPCcVpyWQ1YYYaAysVT8HpTI,1773
11
+ forgefile/resources/account.py,sha256=lsF3xcpZQv2D6FCh09MLBPkOqfzQjfL-nit2PSa3CXY,848
12
+ forgefile/resources/files.py,sha256=_weLdYhiVTUDo387jnSqZ_jacWWuuLourNlxfHYSwew,2998
13
+ forgefile/resources/jobs.py,sha256=XsKX6bFbIDq3bbgNJKruJG4kghs5_zsYaldXGjuo2YI,2048
14
+ forgefile/resources/public.py,sha256=yU3wvrPBP5xTHIMAt0xJrVc1dAG2D3hXkV52rGNBJVk,1136
15
+ forgefile/resources/system.py,sha256=UTaT_ugUPOF1xLRpM1qb2jczKaEbBYBvaRhZ48VILDI,568
16
+ forgefile-0.1.0.dist-info/METADATA,sha256=5HhXVsdBOpZHm45pPBaLNGyFVU1F1kdK-MvQM595ofQ,6720
17
+ forgefile-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
18
+ forgefile-0.1.0.dist-info/licenses/LICENSE,sha256=3rVNFhRxnfjSxi2stNMxxydST1W0hF0--xnAAgWn0xs,1066
19
+ forgefile-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ForgeFile
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.