diff --git a/openapi/openapi.yml b/openapi/openapi.yml index d30f8a1..ea74378 100644 --- a/openapi/openapi.yml +++ b/openapi/openapi.yml @@ -1930,6 +1930,35 @@ paths: schema: $ref: '#/components/schemas/PolicyVersion' description: '' + /v1/tables/upload/: + post: + operationId: upload_table + description: Create a Roe table in the authenticated organization from an uploaded + CSV file. Organization API keys are scoped to one organization; if organization_id + is supplied, it must match that organization. + summary: Upload a CSV as a Roe table + tags: + - tables + - sdk + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/TableUploadRequest' + required: true + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/TableUploadResponse' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Bad request /v1/users/current_user/: get: operationId: users_current_user_retrieve @@ -2734,6 +2763,49 @@ components: - display_name - email - id + TableUploadRequest: + type: object + description: Serializer for public CSV table uploads. + properties: + table_name: + type: string + minLength: 1 + description: Name of the Roe table to create from the uploaded CSV + maxLength: 128 + file: + type: string + format: binary + description: CSV file to upload + with_headers: + type: boolean + default: true + description: Whether the first row of the CSV contains column headers + organization_id: + type: + - string + - 'null' + format: uuid + description: Optional organization ID. Organization API keys are already + scoped to one organization; if supplied, this must match that organization. + required: + - file + - table_name + TableUploadResponse: + type: object + description: Response payload for a public CSV table upload. + properties: + table_name: + type: string + description: Created Roe table name + organization_id: + type: string + format: uuid + description: Organization that owns the table + summary: + description: ClickHouse import summary for the uploaded file + required: + - organization_id + - table_name UpdatePolicy: type: object description: Serializer for updating policy metadata (name, description) diff --git a/src/roe/__init__.py b/src/roe/__init__.py index 0dea65b..73db699 100644 --- a/src/roe/__init__.py +++ b/src/roe/__init__.py @@ -35,6 +35,7 @@ ) from roe.models import FileUpload from roe.models.job import Job, JobBatch, JobStatus +from roe.api.tables import TablesAPI try: __version__ = version("roe-ai") @@ -51,6 +52,7 @@ "JobStatus", # File upload helper "FileUpload", + "TablesAPI", # Exceptions "RoeAPIException", "AuthenticationError", diff --git a/src/roe/_generated/api/tables/__init__.py b/src/roe/_generated/api/tables/__init__.py new file mode 100644 index 0000000..c9921b5 --- /dev/null +++ b/src/roe/_generated/api/tables/__init__.py @@ -0,0 +1 @@ +""" Contains endpoint functions for accessing the API """ diff --git a/src/roe/_generated/api/tables/upload_table.py b/src/roe/_generated/api/tables/upload_table.py new file mode 100644 index 0000000..f265427 --- /dev/null +++ b/src/roe/_generated/api/tables/upload_table.py @@ -0,0 +1,202 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.error_response import ErrorResponse +from ...models.table_upload_request import TableUploadRequest +from ...models.table_upload_response import TableUploadResponse +from typing import cast + + + +def _get_kwargs( + *, + body: TableUploadRequest, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + + + + + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/v1/tables/upload/", + } + + _kwargs["files"] = body.to_multipart() + + + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> ErrorResponse | TableUploadResponse | None: + if response.status_code == 201: + response_201 = TableUploadResponse.from_dict(response.json()) + + + + return response_201 + + if response.status_code == 400: + response_400 = ErrorResponse.from_dict(response.json()) + + + + return response_400 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[ErrorResponse | TableUploadResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: TableUploadRequest, + +) -> Response[ErrorResponse | TableUploadResponse]: + """ Upload a CSV as a Roe table + + Create a Roe table in the authenticated organization from an uploaded CSV file. Organization API + keys are scoped to one organization; if organization_id is supplied, it must match that + organization. + + Args: + body (TableUploadRequest): Serializer for public CSV table uploads. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | TableUploadResponse] + """ + + + kwargs = _get_kwargs( + body=body, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + +def sync( + *, + client: AuthenticatedClient | Client, + body: TableUploadRequest, + +) -> ErrorResponse | TableUploadResponse | None: + """ Upload a CSV as a Roe table + + Create a Roe table in the authenticated organization from an uploaded CSV file. Organization API + keys are scoped to one organization; if organization_id is supplied, it must match that + organization. + + Args: + body (TableUploadRequest): Serializer for public CSV table uploads. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | TableUploadResponse + """ + + + return sync_detailed( + client=client, +body=body, + + ).parsed + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: TableUploadRequest, + +) -> Response[ErrorResponse | TableUploadResponse]: + """ Upload a CSV as a Roe table + + Create a Roe table in the authenticated organization from an uploaded CSV file. Organization API + keys are scoped to one organization; if organization_id is supplied, it must match that + organization. + + Args: + body (TableUploadRequest): Serializer for public CSV table uploads. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | TableUploadResponse] + """ + + + kwargs = _get_kwargs( + body=body, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return _build_response(client=client, response=response) + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: TableUploadRequest, + +) -> ErrorResponse | TableUploadResponse | None: + """ Upload a CSV as a Roe table + + Create a Roe table in the authenticated organization from an uploaded CSV file. Organization API + keys are scoped to one organization; if organization_id is supplied, it must match that + organization. + + Args: + body (TableUploadRequest): Serializer for public CSV table uploads. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | TableUploadResponse + """ + + + return (await asyncio_detailed( + client=client, +body=body, + + )).parsed diff --git a/src/roe/_generated/models/__init__.py b/src/roe/_generated/models/__init__.py index 7572c7c..3824c71 100644 --- a/src/roe/_generated/models/__init__.py +++ b/src/roe/_generated/models/__init__.py @@ -32,6 +32,8 @@ from .policy import Policy from .policy_version import PolicyVersion from .policy_version_created_by import PolicyVersionCreatedBy +from .table_upload_request import TableUploadRequest +from .table_upload_response import TableUploadResponse from .update_policy import UpdatePolicy from .update_policy_request import UpdatePolicyRequest from .user_info import UserInfo @@ -69,6 +71,8 @@ "Policy", "PolicyVersion", "PolicyVersionCreatedBy", + "TableUploadRequest", + "TableUploadResponse", "UpdatePolicy", "UpdatePolicyRequest", "UserInfo", diff --git a/src/roe/_generated/models/table_upload_request.py b/src/roe/_generated/models/table_upload_request.py new file mode 100644 index 0000000..7c715ee --- /dev/null +++ b/src/roe/_generated/models/table_upload_request.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +import json +from .. import types + +from ..types import UNSET, Unset + +from ..types import File, FileTypes +from ..types import UNSET, Unset +from io import BytesIO +from typing import cast +from uuid import UUID + + + + + + +T = TypeVar("T", bound="TableUploadRequest") + + + +@_attrs_define +class TableUploadRequest: + """ Serializer for public CSV table uploads. + + Attributes: + table_name (str): Name of the Roe table to create from the uploaded CSV + file (File): CSV file to upload + with_headers (bool | Unset): Whether the first row of the CSV contains column headers Default: True. + organization_id (None | Unset | UUID): Optional organization ID. Organization API keys are already scoped to one + organization; if supplied, this must match that organization. + """ + + table_name: str + file: File + with_headers: bool | Unset = True + organization_id: None | Unset | UUID = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + table_name = self.table_name + + file = self.file.to_tuple() + + + with_headers = self.with_headers + + organization_id: None | str | Unset + if isinstance(self.organization_id, Unset): + organization_id = UNSET + elif isinstance(self.organization_id, UUID): + organization_id = str(self.organization_id) + else: + organization_id = self.organization_id + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "table_name": table_name, + "file": file, + }) + if with_headers is not UNSET: + field_dict["with_headers"] = with_headers + if organization_id is not UNSET: + field_dict["organization_id"] = organization_id + + return field_dict + + + def to_multipart(self) -> types.RequestFiles: + files: types.RequestFiles = [] + + files.append(("table_name", (None, str(self.table_name).encode(), "text/plain"))) + + + + files.append(("file", self.file.to_tuple())) + + + + if not isinstance(self.with_headers, Unset): + files.append(("with_headers", (None, str(self.with_headers).encode(), "text/plain"))) + + + + if not isinstance(self.organization_id, Unset): + if isinstance(self.organization_id, UUID): + + files.append(("organization_id", (None, str(self.organization_id), "text/plain"))) + else: + files.append(("organization_id", (None, str(self.organization_id).encode(), "text/plain"))) + + + + for prop_name, prop in self.additional_properties.items(): + files.append((prop_name, (None, str(prop).encode(), "text/plain"))) + + + + return files + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + table_name = d.pop("table_name") + + file = File( + payload = BytesIO(d.pop("file")) + ) + + + + + with_headers = d.pop("with_headers", UNSET) + + def _parse_organization_id(data: object) -> None | Unset | UUID: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + organization_id_type_0 = UUID(data) + + + + return organization_id_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | Unset | UUID, data) + + organization_id = _parse_organization_id(d.pop("organization_id", UNSET)) + + + table_upload_request = cls( + table_name=table_name, + file=file, + with_headers=with_headers, + organization_id=organization_id, + ) + + + table_upload_request.additional_properties = d + return table_upload_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/roe/_generated/models/table_upload_response.py b/src/roe/_generated/models/table_upload_response.py new file mode 100644 index 0000000..ed098ec --- /dev/null +++ b/src/roe/_generated/models/table_upload_response.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..types import UNSET, Unset +from uuid import UUID + + + + + + +T = TypeVar("T", bound="TableUploadResponse") + + + +@_attrs_define +class TableUploadResponse: + """ Response payload for a public CSV table upload. + + Attributes: + table_name (str): Created Roe table name + organization_id (UUID): Organization that owns the table + summary (Any | Unset): ClickHouse import summary for the uploaded file + """ + + table_name: str + organization_id: UUID + summary: Any | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + table_name = self.table_name + + organization_id = str(self.organization_id) + + summary = self.summary + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "table_name": table_name, + "organization_id": organization_id, + }) + if summary is not UNSET: + field_dict["summary"] = summary + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + table_name = d.pop("table_name") + + organization_id = UUID(d.pop("organization_id")) + + + + + summary = d.pop("summary", UNSET) + + table_upload_response = cls( + table_name=table_name, + organization_id=organization_id, + summary=summary, + ) + + + table_upload_response.additional_properties = d + return table_upload_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/roe/api/__init__.py b/src/roe/api/__init__.py index 39f5d6e..cdfc4b7 100644 --- a/src/roe/api/__init__.py +++ b/src/roe/api/__init__.py @@ -2,5 +2,6 @@ from roe.api.agents import AgentsAPI from roe.api.policies import PoliciesAPI +from roe.api.tables import TablesAPI -__all__ = ["AgentsAPI", "PoliciesAPI"] +__all__ = ["AgentsAPI", "PoliciesAPI", "TablesAPI"] diff --git a/src/roe/api/tables.py b/src/roe/api/tables.py new file mode 100644 index 0000000..885405e --- /dev/null +++ b/src/roe/api/tables.py @@ -0,0 +1,130 @@ +"""Tables API — helpers for uploading Roe tables.""" + +from __future__ import annotations + +from io import BytesIO +import mimetypes +from pathlib import Path +from typing import BinaryIO +from uuid import UUID + +from roe._generated.api.tables import upload_table +from roe._generated.client import AuthenticatedClient +from roe._generated.models.table_upload_request import TableUploadRequest +from roe._generated.models.table_upload_response import TableUploadResponse +from roe._generated.types import UNSET, File, Unset +from roe.config import RoeConfig +from roe.exceptions import translate_response +from roe.models import FileUpload + + +class TablesAPI: + """API for uploading CSV files into Roe tables.""" + + def __init__(self, config: RoeConfig, raw_client: AuthenticatedClient): + self.config = config + self._raw = raw_client + + def upload( + self, + *, + table_name: str, + file: str | Path | bytes | BinaryIO | FileUpload, + with_headers: bool = True, + organization_id: str | UUID | None = None, + filename: str | None = None, + mime_type: str | None = None, + ) -> TableUploadResponse: + """Upload a CSV file and create a Roe table. + + Args: + table_name: Name of the table to create. + file: CSV file path, bytes, binary file object, or ``FileUpload``. + with_headers: Whether the first CSV row contains column headers. + organization_id: Optional override; defaults to the client's configured org. + filename: Filename to use for bytes/file objects. + mime_type: MIME type override. Defaults to ``text/csv`` for ``.csv`` names. + """ + + # Coerce to UUID once; pass UNSET (not None) when no org id is + # available so the generated multipart serializer omits the form + # field cleanly. Sending a literal "None" would hit the backend + # UUID validator and return 400. + resolved_org: UUID | Unset + candidate = organization_id or self.config.organization_id + resolved_org = UUID(str(candidate)) if candidate else UNSET + + upload_file, close_after = self._as_generated_file(file, filename, mime_type) + try: + body = TableUploadRequest( + table_name=table_name, + file=upload_file, + with_headers=with_headers, + organization_id=resolved_org, + ) + resp = upload_table.sync_detailed(client=self._raw, body=body) + translate_response(resp) + return resp.parsed # type: ignore[return-value] + finally: + if close_after: + upload_file.payload.close() + + @staticmethod + def _as_generated_file( + file: str | Path | bytes | BinaryIO | FileUpload, + filename: str | None, + mime_type: str | None, + ) -> tuple[File, bool]: + if isinstance(file, FileUpload): + payload = file.open() + effective_filename = filename or file.effective_filename + effective_mime_type = mime_type or file.effective_mime_type + return ( + File( + payload=payload, + file_name=effective_filename, + mime_type=effective_mime_type, + ), + file.path is not None, + ) + + if isinstance(file, (str, Path)): + path = Path(file) + payload = path.open("rb") + effective_filename = filename or path.name + return ( + File( + payload=payload, + file_name=effective_filename, + mime_type=_mime_type(effective_filename, mime_type), + ), + True, + ) + + if isinstance(file, bytes): + effective_filename = filename or "upload.csv" + return ( + File( + payload=BytesIO(file), + file_name=effective_filename, + mime_type=_mime_type(effective_filename, mime_type), + ), + True, + ) + + effective_filename = filename or Path(getattr(file, "name", "upload.csv")).name + return ( + File( + payload=file, + file_name=effective_filename, + mime_type=_mime_type(effective_filename, mime_type), + ), + False, + ) + + +def _mime_type(filename: str, override: str | None) -> str: + if override: + return override + guessed, _ = mimetypes.guess_type(filename) + return guessed or "text/csv" diff --git a/src/roe/client.py b/src/roe/client.py index 66aaef5..6c02111 100644 --- a/src/roe/client.py +++ b/src/roe/client.py @@ -5,6 +5,7 @@ from roe._generated.client import AuthenticatedClient as RawClient from roe.api.agents import AgentsAPI from roe.api.policies import PoliciesAPI +from roe.api.tables import TablesAPI from roe.api.users import UsersAPI from roe.auth import RoeAuth from roe.config import RoeConfig @@ -93,6 +94,7 @@ def __init__( # Create API instances. All APIs delegate to the generated raw client. self._agents = AgentsAPI(self.config, self._raw) self._policies = PoliciesAPI(self.config, self._raw) + self._tables = TablesAPI(self.config, self._raw) self._users = UsersAPI(self.config, self._raw) @property @@ -158,6 +160,15 @@ def users(self) -> UsersAPI: """ return self._users + @property + def tables(self) -> TablesAPI: + """Access the tables API. + + Returns: + TablesAPI instance for uploading CSV files into Roe tables. + """ + return self._tables + @property def raw(self) -> RawClient: """Access the generated raw client."""