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 +52 -0
- forgefile/client.py +88 -0
- forgefile/config.py +47 -0
- forgefile/envelope.py +32 -0
- forgefile/errors.py +146 -0
- forgefile/models.py +97 -0
- forgefile/py.typed +0 -0
- forgefile/resources/__init__.py +9 -0
- forgefile/resources/_base.py +52 -0
- forgefile/resources/account.py +28 -0
- forgefile/resources/files.py +96 -0
- forgefile/resources/jobs.py +56 -0
- forgefile/resources/public.py +32 -0
- forgefile/resources/system.py +23 -0
- forgefile/transport.py +133 -0
- forgefile-0.1.0.dist-info/METADATA +199 -0
- forgefile-0.1.0.dist-info/RECORD +19 -0
- forgefile-0.1.0.dist-info/WHEEL +4 -0
- forgefile-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|