-
Notifications
You must be signed in to change notification settings - Fork 10
feat(lib): typed SDK return models + py.typed marker (#428) #429
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,12 +12,16 @@ | |
| meta = kbc.files.upload(b"hello", name="greeting.txt", tags=["x"]) | ||
| data = kbc.files.read_bytes(meta.id) # bytes | ||
| metas = kbc.files.list(tags=["x"]) # list[FileEntry] | ||
| job = kbc.run_job("keboola.ex-db-snowflake", "12345", wait=True) # JobResult | ||
|
|
||
| The facade is a thin convenience wrapper over :class:`KeboolaClient` | ||
| (``client.py``); it adds the high-level shapes -- ``list[dict]`` rows, ``bytes`` | ||
| file reads, a stable :class:`FileEntry` -- that the CLI used to assemble inside | ||
| its service layer. Auth is the storage token passed at construction (12-factor: | ||
| read it from ``KBC_TOKEN`` yourself); nothing is persisted to disk. | ||
| file reads, a stable :class:`FileEntry`, and the typed result models in | ||
| ``result_models.py`` (:class:`JobResult`, :class:`QueryResult`, | ||
| :class:`UploadTableResult`, :class:`ConfigDetailResult`) -- that the CLI used to | ||
| assemble inside its service layer. Auth is the storage token passed at | ||
| construction (12-factor: read it from ``KBC_TOKEN`` yourself); nothing is | ||
| persisted to disk. | ||
|
|
||
| Everything exported here is committed public API and changes follow semver. For | ||
| lower-level access (raw Queue/Storage endpoints) reach for the underlying | ||
|
|
@@ -33,8 +37,14 @@ | |
| from typing import Any | ||
|
|
||
| from .client import KeboolaClient, _collect_inline_results | ||
| from .constants import QUERY_RESULTS_DEFAULT_LIMIT | ||
| from .constants import ( | ||
| DEFAULT_JOB_MODE, | ||
| DEFAULT_JOB_RUN_TIMEOUT, | ||
| DEFAULT_POLL_STRATEGY, | ||
| QUERY_RESULTS_DEFAULT_LIMIT, | ||
| ) | ||
| from .errors import ErrorCode, KeboolaApiError | ||
| from .result_models import ConfigDetailResult, JobResult, QueryResult, UploadTableResult | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
@@ -234,6 +244,54 @@ def _effective_branch_id(self) -> int: | |
| error_code=ErrorCode.NOT_FOUND, | ||
| ) | ||
|
|
||
| def _run_query( | ||
| self, | ||
| workspace_id: int, | ||
| sql: str, | ||
| *, | ||
| transactional: bool, | ||
| limit: int, | ||
| ) -> QueryResult: | ||
| """Submit SQL, wait for completion, and collect the last result set. | ||
|
|
||
| Shared by :meth:`query` (which returns just the rows) and | ||
| :meth:`query_result` (which returns the full typed shape). Mirrors the | ||
| Query Service inline-results fast path: the rows of the *last* | ||
| result-producing statement win, statements without a result set yield | ||
| nothing, and an over-``limit`` result is capped with a logged warning. | ||
| """ | ||
| branch_id = self._effective_branch_id() | ||
| job = self._client.submit_query( | ||
| branch_id=branch_id, | ||
| workspace_id=workspace_id, | ||
| statements=[sql], | ||
| transactional=transactional, | ||
| ) | ||
| job_id = str(job.get("queryJobId", job.get("id", ""))) | ||
| completed = self._client.wait_for_query_job(job_id) | ||
|
|
||
| result = QueryResult() | ||
| for stmt in completed.get("statements", []): | ||
| num_rows = stmt.get("numberOfRows", stmt.get("resultRows", 0)) | ||
| if stmt.get("status") != "completed" or not num_rows: | ||
| continue | ||
| inline = _collect_inline_results(self._client, job_id, str(stmt.get("id", "")), limit) | ||
| col_names = [col.get("name", "") for col in inline.columns] | ||
| result = QueryResult( | ||
| columns=col_names, | ||
| rows=[dict(zip(col_names, row, strict=False)) for row in inline.rows], | ||
| truncated=inline.truncated, | ||
| total_rows=inline.total_rows, | ||
| ) | ||
| if inline.truncated: | ||
| logger.warning( | ||
| "query result truncated to %d rows (warehouse has %s); " | ||
| "raise limit= to fetch more", | ||
| result.row_count, | ||
| inline.total_rows, | ||
| ) | ||
| return result | ||
|
|
||
| def query( | ||
| self, | ||
| workspace_id: int, | ||
|
|
@@ -259,6 +317,8 @@ def query( | |
| statement that produced a result set are returned (so ``USE ...; SELECT | ||
| ...`` yields the SELECT). Statements without a result set yield ``[]``. | ||
|
|
||
| For column order and truncation metadata, use :meth:`query_result`. | ||
|
|
||
| Args: | ||
| workspace_id: Target workspace ID. | ||
| sql: One or more SQL statements. | ||
|
|
@@ -267,32 +327,143 @@ def query( | |
| If the warehouse has more, the result is capped and a warning is | ||
| logged -- raise ``limit`` to fetch more. | ||
| """ | ||
| branch_id = self._effective_branch_id() | ||
| job = self._client.submit_query( | ||
| branch_id=branch_id, | ||
| workspace_id=workspace_id, | ||
| statements=[sql], | ||
| transactional=transactional, | ||
| return self._run_query(workspace_id, sql, transactional=transactional, limit=limit).rows | ||
|
|
||
| def query_result( | ||
| self, | ||
| workspace_id: int, | ||
| sql: str, | ||
| *, | ||
| transactional: bool = False, | ||
| limit: int = QUERY_RESULTS_DEFAULT_LIMIT, | ||
| ) -> QueryResult: | ||
| """Run SQL in a workspace and return a typed :class:`QueryResult`. | ||
|
|
||
| Same execution as :meth:`query`, but returns the full tabular shape -- | ||
| ``columns`` (in warehouse order), ``rows`` (list of dicts), ``truncated`` | ||
| and ``total_rows`` -- instead of just the row list. Use this when you | ||
| need the column ordering or want to detect a ``limit`` cap. The | ||
| string-typing gotcha from :meth:`query` applies to ``rows`` here too. | ||
| """ | ||
| return self._run_query(workspace_id, sql, transactional=transactional, limit=limit) | ||
|
|
||
| def run_job( | ||
| self, | ||
| component_id: str, | ||
| config_id: str, | ||
| *, | ||
| config_row_ids: list[str] | None = None, | ||
| variable_values_id: str | None = None, | ||
| branch_id: int | None = None, | ||
| mode: str = DEFAULT_JOB_MODE, | ||
| wait: bool = False, | ||
| timeout: float = DEFAULT_JOB_RUN_TIMEOUT, | ||
| poll_strategy: str = DEFAULT_POLL_STRATEGY, | ||
| ) -> JobResult: | ||
| """Run a Queue API job and return a typed :class:`JobResult`. | ||
|
|
||
| Creates the job, and -- when ``wait=True`` -- polls until it reaches a | ||
| terminal state (or ``timeout`` elapses). Unlike ``JobService.run_job`` | ||
| this thin facade does **not** auto-resolve linked variable values; pass | ||
| ``variable_values_id`` explicitly if the config needs a values row. | ||
|
|
||
| Args: | ||
| component_id: Component to run, e.g. ``keboola.ex-db-snowflake``. | ||
| config_id: Configuration ID to run. | ||
| config_row_ids: Optional row IDs (omit to run the whole config). | ||
| variable_values_id: Optional explicit values row for linked variables. | ||
| branch_id: Dev branch to run on. Defaults to the client's branch | ||
| (``None`` = production). | ||
| mode: Queue job mode (``run`` | ``debug`` | ``forceRun``). | ||
| wait: If True, poll until the job finishes or ``timeout`` elapses. | ||
| timeout: Max seconds to wait (only used when ``wait=True``). | ||
| poll_strategy: Wait cadence, one of ``VALID_POLL_STRATEGIES``. | ||
| """ | ||
| effective_branch = branch_id if branch_id is not None else self._resolved_branch_id | ||
| job = self._client.create_job( | ||
| component_id=component_id, | ||
| config_id=config_id, | ||
| config_row_ids=config_row_ids, | ||
| mode=mode, | ||
| branch_id=effective_branch, | ||
| variable_values_id=variable_values_id, | ||
| ) | ||
| job_id = str(job.get("queryJobId", job.get("id", ""))) | ||
| completed = self._client.wait_for_query_job(job_id) | ||
| job_id = str(job.get("id", "")) | ||
| if wait and job_id: | ||
| job = self._client.wait_for_queue_job( | ||
| job_id, max_wait=timeout, poll_strategy=poll_strategy | ||
| ) | ||
| return JobResult.model_validate(job) | ||
|
|
||
| rows: list[dict[str, Any]] = [] | ||
| for stmt in completed.get("statements", []): | ||
| num_rows = stmt.get("numberOfRows", stmt.get("resultRows", 0)) | ||
| if stmt.get("status") != "completed" or not num_rows: | ||
| continue | ||
| inline = _collect_inline_results(self._client, job_id, str(stmt.get("id", "")), limit) | ||
| col_names = [col.get("name", "") for col in inline.columns] | ||
| rows = [dict(zip(col_names, row, strict=False)) for row in inline.rows] | ||
| if inline.truncated: | ||
| logger.warning( | ||
| "query result truncated to %d rows (warehouse has %s); " | ||
| "raise limit= to fetch more", | ||
| len(rows), | ||
| inline.total_rows, | ||
| ) | ||
| return rows | ||
| def config_detail( | ||
| self, | ||
| component_id: str, | ||
| config_id: str, | ||
| *, | ||
| branch_id: int | None = None, | ||
| ) -> ConfigDetailResult: | ||
| """Fetch one configuration's detail as a typed :class:`ConfigDetailResult`. | ||
|
|
||
| Args: | ||
| component_id: Owning component ID. | ||
| config_id: Configuration ID. | ||
| branch_id: Dev branch to read from. Defaults to the client's branch | ||
| (``None`` = production). | ||
| """ | ||
| effective_branch = branch_id if branch_id is not None else self._resolved_branch_id | ||
| detail = dict( | ||
| self._client.get_config_detail(component_id, config_id, branch_id=effective_branch) | ||
| ) | ||
| detail.setdefault("component_id", component_id) | ||
| if effective_branch is not None: | ||
| detail.setdefault("branch_id", effective_branch) | ||
|
Comment on lines
+413
to
+419
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Lazy default-branch cache in The field Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| return ConfigDetailResult.model_validate(detail) | ||
|
|
||
| def upload_table( | ||
| self, | ||
| table_id: str, | ||
| file_path: str | Path, | ||
| *, | ||
| incremental: bool = False, | ||
| delimiter: str = ",", | ||
| enclosure: str = '"', | ||
| branch_id: int | None = None, | ||
| ) -> UploadTableResult: | ||
| """Import a CSV into an **existing** Storage table -> :class:`UploadTableResult`. | ||
|
|
||
| Unlike ``StorageService.upload_table`` the facade does **not** auto-create | ||
| a missing bucket/table (it has no config-dir / service context); the | ||
| target table must already exist. Use the CLI (``kbagent storage | ||
| upload-table``) for the auto-create path. | ||
|
|
||
| Args: | ||
| table_id: Target table ID (must exist). | ||
| file_path: Local CSV path. | ||
| incremental: Append rows (True) or full load (False). | ||
| delimiter: CSV column delimiter. | ||
| enclosure: CSV value enclosure character. | ||
| branch_id: Dev branch to target. Defaults to the client's branch | ||
| (``None`` = production). | ||
| """ | ||
| effective_branch = branch_id if branch_id is not None else self._resolved_branch_id | ||
| file_size_bytes = Path(file_path).stat().st_size | ||
| results = self._client.upload_table( | ||
| table_id=table_id, | ||
| file_path=str(file_path), | ||
| incremental=incremental, | ||
| delimiter=delimiter, | ||
| enclosure=enclosure, | ||
| branch_id=effective_branch, | ||
| ) | ||
| return UploadTableResult.model_validate( | ||
| { | ||
| "table_id": table_id, | ||
| "incremental": incremental, | ||
| "file_size_bytes": file_size_bytes, | ||
| "imported_rows": results.get("importedRowsCount"), | ||
| "warnings": results.get("warnings", []), | ||
| } | ||
| ) | ||
|
|
||
| def close(self) -> None: | ||
| """Close the underlying HTTP client.""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # PEP 561 marker: keboola_agent_cli ships inline type annotations. | ||
| # Downstream mypy / ty / IDEs treat the importable SDK (Client, the | ||
| # result_models.* contracts) as typed. See issue #428. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚩
run_jobdocstring listsforceRunas a valid mode butVALID_JOB_MODESconstant doesn't include itThe docstring at
lib.py:377saysmodeacceptsrun | debug | forceRun, butconstants.py:116definesVALID_JOB_MODES = frozenset({"run", "debug"}). No validation is performed againstVALID_JOB_MODESin eitherrun_joborcreate_job(client.py:2496), soforceRunwould be accepted by the API if the Queue API supports it. This is a documentation inconsistency rather than a runtime bug — either the constant should includeforceRunor the docstring should be narrowed. The existingJobService.run_jobin the CLI validates againstVALID_JOB_MODES(which excludesforceRun), so there's a discrepancy in what the facade vs. CLI allows.Was this helpful? React with 👍 or 👎 to provide feedback.