diff --git a/docs/api/_index.md b/docs/api/_index.md
index 22923ba..f3599d7 100644
--- a/docs/api/_index.md
+++ b/docs/api/_index.md
@@ -5,4 +5,5 @@
| [`chat`](./chat.md) | 1 |
| [`database`](./database.md) | 18 |
| [`echo`](./echo.md) | 1 |
+| [`files`](./files.md) | 9 |
| [`membership`](./membership.md) | 18 |
diff --git a/docs/api/files.md b/docs/api/files.md
new file mode 100644
index 0000000..f1a36d0
--- /dev/null
+++ b/docs/api/files.md
@@ -0,0 +1,76 @@
+# API · Files
+
+Access with `norbix.api.files`.
+
+| Method | Verb | Path | Scope |
+| --- | --- | --- | --- |
+| `list_files` | `GET` | `/{version}/files/{filesIntegrationId}` | `project` |
+| `get_file_info` | `GET` | `/{version}/files/{filesIntegrationId}/info` | `project` |
+| `get_signed_url` | `GET` | `/{version}/files/{filesIntegrationId}/sign` | `project` |
+| `request_upload_url` | `POST` | `/{version}/files/{filesIntegrationId}/upload-url` | `project` |
+| `commit_upload` | `POST` | `/{version}/files/{filesIntegrationId}/commit` | `project` |
+| `download_file_api` | `GET` | `/{version}/files/{filesIntegrationId}/download` | `project` |
+| `delete_file_api` | `DELETE` | `/{version}/files/{filesIntegrationId}` | `project` |
+| `delete_many_files_api` | `DELETE` | `/{version}/files/{filesIntegrationId}/bulk` | `project` |
+
+`filesIntegrationId` names the storage integration to work with. Get it from
+`norbix.hub.files.get_files_integrations()`.
+
+## Uploading a file
+
+Uploading takes two steps, so the file bytes never pass through Norbix:
+
+1. `request_upload_url` returns a short-lived address to upload to.
+2. Send the bytes to that address with a plain `PUT`.
+3. `commit_upload` tells Norbix the upload finished.
+
+```python
+upload = client.api.files.request_upload_url(
+ integration_id,
+ path="invoices/invoice.pdf",
+ contentType="application/pdf",
+)
+httpx.put(upload["url"], content=pdf_bytes, headers={"Content-Type": "application/pdf"})
+client.api.files.commit_upload(
+ integration_id,
+ path="invoices/invoice.pdf",
+ contentType="application/pdf",
+ sizeBytes=len(pdf_bytes),
+)
+```
+
+## Reading a file
+
+`get_signed_url` returns a short-lived address anyone can open, which suits a
+browser preview or a download link. `download_file_api` streams the bytes
+through Norbix instead.
+
+## Reading a public link
+
+`get_public_file` reads a file somebody made public with
+[`hub.files.make_file_public`](../hub/files.md#public-file-links).
+
+**This call carries no sign-in and no project id.** The SDK deliberately sends
+no `Authorization` header for it, even when the client you call it on is signed
+in. That is what public means: the link has to work in an e-mail, in an
+`
`, or in a browser on a stranger's phone, and the unguessable
+`nbpf_…` id is the whole credential.
+
+```python
+pdf_bytes = client.api.files.get_public_file("nbpf_7hK2abc", "invoice.pdf")
+
+# a file inside a published folder — the path keeps its slashes
+report = client.api.files.get_public_file("nbpf_folder1", "2026/q1/report.pdf")
+```
+
+It gives back raw `bytes`. When the storage provider signs its own links
+(Amazon S3, Azure Blob, Google Cloud Storage) Norbix answers `302` and the call
+follows the redirect, so the bytes come straight from the provider.
+
+Every miss is the same plain `404` — an unknown id, a name that does not match,
+a file made private again, a file gone from storage. A more precise answer
+would tell a stranger that the file is there.
+
+The link is a plain HTTP address, so anything that can do a `GET` can read it —
+`httpx`, `curl`, an `
` tag. You do not need this SDK, or a Norbix client
+at all, to open one; the method is here for code that already has a client.
diff --git a/docs/hub/_index.md b/docs/hub/_index.md
index c3ad6c8..9505fc4 100644
--- a/docs/hub/_index.md
+++ b/docs/hub/_index.md
@@ -7,7 +7,7 @@
| [`database`](./database.md) | 41 |
| [`echo`](./echo.md) | 1 |
| [`email`](./email.md) | 1 |
-| [`files`](./files.md) | 15 |
+| [`files`](./files.md) | 22 |
| [`internal`](./internal.md) | 1 |
| [`logs`](./logs.md) | 9 |
| [`membership`](./membership.md) | 25 |
diff --git a/docs/hub/files.md b/docs/hub/files.md
index c1f606b..9b6dc96 100644
--- a/docs/hub/files.md
+++ b/docs/hub/files.md
@@ -6,6 +6,8 @@ Access with `norbix.hub.files`.
| --- | --- | --- | --- |
| `disable_files` | `GET` | `/{version}/files/disable` | `project` |
| `enable_files` | `GET` | `/{version}/files/enable` | `project` |
+| `get_folder_files` | `GET` | `/{version}/files/folder` | `project` |
+| `get_file` | `GET` | `/{version}/files/item` | `project` |
| `delete_files_trigger` | `DELETE` | `/{version}/files/triggers/{triggerId}` | `project` |
| `disable_files_trigger` | `PATCH` | `/{version}/files/triggers/{triggerId}/disable` | `project` |
| `enable_files_trigger` | `PATCH` | `/{version}/files/triggers/{triggerId}/enable` | `project` |
@@ -19,3 +21,74 @@ Access with `norbix.hub.files`.
| `get_files_integrations` | `GET` | `/{version}/files/integrations` | `project` |
| `save_files_integration` | `POST` | `/{version}/files/integrations` | `project` |
| `set_files_integration_as_default` | `PUT` | `/{version}/files/integrations/{Id}/default` | `project` |
+| `test_files_integration` | `POST` | `/{version}/files/integrations/test` | `project` |
+| `make_file_public` | `POST` | `/{version}/files/item/public` | `project` |
+| `make_file_private` | `POST` | `/{version}/files/item/private` | `project` |
+| `make_folder_public` | `POST` | `/{version}/files/folder/public` | `project` |
+| `make_folder_private` | `POST` | `/{version}/files/folder/private` | `project` |
+
+## What the parts are
+
+* An **integration** is one storage place — an Amazon S3 bucket, an Azure
+ container, a folder on the server. A project can have several; one is the
+ default.
+* `get_folder_files` lists what is under a path. `get_file` reads the details of
+ one file.
+* `test_files_integration` checks the settings really work before you save them.
+
+Uploading and downloading file bytes is on the public API side — see
+[API · Files](../api/files.md).
+
+## Public file links
+
+A file, or a whole folder, can be made readable by anyone holding a link — no
+sign-in, no project id, no account. Norbix keeps a record and mints an
+unguessable id that looks like `nbpf_7hK2…`; the link is then
+
+```
+https:///v3/files/public/nbpf_7hK2…/invoice.pdf
+```
+
+```python
+# one file
+result = client.hub.files.make_file_public(
+ filesIntegrationId=integration_id,
+ path="invoices/invoice.pdf",
+)
+public_id = result["id"] # "nbpf_7hK2abc"
+
+# take it back
+client.hub.files.make_file_private(
+ filesIntegrationId=integration_id,
+ path="invoices/invoice.pdf",
+)
+
+# a whole folder — one record, however many files are under it
+folder = client.hub.files.make_folder_public(
+ filesIntegrationId=integration_id,
+ path="invoices",
+)
+# every file under invoices/ is now readable at
+# https:///v3/files/public//
+
+client.hub.files.make_folder_private(
+ filesIntegrationId=integration_id,
+ path="invoices",
+)
+```
+
+Four rules worth knowing:
+
+* **Publishing a folder is one record**, whatever is under it, at any depth.
+* **Asking twice gives the same id back.** The first link is already in
+ somebody's hands; a second id would leave it live and invisible.
+* **A file cannot be made private on its own while a folder above it is
+ public.** The call is refused (`CM-ERRORS-FILES-021`) and the message names
+ the folder to switch off.
+* **The root cannot be published**, and a folder link with nothing after it is
+ a `404` — publishing a prefix must not publish its listing.
+
+After any of these, `get_file` and `list_files` report `isPublic` and
+`publicUrl` on the file, and a listing carries `publicFolders`. Opening the
+link itself is on the public API side — see
+[API · Files](../api/files.md#reading-a-public-link).
diff --git a/src/norbix_python/api/files.py b/src/norbix_python/api/files.py
index f5befa6..60f34d9 100644
--- a/src/norbix_python/api/files.py
+++ b/src/norbix_python/api/files.py
@@ -113,6 +113,50 @@ def request_upload_url(self, files_integration_id: str, *, timeout: float | None
bearer_token=bearer_token,
)
+ # ------------------------------------------------------------------
+ # The public file link (10b-files slice PUB). Hand-added by slice SDK-2.
+ # ------------------------------------------------------------------
+
+ def get_public_file(self, public_id: str, name: str, *, timeout: float | None = None, **request: Any) -> Any:
+ """GET /{version}/files/public/{publicId}/{name}
+
+ The gateway declares the route as /{version}/files/public/{PublicId}/{Name*} —
+ written out here exactly as the gateway spells it, so the SDK coverage
+ matrix can see that the two agree (knowledge.md K23).
+
+ Reads a file somebody made public.
+
+ **No sign-in and no project id.** The SDK deliberately sends no
+ ``Authorization`` header for this call, even when the client is signed
+ in: the link has to work in an e-mail, in an ``
``, or in a
+ browser on a stranger's phone. The unguessable ``nbpf_…`` id is the
+ whole credential.
+
+ Gives back the file's raw ``bytes``. When the storage provider signs
+ its own links (Amazon S3, Azure Blob, Google Cloud Storage) the gateway
+ answers 302 and this call follows the redirect, so the bytes come from
+ the provider and never pass through Norbix.
+
+ ``name`` is the file's name for a file link, or the path inside the
+ folder for a folder link (``2026/q1/report.pdf``); its slashes stay
+ slashes.
+
+ Every miss is the same plain 404 — an unknown id, a name that does not
+ match, a file made private again, a file gone from storage. A more
+ precise answer would tell a stranger the file is there.
+ """
+ return self._transport.send(
+ target="api",
+ path="/{version}/files/public/{publicId}/{name}",
+ method="GET",
+ path_params={"publicId": public_id, "name": name},
+ request=request,
+ scope="unauthenticated",
+ timeout=timeout,
+ response_type="binary",
+ follow_redirects=True,
+ )
+
class AsyncFilesModule:
def __init__(self, transport: AsyncTransport) -> None:
@@ -221,3 +265,47 @@ async def request_upload_url(self, files_integration_id: str, *, timeout: float
timeout=timeout,
bearer_token=bearer_token,
)
+
+ # ------------------------------------------------------------------
+ # The public file link (10b-files slice PUB). Hand-added by slice SDK-2.
+ # ------------------------------------------------------------------
+
+ async def get_public_file(self, public_id: str, name: str, *, timeout: float | None = None, **request: Any) -> Any:
+ """GET /{version}/files/public/{publicId}/{name}
+
+ The gateway declares the route as /{version}/files/public/{PublicId}/{Name*} —
+ written out here exactly as the gateway spells it, so the SDK coverage
+ matrix can see that the two agree (knowledge.md K23).
+
+ Reads a file somebody made public.
+
+ **No sign-in and no project id.** The SDK deliberately sends no
+ ``Authorization`` header for this call, even when the client is signed
+ in: the link has to work in an e-mail, in an ``
``, or in a
+ browser on a stranger's phone. The unguessable ``nbpf_…`` id is the
+ whole credential.
+
+ Gives back the file's raw ``bytes``. When the storage provider signs
+ its own links (Amazon S3, Azure Blob, Google Cloud Storage) the gateway
+ answers 302 and this call follows the redirect, so the bytes come from
+ the provider and never pass through Norbix.
+
+ ``name`` is the file's name for a file link, or the path inside the
+ folder for a folder link (``2026/q1/report.pdf``); its slashes stay
+ slashes.
+
+ Every miss is the same plain 404 — an unknown id, a name that does not
+ match, a file made private again, a file gone from storage. A more
+ precise answer would tell a stranger the file is there.
+ """
+ return await self._transport.send(
+ target="api",
+ path="/{version}/files/public/{publicId}/{name}",
+ method="GET",
+ path_params={"publicId": public_id, "name": name},
+ request=request,
+ scope="unauthenticated",
+ timeout=timeout,
+ response_type="binary",
+ follow_redirects=True,
+ )
diff --git a/src/norbix_python/hub/files.py b/src/norbix_python/hub/files.py
index 2f113b5..1c1ad86 100644
--- a/src/norbix_python/hub/files.py
+++ b/src/norbix_python/hub/files.py
@@ -217,6 +217,19 @@ def save_files_integration(self, *, timeout: float | None = None, bearer_token:
bearer_token=bearer_token,
)
+ def test_files_integration(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
+ """POST /{version}/files/integrations/test"""
+ return self._transport.send(
+ target="hub",
+ path="/{version}/files/integrations/test",
+ method="POST",
+ path_params={},
+ request=request,
+ scope="project",
+ timeout=timeout,
+ bearer_token=bearer_token,
+ )
+
def set_files_integration_as_default(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
"""PUT /{version}/files/integrations/{Id}/default"""
return self._transport.send(
@@ -230,6 +243,97 @@ def set_files_integration_as_default(self, id: str, *, timeout: float | None = N
bearer_token=bearer_token,
)
+ # ------------------------------------------------------------------
+ # Public file links (10b-files slice PUB). Hand-added by slice SDK-2 in
+ # the shape the module generator produces.
+ #
+ # A file, or a whole folder, can be made readable by anyone holding a
+ # link — no sign-in, no project id. The gateway mints an unguessable
+ # ``nbpf_…`` id and the link is
+ # ``https:///v3/files/public/nbpf_…/invoice.pdf``.
+ #
+ # Worth knowing before you call these:
+ # * publishing a folder is ONE record, whatever is under it;
+ # * asking twice gives the same id back;
+ # * a file cannot be made private on its own while a folder above it
+ # is public (CM-ERRORS-FILES-021) — switch the folder off instead;
+ # * the root cannot be published.
+ # ------------------------------------------------------------------
+
+ def make_file_public(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
+ """POST /{version}/files/item/public
+
+ Makes one file readable by anyone holding its link. The file has to
+ exist already. Answers with ``id`` — the ``nbpf_…`` public id.
+
+ Keyword arguments: ``filesIntegrationId``, ``path``.
+ """
+ return self._transport.send(
+ target="hub",
+ path="/{version}/files/item/public",
+ method="POST",
+ path_params={},
+ request=request,
+ scope="project",
+ timeout=timeout,
+ bearer_token=bearer_token,
+ )
+
+ def make_file_private(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
+ """POST /{version}/files/item/private
+
+ Takes the file's public link away; opening it afterwards gives a 404.
+
+ Keyword arguments: ``filesIntegrationId``, ``path``.
+ """
+ return self._transport.send(
+ target="hub",
+ path="/{version}/files/item/private",
+ method="POST",
+ path_params={},
+ request=request,
+ scope="project",
+ timeout=timeout,
+ bearer_token=bearer_token,
+ )
+
+ def make_folder_public(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
+ """POST /{version}/files/folder/public
+
+ Publishes a whole folder prefix — one record, however many files sit
+ under it, at any depth. Answers with the ``nbpf_…`` id.
+
+ Keyword arguments: ``filesIntegrationId``, ``path``.
+ """
+ return self._transport.send(
+ target="hub",
+ path="/{version}/files/folder/public",
+ method="POST",
+ path_params={},
+ request=request,
+ scope="project",
+ timeout=timeout,
+ bearer_token=bearer_token,
+ )
+
+ def make_folder_private(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
+ """POST /{version}/files/folder/private
+
+ Takes back every link inside the folder, per-file links included.
+
+ Keyword arguments: ``filesIntegrationId``, ``path``.
+ """
+ return self._transport.send(
+ target="hub",
+ path="/{version}/files/folder/private",
+ method="POST",
+ path_params={},
+ request=request,
+ scope="project",
+ timeout=timeout,
+ bearer_token=bearer_token,
+ )
+
class AsyncFilesModule:
def __init__(self, transport: AsyncTransport) -> None:
@@ -443,6 +547,19 @@ async def save_files_integration(self, *, timeout: float | None = None, bearer_t
bearer_token=bearer_token,
)
+ async def test_files_integration(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
+ """POST /{version}/files/integrations/test"""
+ return await self._transport.send(
+ target="hub",
+ path="/{version}/files/integrations/test",
+ method="POST",
+ path_params={},
+ request=request,
+ scope="project",
+ timeout=timeout,
+ bearer_token=bearer_token,
+ )
+
async def set_files_integration_as_default(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
"""PUT /{version}/files/integrations/{Id}/default"""
return await self._transport.send(
@@ -455,3 +572,94 @@ async def set_files_integration_as_default(self, id: str, *, timeout: float | No
timeout=timeout,
bearer_token=bearer_token,
)
+
+ # ------------------------------------------------------------------
+ # Public file links (10b-files slice PUB). Hand-added by slice SDK-2 in
+ # the shape the module generator produces.
+ #
+ # A file, or a whole folder, can be made readable by anyone holding a
+ # link — no sign-in, no project id. The gateway mints an unguessable
+ # ``nbpf_…`` id and the link is
+ # ``https:///v3/files/public/nbpf_…/invoice.pdf``.
+ #
+ # Worth knowing before you call these:
+ # * publishing a folder is ONE record, whatever is under it;
+ # * asking twice gives the same id back;
+ # * a file cannot be made private on its own while a folder above it
+ # is public (CM-ERRORS-FILES-021) — switch the folder off instead;
+ # * the root cannot be published.
+ # ------------------------------------------------------------------
+
+ async def make_file_public(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
+ """POST /{version}/files/item/public
+
+ Makes one file readable by anyone holding its link. The file has to
+ exist already. Answers with ``id`` — the ``nbpf_…`` public id.
+
+ Keyword arguments: ``filesIntegrationId``, ``path``.
+ """
+ return await self._transport.send(
+ target="hub",
+ path="/{version}/files/item/public",
+ method="POST",
+ path_params={},
+ request=request,
+ scope="project",
+ timeout=timeout,
+ bearer_token=bearer_token,
+ )
+
+ async def make_file_private(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
+ """POST /{version}/files/item/private
+
+ Takes the file's public link away; opening it afterwards gives a 404.
+
+ Keyword arguments: ``filesIntegrationId``, ``path``.
+ """
+ return await self._transport.send(
+ target="hub",
+ path="/{version}/files/item/private",
+ method="POST",
+ path_params={},
+ request=request,
+ scope="project",
+ timeout=timeout,
+ bearer_token=bearer_token,
+ )
+
+ async def make_folder_public(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
+ """POST /{version}/files/folder/public
+
+ Publishes a whole folder prefix — one record, however many files sit
+ under it, at any depth. Answers with the ``nbpf_…`` id.
+
+ Keyword arguments: ``filesIntegrationId``, ``path``.
+ """
+ return await self._transport.send(
+ target="hub",
+ path="/{version}/files/folder/public",
+ method="POST",
+ path_params={},
+ request=request,
+ scope="project",
+ timeout=timeout,
+ bearer_token=bearer_token,
+ )
+
+ async def make_folder_private(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any:
+ """POST /{version}/files/folder/private
+
+ Takes back every link inside the folder, per-file links included.
+
+ Keyword arguments: ``filesIntegrationId``, ``path``.
+ """
+ return await self._transport.send(
+ target="hub",
+ path="/{version}/files/folder/private",
+ method="POST",
+ path_params={},
+ request=request,
+ scope="project",
+ timeout=timeout,
+ bearer_token=bearer_token,
+ )
diff --git a/src/norbix_python/transport.py b/src/norbix_python/transport.py
index 09f294a..d095cbd 100644
--- a/src/norbix_python/transport.py
+++ b/src/norbix_python/transport.py
@@ -13,6 +13,11 @@
HttpVerb = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
Scope = Literal["project", "account", "unauthenticated"]
+# How to read a successful response body. "json" (the default) parses it.
+# "binary" gives back the raw bytes — for an endpoint that answers with a
+# file rather than a document, such as a public file link. Parsing a PDF as
+# JSON quietly hands back its text instead (10b-files slice SDK-2).
+ResponseType = Literal["json", "binary"]
Target = Literal["api", "hub"]
_IDEMPOTENT_VERBS: frozenset[str] = frozenset({"GET", "DELETE"})
@@ -58,6 +63,8 @@ def send(
bearer_token: str | None = None,
env: str | None = None,
region: str | None = None,
+ response_type: ResponseType = "json",
+ follow_redirects: bool = False,
) -> Any:
if scope == "account" and not self._cfg.account_id:
raise NorbixError(
@@ -112,6 +119,7 @@ def send(
headers=headers,
json=body,
timeout=timeout or self._cfg.timeout,
+ follow_redirects=follow_redirects,
)
except httpx.HTTPError as exc:
raise NorbixError(message=str(exc), code="NORBIX_NETWORK_ERROR") from exc
@@ -130,6 +138,8 @@ def send(
details=data,
)
+ if response_type == "binary":
+ return response.content
if response.status_code == 204 or not response.content:
return None
try:
@@ -145,6 +155,7 @@ def _request_with_retries(
headers: dict[str, str],
json: dict[str, Any] | None,
timeout: float,
+ follow_redirects: bool = False,
) -> httpx.Response:
attempt = 0
last_response: httpx.Response | None = None
@@ -155,6 +166,7 @@ def _request_with_retries(
headers=headers,
json=json,
timeout=timeout,
+ follow_redirects=follow_redirects,
)
last_response = response
if response.status_code < 400:
@@ -193,6 +205,8 @@ async def send(
bearer_token: str | None = None,
env: str | None = None,
region: str | None = None,
+ response_type: ResponseType = "json",
+ follow_redirects: bool = False,
) -> Any:
if scope == "account" and not self._cfg.account_id:
raise NorbixError(
@@ -247,6 +261,7 @@ async def send(
headers=headers,
json=body,
timeout=timeout or self._cfg.timeout,
+ follow_redirects=follow_redirects,
)
except httpx.HTTPError as exc:
raise NorbixError(message=str(exc), code="NORBIX_NETWORK_ERROR") from exc
@@ -265,6 +280,8 @@ async def send(
details=data,
)
+ if response_type == "binary":
+ return response.content
if response.status_code == 204 or not response.content:
return None
try:
@@ -280,12 +297,18 @@ async def _request_with_retries(
headers: dict[str, str],
json: dict[str, Any] | None,
timeout: float,
+ follow_redirects: bool = False,
) -> httpx.Response:
attempt = 0
last_response: httpx.Response | None = None
while attempt < _DEFAULT_MAX_RETRIES:
response = await self._client.request(
- method=method, url=url, headers=headers, json=json, timeout=timeout
+ method=method,
+ url=url,
+ headers=headers,
+ json=json,
+ timeout=timeout,
+ follow_redirects=follow_redirects,
)
last_response = response
if response.status_code < 400:
diff --git a/tests/hub/test_files.py b/tests/hub/test_files.py
index 5edfd83..79cdf12 100644
--- a/tests/hub/test_files.py
+++ b/tests/hub/test_files.py
@@ -24,8 +24,16 @@ def test_hub_files_module_surface() -> None:
assert callable(module.get_files_integrations)
assert callable(module.save_files_integration)
assert callable(module.set_files_integration_as_default)
+ assert callable(module.test_files_integration)
+def test_hub_files_test_files_integration_request_shape() -> None:
+ client, transport = make_client(account_id=None)
+ client.hub.files.test_files_integration(integrationId="stub-integrationId")
+ assert transport.last_request['method'] == 'POST'
+ assert transport.last_request is not None
+ assert transport.last_request['url'].startswith('https://')
+
def test_hub_files_disable_files_request_shape() -> None:
client, transport = make_client(account_id=None)
client.hub.files.disable_files()
diff --git a/tests/test_files_routes.py b/tests/test_files_routes.py
new file mode 100644
index 0000000..4eb1377
--- /dev/null
+++ b/tests/test_files_routes.py
@@ -0,0 +1,195 @@
+"""Files endpoints: the route each SDK method actually calls.
+
+The generated tests in ``tests/hub/test_files.py`` and ``tests/api/test_files.py``
+only check the HTTP verb and that the address starts with ``https://``. A method
+pointed at the wrong path would still pass them. These tests check the path
+itself, and that the values the caller passes reach the query string or the body.
+
+One test per endpoint: 12 on the hub (the dashboard API) and 8 on the public API.
+Each test builds its own client and its own fake response, so the order the tests
+run in does not matter and no real storage provider is contacted.
+"""
+
+from __future__ import annotations
+
+import json
+from urllib.parse import urlparse
+
+from .helpers import make_client
+
+INTEGRATION_ID = "44444444-4444-4444-4444-444444444444"
+
+
+def _sent(transport):
+ """The last request, split into the parts the tests care about."""
+ request = transport.last_request
+ assert request is not None, "no request was sent"
+ url = urlparse(request["url"])
+ body = json.loads(request["body"]) if request["body"] else {}
+ return request["method"], url.path, url.query, body
+
+
+# --- hub: the module itself --------------------------------------------------
+
+
+def test_enable_files() -> None:
+ client, transport = make_client()
+ client.hub.files.enable_files()
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("GET", "/v2/files/enable")
+
+
+def test_disable_files() -> None:
+ client, transport = make_client()
+ client.hub.files.disable_files()
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("GET", "/v2/files/disable")
+
+
+# --- hub: browsing -----------------------------------------------------------
+
+
+def test_get_folder_files() -> None:
+ client, transport = make_client()
+ client.hub.files.get_folder_files(filesIntegrationId=INTEGRATION_ID, path="invoices/")
+ method, path, query, _ = _sent(transport)
+ assert (method, path) == ("GET", "/v2/files/folder")
+ assert INTEGRATION_ID in query
+
+
+def test_get_file() -> None:
+ client, transport = make_client()
+ client.hub.files.get_file(filesIntegrationId=INTEGRATION_ID, path="invoices/invoice.pdf")
+ method, path, query, _ = _sent(transport)
+ assert (method, path) == ("GET", "/v2/files/item")
+ assert INTEGRATION_ID in query
+
+
+# --- hub: integrations -------------------------------------------------------
+
+
+def test_get_files_integrations() -> None:
+ client, transport = make_client()
+ client.hub.files.get_files_integrations()
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("GET", "/v2/files/integrations")
+
+
+def test_get_files_integration() -> None:
+ client, transport = make_client()
+ client.hub.files.get_files_integration(id=INTEGRATION_ID)
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("GET", f"/v2/files/integrations/{INTEGRATION_ID}")
+
+
+def test_save_files_integration() -> None:
+ client, transport = make_client()
+ client.hub.files.save_files_integration(
+ integration={"integrationName": "Invoices bucket", "bucketName": "norbix-invoices"}
+ )
+ method, path, _, body = _sent(transport)
+ assert (method, path) == ("POST", "/v2/files/integrations")
+ assert body["integration"]["bucketName"] == "norbix-invoices"
+
+
+def test_delete_files_integration() -> None:
+ client, transport = make_client()
+ client.hub.files.delete_files_integration(id=INTEGRATION_ID)
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("DELETE", f"/v2/files/integrations/{INTEGRATION_ID}")
+
+
+def test_enable_files_integration() -> None:
+ client, transport = make_client()
+ client.hub.files.enable_files_integration(id=INTEGRATION_ID)
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("PUT", f"/v2/files/integrations/{INTEGRATION_ID}/enable")
+
+
+def test_disable_files_integration() -> None:
+ client, transport = make_client()
+ client.hub.files.disable_files_integration(id=INTEGRATION_ID)
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("PUT", f"/v2/files/integrations/{INTEGRATION_ID}/disable")
+
+
+def test_set_files_integration_as_default() -> None:
+ client, transport = make_client()
+ client.hub.files.set_files_integration_as_default(id=INTEGRATION_ID)
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("PUT", f"/v2/files/integrations/{INTEGRATION_ID}/default")
+
+
+def test_test_files_integration() -> None:
+ client, transport = make_client()
+ client.hub.files.test_files_integration(integrationId=INTEGRATION_ID)
+ method, path, _, body = _sent(transport)
+ assert (method, path) == ("POST", "/v2/files/integrations/test")
+ assert body["integrationId"] == INTEGRATION_ID
+
+
+# --- public API --------------------------------------------------------------
+
+
+def test_list_files() -> None:
+ client, transport = make_client()
+ client.api.files.list_files(INTEGRATION_ID, path="invoices/")
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("GET", f"/v2/files/{INTEGRATION_ID}")
+
+
+def test_get_file_info() -> None:
+ client, transport = make_client()
+ client.api.files.get_file_info(INTEGRATION_ID, path="invoices/invoice.pdf")
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("GET", f"/v2/files/{INTEGRATION_ID}/info")
+
+
+def test_get_signed_url() -> None:
+ client, transport = make_client()
+ client.api.files.get_signed_url(INTEGRATION_ID, path="invoices/invoice.pdf")
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("GET", f"/v2/files/{INTEGRATION_ID}/sign")
+
+
+def test_request_upload_url() -> None:
+ client, transport = make_client()
+ client.api.files.request_upload_url(
+ INTEGRATION_ID, path="invoices/invoice.pdf", contentType="application/pdf"
+ )
+ method, path, _, body = _sent(transport)
+ assert (method, path) == ("POST", f"/v2/files/{INTEGRATION_ID}/upload-url")
+ assert body["contentType"] == "application/pdf"
+
+
+def test_commit_upload() -> None:
+ client, transport = make_client()
+ client.api.files.commit_upload(
+ INTEGRATION_ID, path="invoices/invoice.pdf", contentType="application/pdf", sizeBytes=1024
+ )
+ method, path, _, body = _sent(transport)
+ assert (method, path) == ("POST", f"/v2/files/{INTEGRATION_ID}/commit")
+ assert body["path"] == "invoices/invoice.pdf"
+
+
+def test_download_file() -> None:
+ client, transport = make_client()
+ client.api.files.download_file_api(INTEGRATION_ID, path="invoices/invoice.pdf")
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("GET", f"/v2/files/{INTEGRATION_ID}/download")
+
+
+def test_delete_file() -> None:
+ client, transport = make_client()
+ client.api.files.delete_file_api(INTEGRATION_ID, path="invoices/invoice.pdf")
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("DELETE", f"/v2/files/{INTEGRATION_ID}")
+
+
+def test_delete_many_files() -> None:
+ client, transport = make_client()
+ client.api.files.delete_many_files_api(
+ INTEGRATION_ID, paths=["invoices/a.pdf", "invoices/b.pdf"]
+ )
+ method, path, _, _ = _sent(transport)
+ assert (method, path) == ("DELETE", f"/v2/files/{INTEGRATION_ID}/bulk")
diff --git a/tests/test_public_files.py b/tests/test_public_files.py
new file mode 100644
index 0000000..5ebf0dc
--- /dev/null
+++ b/tests/test_public_files.py
@@ -0,0 +1,199 @@
+"""Public file links — the five endpoints slice PUB added to the gateway.
+
+10b-files, slice SDK-2. Written by hand: the generated tests in
+``tests/hub/test_files.py`` and ``tests/api/test_files.py`` are refreshed from
+the module sources and would lose these.
+
+Each test builds its own client and its own fake response, so the order the
+tests run in does not matter and no real storage provider is contacted.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+from urllib.parse import urlparse
+
+import httpx
+import pytest
+
+from norbix_python import Norbix
+
+from .helpers import make_client
+
+INTEGRATION_ID = "44444444-4444-4444-4444-444444444444"
+PUBLIC_ID = "nbpf_7hK2abc"
+
+
+def _sent(transport):
+ request = transport.last_request
+ assert request is not None, "no request was sent"
+ url = urlparse(request["url"])
+ body = json.loads(request["body"]) if request["body"] else {}
+ return request["method"], url.path, body, request["headers"]
+
+
+# --- hub: make it public, make it private ------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("method_name", "expected_path"),
+ [
+ ("make_file_public", "/v2/files/item/public"),
+ ("make_file_private", "/v2/files/item/private"),
+ ("make_folder_public", "/v2/files/folder/public"),
+ ("make_folder_private", "/v2/files/folder/private"),
+ ],
+)
+def test_hub_method_hits_its_own_route(method_name: str, expected_path: str) -> None:
+ client, transport = make_client()
+ getattr(client.hub.files, method_name)(
+ filesIntegrationId=INTEGRATION_ID, path="invoices/invoice.pdf"
+ )
+ verb, path, body, headers = _sent(transport)
+ assert (verb, path) == ("POST", expected_path)
+ assert body == {"filesIntegrationId": INTEGRATION_ID, "path": "invoices/invoice.pdf"}
+ assert headers["authorization"] == "Bearer test-token"
+ assert headers["x-cm-projectid"] == "test-project"
+
+
+def test_make_file_public_gives_back_the_minted_id() -> None:
+ class Answers(httpx.BaseTransport):
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, json={"id": PUBLIC_ID, "status": "Success"})
+
+ client = Norbix(
+ project_id="p",
+ bearer_token="t",
+ http_client=httpx.Client(transport=Answers()),
+ )
+ result = client.hub.files.make_file_public(
+ filesIntegrationId=INTEGRATION_ID, path="invoices/invoice.pdf"
+ )
+ assert result["id"] == PUBLIC_ID
+
+
+def test_all_four_methods_exist_sync_and_async() -> None:
+ from norbix_python.hub.files import AsyncFilesModule, FilesModule
+
+ for name in (
+ "make_file_public",
+ "make_file_private",
+ "make_folder_public",
+ "make_folder_private",
+ ):
+ assert callable(getattr(FilesModule, name)), name
+ assert callable(getattr(AsyncFilesModule, name)), name
+
+
+# --- api: the link anyone can open -------------------------------------------
+
+
+class BinaryTransport(httpx.BaseTransport):
+ """Answers with real bytes, like a file server does."""
+
+ def __init__(self, payload: bytes, status: int = 200) -> None:
+ self.payload = payload
+ self.status = status
+ self.last_request: dict[str, Any] | None = None
+
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
+ self.last_request = {
+ "url": str(request.url),
+ "headers": dict(request.headers),
+ }
+ return httpx.Response(
+ self.status,
+ content=self.payload,
+ headers={"Content-Type": "application/pdf"},
+ )
+
+
+def _public_client(transport: httpx.BaseTransport) -> Norbix:
+ # No bearer token and no API key: somebody who has never signed in. A
+ # project_id is still required to build the client at all — see finding F3
+ # in the slice report; a public link itself carries none.
+ return Norbix(
+ project_id="not-used-by-a-public-link",
+ api_version="v3",
+ http_client=httpx.Client(transport=transport),
+ )
+
+
+def test_get_public_file_returns_the_bytes_unchanged() -> None:
+ payload = b"%PDF-1.7 hello"
+ transport = BinaryTransport(payload)
+
+ result = _public_client(transport).api.files.get_public_file(PUBLIC_ID, "invoice.pdf")
+
+ assert result == payload
+ assert urlparse(transport.last_request["url"]).path == f"/v3/files/public/{PUBLIC_ID}/invoice.pdf"
+
+
+def test_get_public_file_sends_no_authorization_header() -> None:
+ transport = BinaryTransport(b"x")
+
+ _public_client(transport).api.files.get_public_file(PUBLIC_ID, "invoice.pdf")
+
+ assert "authorization" not in transport.last_request["headers"]
+
+
+def test_get_public_file_sends_no_authorization_even_when_signed_in() -> None:
+ transport = BinaryTransport(b"x")
+ client = Norbix(
+ project_id="test-project",
+ bearer_token="a-real-session",
+ api_version="v3",
+ http_client=httpx.Client(transport=transport),
+ )
+
+ client.api.files.get_public_file(PUBLIC_ID, "invoice.pdf")
+
+ assert "authorization" not in transport.last_request["headers"]
+
+
+def test_a_folder_relative_path_keeps_its_slashes() -> None:
+ transport = BinaryTransport(b"x")
+
+ _public_client(transport).api.files.get_public_file("nbpf_folder1", "2026/q1/report.pdf")
+
+ assert (
+ urlparse(transport.last_request["url"]).path
+ == "/v3/files/public/nbpf_folder1/2026/q1/report.pdf"
+ )
+
+
+def test_a_link_that_points_at_nothing_raises() -> None:
+ transport = BinaryTransport(b"", status=404)
+
+ with pytest.raises(Exception) as caught:
+ _public_client(transport).api.files.get_public_file("nbpf_gone", "invoice.pdf")
+
+ assert getattr(caught.value, "status", None) == 404
+
+
+def test_it_follows_the_redirect_a_signing_provider_answers_with() -> None:
+ """S3 / Azure / Google Cloud sign their own links, so the gateway replies
+ 302 and the bytes come from the provider. httpx does NOT follow redirects
+ by default, so without ``follow_redirects=True`` this call would hand back
+ an empty 302 and look like an empty file."""
+ payload = b"bytes-from-the-provider"
+
+ class Redirecting(httpx.BaseTransport):
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
+ if "files/public" in str(request.url):
+ return httpx.Response(
+ 302, headers={"Location": "https://bucket.s3.example.com/signed"}
+ )
+ return httpx.Response(200, content=payload)
+
+ result = _public_client(Redirecting()).api.files.get_public_file(PUBLIC_ID, "invoice.pdf")
+
+ assert result == payload
+
+
+def test_get_public_file_exists_sync_and_async() -> None:
+ from norbix_python.api.files import AsyncFilesModule, FilesModule
+
+ assert callable(FilesModule.get_public_file)
+ assert callable(AsyncFilesModule.get_public_file)