databay-cli 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.
- databay/__init__.py +5 -0
- databay/__main__.py +7 -0
- databay/agent_skill.py +184 -0
- databay/api.py +626 -0
- databay/cli/__init__.py +0 -0
- databay/cli/auth.py +377 -0
- databay/cli/build.py +89 -0
- databay/cli/commands/__init__.py +0 -0
- databay/cli/commands/alerting_webhook.py +19 -0
- databay/cli/commands/alerting_webhook_tag.py +14 -0
- databay/cli/commands/alerting_webhook_type.py +14 -0
- databay/cli/commands/alerting_webhook_undelivered.py +15 -0
- databay/cli/commands/announcement.py +17 -0
- databay/cli/commands/api_key.py +18 -0
- databay/cli/commands/api_key_project.py +16 -0
- databay/cli/commands/backfill_reason_code.py +14 -0
- databay/cli/commands/backfill_run.py +17 -0
- databay/cli/commands/canvas.py +16 -0
- databay/cli/commands/canvas_graph.py +15 -0
- databay/cli/commands/canvas_table_check.py +15 -0
- databay/cli/commands/dashboards.py +21 -0
- databay/cli/commands/dashboards_card.py +20 -0
- databay/cli/commands/dashboards_dataset.py +14 -0
- databay/cli/commands/dashboards_folder.py +16 -0
- databay/cli/commands/dashboards_home.py +17 -0
- databay/cli/commands/dashboards_placement.py +16 -0
- databay/cli/commands/db.py +19 -0
- databay/cli/commands/db_column.py +14 -0
- databay/cli/commands/docs_article.py +16 -0
- databay/cli/commands/dq_result.py +14 -0
- databay/cli/commands/dq_rule.py +16 -0
- databay/cli/commands/feature.py +14 -0
- databay/cli/commands/google_ads_source.py +14 -0
- databay/cli/commands/google_analytics_source.py +14 -0
- databay/cli/commands/instagram_account.py +14 -0
- databay/cli/commands/instagram_source.py +14 -0
- databay/cli/commands/linkedin_account.py +14 -0
- databay/cli/commands/linkedin_source.py +14 -0
- databay/cli/commands/me.py +14 -0
- databay/cli/commands/meta_ads_source.py +14 -0
- databay/cli/commands/metric_models.py +26 -0
- databay/cli/commands/metrics.py +14 -0
- databay/cli/commands/models.py +16 -0
- databay/cli/commands/nlq.py +15 -0
- databay/cli/commands/notebook_credential.py +16 -0
- databay/cli/commands/org.py +14 -0
- databay/cli/commands/org_member.py +17 -0
- databay/cli/commands/org_offboarding.py +14 -0
- databay/cli/commands/pipe.py +26 -0
- databay/cli/commands/pipe_backfill_run.py +14 -0
- databay/cli/commands/pipe_config_option.py +14 -0
- databay/cli/commands/pipe_destination.py +16 -0
- databay/cli/commands/pipe_leftover.py +16 -0
- databay/cli/commands/pipe_sink_type.py +14 -0
- databay/cli/commands/pipe_source_type.py +14 -0
- databay/cli/commands/pipe_status_bucket.py +14 -0
- databay/cli/commands/pipe_stuck_delete.py +14 -0
- databay/cli/commands/plan.py +14 -0
- databay/cli/commands/project.py +21 -0
- databay/cli/commands/project_budget.py +15 -0
- databay/cli/commands/project_member.py +18 -0
- databay/cli/commands/project_privilege.py +16 -0
- databay/cli/commands/project_share.py +16 -0
- databay/cli/commands/provision.py +14 -0
- databay/cli/commands/provisioning.py +14 -0
- databay/cli/commands/registry.py +14 -0
- databay/cli/commands/resource_home.py +14 -0
- databay/cli/commands/schema_change.py +18 -0
- databay/cli/commands/schema_history.py +17 -0
- databay/cli/commands/schema_version.py +16 -0
- databay/cli/commands/shopify_store.py +14 -0
- databay/cli/commands/sink_data_type.py +14 -0
- databay/cli/commands/source.py +24 -0
- databay/cli/commands/source_catalog.py +15 -0
- databay/cli/commands/source_check_run.py +17 -0
- databay/cli/commands/source_hidden_type.py +14 -0
- databay/cli/commands/source_name.py +14 -0
- databay/cli/commands/source_pipe.py +14 -0
- databay/cli/commands/source_publication.py +16 -0
- databay/cli/commands/source_readiness_override.py +15 -0
- databay/cli/commands/source_setup_choice.py +16 -0
- databay/cli/commands/source_stream.py +15 -0
- databay/cli/commands/source_table.py +14 -0
- databay/cli/commands/ssbi_job.py +18 -0
- databay/cli/commands/transform_type.py +14 -0
- databay/cli/commands/usage.py +14 -0
- databay/cli/commands/warehouse.py +21 -0
- databay/cli/commands/warehouse_access_assignment.py +16 -0
- databay/cli/commands/warehouse_access_role.py +22 -0
- databay/cli/commands/warehouse_access_settings.py +17 -0
- databay/cli/commands/warehouse_login.py +20 -0
- databay/cli/commands/warehouse_principal.py +17 -0
- databay/cli/commands/warehouse_service_account.py +17 -0
- databay/cli/commands/warehouse_service_account_credential.py +18 -0
- databay/cli/common.py +93 -0
- databay/cli/console.py +121 -0
- databay/cli/flags.py +183 -0
- databay/cli/handwritten.py +22 -0
- databay/cli/main.py +198 -0
- databay/cli/metrics.py +171 -0
- databay/cli/org.py +193 -0
- databay/cli/output.py +134 -0
- databay/cli/pipe_wait.py +357 -0
- databay/cli/profile.py +230 -0
- databay/cli/runner.py +432 -0
- databay/cli/skill.py +64 -0
- databay/cli/sql.py +84 -0
- databay/compat.py +161 -0
- databay/credentials.py +217 -0
- databay/error_catalog.py +226 -0
- databay/error_codes.py +3265 -0
- databay/errors.py +222 -0
- databay/feature_header.py +40 -0
- databay/invoke.py +225 -0
- databay/locks.py +41 -0
- databay/login_flow.py +245 -0
- databay/model_base.py +40 -0
- databay/models/__init__.py +0 -0
- databay/models/alerting_webhook.py +46 -0
- databay/models/alerting_webhook_tag.py +11 -0
- databay/models/alerting_webhook_type.py +11 -0
- databay/models/alerting_webhook_undelivered.py +23 -0
- databay/models/announcement.py +37 -0
- databay/models/api_key.py +81 -0
- databay/models/api_key_project.py +29 -0
- databay/models/backfill_reason_code.py +11 -0
- databay/models/backfill_run.py +41 -0
- databay/models/canvas.py +68 -0
- databay/models/canvas_graph.py +86 -0
- databay/models/canvas_table_check.py +181 -0
- databay/models/dashboards.py +79 -0
- databay/models/dashboards_card.py +37 -0
- databay/models/dashboards_dataset.py +27 -0
- databay/models/dashboards_folder.py +18 -0
- databay/models/dashboards_home.py +28 -0
- databay/models/db.py +34 -0
- databay/models/db_column.py +11 -0
- databay/models/docs_article.py +59 -0
- databay/models/dq_result.py +27 -0
- databay/models/dq_rule.py +43 -0
- databay/models/feature.py +15 -0
- databay/models/google_ads_source.py +11 -0
- databay/models/google_analytics_source.py +11 -0
- databay/models/instagram_source.py +11 -0
- databay/models/linkedin_source.py +11 -0
- databay/models/me.py +71 -0
- databay/models/meta_ads_source.py +11 -0
- databay/models/metric_models.py +274 -0
- databay/models/metrics.py +158 -0
- databay/models/models.py +73 -0
- databay/models/nlq.py +92 -0
- databay/models/notebook_credential.py +51 -0
- databay/models/org.py +44 -0
- databay/models/org_member.py +123 -0
- databay/models/org_offboarding.py +36 -0
- databay/models/pipe.py +340 -0
- databay/models/pipe_config_option.py +25 -0
- databay/models/pipe_destination.py +128 -0
- databay/models/pipe_leftover.py +51 -0
- databay/models/pipe_sink_type.py +11 -0
- databay/models/pipe_source_type.py +11 -0
- databay/models/pipe_status_bucket.py +13 -0
- databay/models/pipe_stuck_delete.py +25 -0
- databay/models/plan.py +37 -0
- databay/models/project.py +154 -0
- databay/models/project_budget.py +22 -0
- databay/models/project_member.py +67 -0
- databay/models/project_privilege.py +21 -0
- databay/models/project_share.py +76 -0
- databay/models/provision.py +12 -0
- databay/models/provisioning.py +20 -0
- databay/models/registry.py +18 -0
- databay/models/resource_home.py +35 -0
- databay/models/schema_change.py +96 -0
- databay/models/schema_history.py +32 -0
- databay/models/schema_version.py +23 -0
- databay/models/shared.py +875 -0
- databay/models/shopify_store.py +19 -0
- databay/models/sink_data_type.py +11 -0
- databay/models/source.py +263 -0
- databay/models/source_catalog.py +82 -0
- databay/models/source_check_run.py +63 -0
- databay/models/source_hidden_type.py +13 -0
- databay/models/source_name.py +18 -0
- databay/models/source_pipe.py +16 -0
- databay/models/source_publication.py +30 -0
- databay/models/source_readiness_override.py +13 -0
- databay/models/source_setup_choice.py +33 -0
- databay/models/source_stream.py +65 -0
- databay/models/source_table.py +27 -0
- databay/models/sql.py +31 -0
- databay/models/ssbi_job.py +97 -0
- databay/models/transform_type.py +40 -0
- databay/models/usage.py +112 -0
- databay/models/warehouse.py +38 -0
- databay/models/warehouse_access_assignment.py +70 -0
- databay/models/warehouse_access_role.py +148 -0
- databay/models/warehouse_access_settings.py +35 -0
- databay/models/warehouse_login.py +20 -0
- databay/models/warehouse_principal.py +49 -0
- databay/models/warehouse_service_account.py +35 -0
- databay/models/warehouse_service_account_credential.py +11 -0
- databay/oidc.py +205 -0
- databay/operation.py +191 -0
- databay/operations/__init__.py +0 -0
- databay/operations/alerting_webhook.py +317 -0
- databay/operations/alerting_webhook_tag.py +35 -0
- databay/operations/alerting_webhook_type.py +35 -0
- databay/operations/alerting_webhook_undelivered.py +74 -0
- databay/operations/announcement.py +135 -0
- databay/operations/api_key.py +198 -0
- databay/operations/api_key_project.py +130 -0
- databay/operations/backfill_reason_code.py +35 -0
- databay/operations/backfill_run.py +245 -0
- databay/operations/canvas.py +136 -0
- databay/operations/canvas_graph.py +102 -0
- databay/operations/canvas_table_check.py +101 -0
- databay/operations/dashboards.py +441 -0
- databay/operations/dashboards_card.py +406 -0
- databay/operations/dashboards_dataset.py +50 -0
- databay/operations/dashboards_folder.py +146 -0
- databay/operations/dashboards_home.py +176 -0
- databay/operations/dashboards_placement.py +152 -0
- databay/operations/db.py +337 -0
- databay/operations/db_column.py +57 -0
- databay/operations/docs_article.py +101 -0
- databay/operations/dq_result.py +43 -0
- databay/operations/dq_rule.py +155 -0
- databay/operations/feature.py +35 -0
- databay/operations/google_ads_source.py +40 -0
- databay/operations/google_analytics_source.py +40 -0
- databay/operations/instagram_account.py +47 -0
- databay/operations/instagram_source.py +40 -0
- databay/operations/linkedin_account.py +47 -0
- databay/operations/linkedin_source.py +40 -0
- databay/operations/me.py +57 -0
- databay/operations/meta_ads_source.py +40 -0
- databay/operations/metric_models.py +612 -0
- databay/operations/metrics.py +218 -0
- databay/operations/models.py +123 -0
- databay/operations/nlq.py +100 -0
- databay/operations/notebook_credential.py +95 -0
- databay/operations/org.py +79 -0
- databay/operations/org_member.py +192 -0
- databay/operations/org_offboarding.py +43 -0
- databay/operations/pipe.py +882 -0
- databay/operations/pipe_backfill_run.py +72 -0
- databay/operations/pipe_config_option.py +50 -0
- databay/operations/pipe_destination.py +178 -0
- databay/operations/pipe_leftover.py +110 -0
- databay/operations/pipe_sink_type.py +35 -0
- databay/operations/pipe_source_type.py +35 -0
- databay/operations/pipe_status_bucket.py +35 -0
- databay/operations/pipe_stuck_delete.py +52 -0
- databay/operations/plan.py +35 -0
- databay/operations/project.py +346 -0
- databay/operations/project_budget.py +135 -0
- databay/operations/project_member.py +226 -0
- databay/operations/project_privilege.py +143 -0
- databay/operations/project_share.py +160 -0
- databay/operations/provision.py +35 -0
- databay/operations/provisioning.py +35 -0
- databay/operations/registry.py +35 -0
- databay/operations/resource_home.py +80 -0
- databay/operations/schema_change.py +222 -0
- databay/operations/schema_history.py +136 -0
- databay/operations/schema_version.py +115 -0
- databay/operations/shopify_store.py +40 -0
- databay/operations/sink_data_type.py +43 -0
- databay/operations/source.py +1207 -0
- databay/operations/source_catalog.py +135 -0
- databay/operations/source_check_run.py +242 -0
- databay/operations/source_hidden_type.py +59 -0
- databay/operations/source_name.py +69 -0
- databay/operations/source_pipe.py +57 -0
- databay/operations/source_publication.py +139 -0
- databay/operations/source_readiness_override.py +96 -0
- databay/operations/source_setup_choice.py +161 -0
- databay/operations/source_stream.py +99 -0
- databay/operations/source_table.py +59 -0
- databay/operations/sql.py +54 -0
- databay/operations/ssbi_job.py +242 -0
- databay/operations/transform_type.py +35 -0
- databay/operations/usage.py +35 -0
- databay/operations/warehouse.py +343 -0
- databay/operations/warehouse_access_assignment.py +179 -0
- databay/operations/warehouse_access_role.py +473 -0
- databay/operations/warehouse_access_settings.py +172 -0
- databay/operations/warehouse_login.py +308 -0
- databay/operations/warehouse_principal.py +174 -0
- databay/operations/warehouse_service_account.py +206 -0
- databay/operations/warehouse_service_account_credential.py +285 -0
- databay/paths.py +32 -0
- databay/platform_check.py +20 -0
- databay/profiles.py +181 -0
- databay/redact.py +22 -0
- databay/reference.py +218 -0
- databay/resource.py +167 -0
- databay/resources/__init__.py +0 -0
- databay/resources/alerting_webhook.py +84 -0
- databay/resources/alerting_webhook_tag.py +21 -0
- databay/resources/alerting_webhook_type.py +21 -0
- databay/resources/alerting_webhook_undelivered.py +35 -0
- databay/resources/announcement.py +45 -0
- databay/resources/api_key.py +56 -0
- databay/resources/api_key_project.py +47 -0
- databay/resources/backfill_reason_code.py +21 -0
- databay/resources/backfill_run.py +78 -0
- databay/resources/canvas.py +43 -0
- databay/resources/canvas_graph.py +38 -0
- databay/resources/canvas_table_check.py +38 -0
- databay/resources/dashboards.py +105 -0
- databay/resources/dashboards_card.py +97 -0
- databay/resources/dashboards_dataset.py +27 -0
- databay/resources/dashboards_folder.py +47 -0
- databay/resources/dashboards_home.py +59 -0
- databay/resources/dashboards_placement.py +47 -0
- databay/resources/db.py +87 -0
- databay/resources/db_column.py +28 -0
- databay/resources/docs_article.py +37 -0
- databay/resources/dq_result.py +25 -0
- databay/resources/dq_rule.py +48 -0
- databay/resources/feature.py +21 -0
- databay/resources/google_ads_source.py +25 -0
- databay/resources/google_analytics_source.py +25 -0
- databay/resources/instagram_account.py +27 -0
- databay/resources/instagram_source.py +25 -0
- databay/resources/linkedin_account.py +27 -0
- databay/resources/linkedin_source.py +25 -0
- databay/resources/me.py +27 -0
- databay/resources/meta_ads_source.py +25 -0
- databay/resources/metric_models.py +154 -0
- databay/resources/metrics.py +57 -0
- databay/resources/models.py +42 -0
- databay/resources/nlq.py +34 -0
- databay/resources/notebook_credential.py +41 -0
- databay/resources/org.py +29 -0
- databay/resources/org_member.py +53 -0
- databay/resources/org_offboarding.py +25 -0
- databay/resources/pipe.py +208 -0
- databay/resources/pipe_backfill_run.py +28 -0
- databay/resources/pipe_config_option.py +26 -0
- databay/resources/pipe_destination.py +49 -0
- databay/resources/pipe_leftover.py +42 -0
- databay/resources/pipe_sink_type.py +21 -0
- databay/resources/pipe_source_type.py +21 -0
- databay/resources/pipe_status_bucket.py +21 -0
- databay/resources/pipe_stuck_delete.py +27 -0
- databay/resources/plan.py +21 -0
- databay/resources/project.py +92 -0
- databay/resources/project_budget.py +38 -0
- databay/resources/project_member.py +68 -0
- databay/resources/project_privilege.py +48 -0
- databay/resources/project_share.py +47 -0
- databay/resources/provision.py +21 -0
- databay/resources/provisioning.py +21 -0
- databay/resources/registry.py +21 -0
- databay/resources/resource_home.py +29 -0
- databay/resources/schema_change.py +62 -0
- databay/resources/schema_history.py +52 -0
- databay/resources/schema_version.py +43 -0
- databay/resources/shopify_store.py +25 -0
- databay/resources/sink_data_type.py +25 -0
- databay/resources/source.py +132 -0
- databay/resources/source_catalog.py +34 -0
- databay/resources/source_check_run.py +67 -0
- databay/resources/source_hidden_type.py +25 -0
- databay/resources/source_name.py +27 -0
- databay/resources/source_pipe.py +28 -0
- databay/resources/source_publication.py +47 -0
- databay/resources/source_readiness_override.py +37 -0
- databay/resources/source_setup_choice.py +50 -0
- databay/resources/source_stream.py +38 -0
- databay/resources/source_table.py +27 -0
- databay/resources/sql.py +27 -0
- databay/resources/ssbi_job.py +69 -0
- databay/resources/transform_type.py +21 -0
- databay/resources/usage.py +21 -0
- databay/resources/warehouse.py +136 -0
- databay/resources/warehouse_access_assignment.py +51 -0
- databay/resources/warehouse_access_role.py +118 -0
- databay/resources/warehouse_access_settings.py +56 -0
- databay/resources/warehouse_login.py +81 -0
- databay/resources/warehouse_principal.py +51 -0
- databay/resources/warehouse_service_account.py +61 -0
- databay/resources/warehouse_service_account_credential.py +78 -0
- databay/sdk.py +77 -0
- databay/settings.py +90 -0
- databay/skill/SKILL.md +357 -0
- databay/skill/references/commands.md +7025 -0
- databay/skill/references/errors.md +727 -0
- databay/spec.py +362 -0
- databay/stored_token.py +134 -0
- databay/tokens.py +108 -0
- databay/transport.py +357 -0
- databay/update_check.py +151 -0
- databay/version.py +6 -0
- databay/waits.py +330 -0
- databay_cli-0.1.0.dist-info/METADATA +42 -0
- databay_cli-0.1.0.dist-info/RECORD +403 -0
- databay_cli-0.1.0.dist-info/WHEEL +4 -0
- databay_cli-0.1.0.dist-info/entry_points.txt +2 -0
- databay_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
databay/oidc.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The OAuth requests the CLI sends to the identity provider (Keycloak): discovery, token grants, revocation.
|
|
3
|
+
|
|
4
|
+
Every grant answer is checked the same way: a 2xx with ``access_token`` and ``expires_in``, or an
|
|
5
|
+
OAuth error (``{"error": ...}``) raised as ``OAuthRefusal`` naming it. Tokens never appear in an
|
|
6
|
+
error message or an event.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import secrets
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from databay.errors import DatabayError, DatabayNetworkError, DatabayResponseInvalid, DatabayServerUnavailable
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
25
|
+
LOGIN_SCOPE = "openid offline_access"
|
|
26
|
+
OFFLINE_SCOPE = "offline_access"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OAuthRefusal(DatabayError):
|
|
30
|
+
"""The provider answered an OAuth error; ``error`` is its ``error`` field (``invalid_grant``, ``slow_down``, …)."""
|
|
31
|
+
|
|
32
|
+
code = "login_failed"
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str, *, error: str, **kwargs: Any) -> None: # noqa: ANN401
|
|
35
|
+
super().__init__(message, **kwargs)
|
|
36
|
+
self.error = error
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class Endpoints:
|
|
41
|
+
issuer: str
|
|
42
|
+
authorization: str
|
|
43
|
+
token: str
|
|
44
|
+
device: str
|
|
45
|
+
#: ``None`` when the provider advertises no revocation endpoint.
|
|
46
|
+
revocation: str | None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class Grant:
|
|
51
|
+
access_token: str
|
|
52
|
+
expires_at: float
|
|
53
|
+
refresh_token: str | None
|
|
54
|
+
scope: tuple[str, ...]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pkce() -> tuple[str, str]:
|
|
58
|
+
"""A code verifier and its S256 challenge."""
|
|
59
|
+
verifier = secrets.token_urlsafe(64)
|
|
60
|
+
digest = hashlib.sha256(verifier.encode()).digest()
|
|
61
|
+
return verifier, base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def claims(jwt: str) -> dict[str, Any]:
|
|
65
|
+
"""
|
|
66
|
+
The payload of a JWT the token endpoint just issued to this client.
|
|
67
|
+
|
|
68
|
+
Read, not verified: the token came straight from the provider over the connection the CLI
|
|
69
|
+
opened, and the server checks every token it is sent.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
payload = jwt.split(".")[1]
|
|
73
|
+
data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
|
74
|
+
except (IndexError, ValueError) as exc:
|
|
75
|
+
raise DatabayResponseInvalid("the access token the provider issued is not a JWT") from exc
|
|
76
|
+
if not isinstance(data, dict) or not isinstance(data.get("sub"), str):
|
|
77
|
+
raise DatabayResponseInvalid("the access token the provider issued has no sub")
|
|
78
|
+
return data
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _post(http: httpx.Client, url: str, data: dict[str, str]) -> httpx.Response:
|
|
82
|
+
import httpx # noqa: PLC0415
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
return http.post(url, data=data, headers={"Accept": "application/json"})
|
|
86
|
+
except httpx.TransportError as exc:
|
|
87
|
+
raise DatabayNetworkError(f"POST {url}: could not reach the identity provider ({type(exc).__name__})") from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _transient(status: int) -> bool:
|
|
91
|
+
return status >= 500 or status == 429 # noqa: PLR2004
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _json(response: httpx.Response, where: str) -> dict[str, Any]:
|
|
95
|
+
try:
|
|
96
|
+
body = response.json()
|
|
97
|
+
except ValueError:
|
|
98
|
+
body = None
|
|
99
|
+
if not isinstance(body, dict):
|
|
100
|
+
if _transient(response.status_code):
|
|
101
|
+
raise DatabayServerUnavailable(f"{where}: HTTP {response.status_code} from the identity provider")
|
|
102
|
+
raise DatabayResponseInvalid(f"{where}: HTTP {response.status_code} without a JSON object")
|
|
103
|
+
return body
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _refusal(response: httpx.Response, body: dict[str, Any], where: str) -> DatabayError:
|
|
107
|
+
"""The error a non-200 answer is: transient for a 5xx or 429 whatever its body, else the OAuth error it names."""
|
|
108
|
+
error = body.get("error")
|
|
109
|
+
if _transient(response.status_code):
|
|
110
|
+
return DatabayServerUnavailable(
|
|
111
|
+
f"{where}: HTTP {response.status_code} from the identity provider"
|
|
112
|
+
+ (f" ({error})" if isinstance(error, str) else ""),
|
|
113
|
+
status=response.status_code,
|
|
114
|
+
)
|
|
115
|
+
if not isinstance(error, str):
|
|
116
|
+
raise DatabayResponseInvalid(f"{where}: HTTP {response.status_code} without an OAuth error")
|
|
117
|
+
description = body.get("error_description")
|
|
118
|
+
text = f"{where}: the identity provider answered {error}" + (
|
|
119
|
+
f" ({description})" if isinstance(description, str) else ""
|
|
120
|
+
)
|
|
121
|
+
return OAuthRefusal(text, error=error, status=response.status_code)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def discover(http: httpx.Client, issuer: str) -> Endpoints:
|
|
125
|
+
"""The issuer's endpoints from its OpenID discovery document."""
|
|
126
|
+
import httpx # noqa: PLC0415
|
|
127
|
+
|
|
128
|
+
url = f"{issuer}/.well-known/openid-configuration"
|
|
129
|
+
try:
|
|
130
|
+
response = http.get(url, headers={"Accept": "application/json"})
|
|
131
|
+
except httpx.TransportError as exc:
|
|
132
|
+
raise DatabayNetworkError(f"GET {url}: could not reach the identity provider ({type(exc).__name__})") from exc
|
|
133
|
+
if response.status_code != 200: # noqa: PLR2004
|
|
134
|
+
raise DatabayServerUnavailable(f"GET {url}: HTTP {response.status_code}")
|
|
135
|
+
body = _json(response, f"GET {url}")
|
|
136
|
+
required = ("issuer", "authorization_endpoint", "token_endpoint", "device_authorization_endpoint")
|
|
137
|
+
missing = [name for name in required if not isinstance(body.get(name), str)]
|
|
138
|
+
if missing:
|
|
139
|
+
raise DatabayResponseInvalid(f"GET {url}: the discovery document has no {missing}")
|
|
140
|
+
if body["issuer"] != issuer:
|
|
141
|
+
raise DatabayResponseInvalid(
|
|
142
|
+
f"GET {url}: the discovery document names issuer {body['issuer']}, not {issuer}",
|
|
143
|
+
details={"issuer": body["issuer"], "expected": issuer},
|
|
144
|
+
)
|
|
145
|
+
revocation = body.get("revocation_endpoint")
|
|
146
|
+
return Endpoints(
|
|
147
|
+
issuer=issuer,
|
|
148
|
+
authorization=body["authorization_endpoint"],
|
|
149
|
+
token=body["token_endpoint"],
|
|
150
|
+
device=body["device_authorization_endpoint"],
|
|
151
|
+
revocation=revocation if isinstance(revocation, str) else None,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def grant(http: httpx.Client, token_endpoint: str, data: dict[str, str], *, now: float | None = None) -> Grant:
|
|
156
|
+
"""POST one grant to the token endpoint; an OAuth error is ``OAuthRefusal``."""
|
|
157
|
+
issued_at = time.time() if now is None else now
|
|
158
|
+
where = f"{data['grant_type']} grant"
|
|
159
|
+
response = _post(http, token_endpoint, data)
|
|
160
|
+
body = _json(response, where)
|
|
161
|
+
if response.status_code != 200: # noqa: PLR2004
|
|
162
|
+
raise _refusal(response, body, where)
|
|
163
|
+
access, expires_in = body.get("access_token"), body.get("expires_in")
|
|
164
|
+
if not isinstance(access, str) or not isinstance(expires_in, int | float):
|
|
165
|
+
raise DatabayResponseInvalid(f"{where}: the answer has no access_token and expires_in")
|
|
166
|
+
refresh = body.get("refresh_token")
|
|
167
|
+
scope = body.get("scope")
|
|
168
|
+
return Grant(
|
|
169
|
+
access_token=access,
|
|
170
|
+
expires_at=issued_at + float(expires_in),
|
|
171
|
+
refresh_token=refresh if isinstance(refresh, str) else None,
|
|
172
|
+
scope=tuple(scope.split()) if isinstance(scope, str) else (),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def start_device(http: httpx.Client, endpoint: str, client_id: str, challenge: str) -> dict[str, Any]:
|
|
177
|
+
"""Start a device authorization; the answer holds the user code and the verification links."""
|
|
178
|
+
where = "device authorization"
|
|
179
|
+
response = _post(
|
|
180
|
+
http,
|
|
181
|
+
endpoint,
|
|
182
|
+
{"client_id": client_id, "scope": LOGIN_SCOPE, "code_challenge": challenge, "code_challenge_method": "S256"},
|
|
183
|
+
)
|
|
184
|
+
body = _json(response, where)
|
|
185
|
+
if response.status_code != 200: # noqa: PLR2004
|
|
186
|
+
raise _refusal(response, body, where)
|
|
187
|
+
missing = [
|
|
188
|
+
name
|
|
189
|
+
for name, kind in (("device_code", str), ("user_code", str), ("verification_uri", str), ("expires_in", int))
|
|
190
|
+
if not isinstance(body.get(name), kind)
|
|
191
|
+
]
|
|
192
|
+
if missing:
|
|
193
|
+
raise DatabayResponseInvalid(f"{where}: the answer has no {missing}")
|
|
194
|
+
return body
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def revoke(http: httpx.Client, endpoint: str, client_id: str, refresh_token: str) -> None:
|
|
198
|
+
"""Revoke ``refresh_token`` (RFC 7009); a refusal raises ``OAuthRefusal`` or a transport error."""
|
|
199
|
+
where = "token revocation"
|
|
200
|
+
response = _post(
|
|
201
|
+
http, endpoint, {"client_id": client_id, "token": refresh_token, "token_type_hint": "refresh_token"}
|
|
202
|
+
)
|
|
203
|
+
if response.status_code == 200: # noqa: PLR2004
|
|
204
|
+
return
|
|
205
|
+
raise _refusal(response, _json(response, where), where)
|
databay/operation.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What one exposed API operation is to the SDK and CLI, as plain data.
|
|
3
|
+
|
|
4
|
+
The generated ``databay.operations.<noun>`` modules hold one ``Operation`` per route, built from the
|
|
5
|
+
route's ``x-databay-client`` declaration in ``contract/core.json``. Both the SDK resources and the
|
|
6
|
+
CLI commands read them, so the two never disagree on a path, a flag or a wait. This module imports
|
|
7
|
+
only the standard library: ``python/tools/gen_client.py`` builds these objects too.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
Location = Literal["path", "query", "header", "body"]
|
|
16
|
+
#: How a value is read from the command line: ``json`` takes a JSON document (objects and arrays
|
|
17
|
+
#: of objects), ``list`` repeats the flag once per item.
|
|
18
|
+
ValueKind = Literal["string", "integer", "number", "boolean", "json", "list"]
|
|
19
|
+
ResponseKind = Literal["json", "text", "empty"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class Param:
|
|
24
|
+
"""One input of an operation: a path, query or header parameter, or a body field."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
location: Location
|
|
28
|
+
kind: ValueKind
|
|
29
|
+
required: bool
|
|
30
|
+
description: str = ""
|
|
31
|
+
#: Marked ``format: password`` in the spec: never a value flag, redacted in verbose logs.
|
|
32
|
+
secret: bool = False
|
|
33
|
+
#: The values an enum takes; for a ``list`` of an enum, the values of one item.
|
|
34
|
+
choices: tuple[str, ...] = ()
|
|
35
|
+
#: The kind of one item of a ``list``.
|
|
36
|
+
item_kind: ValueKind | None = None
|
|
37
|
+
#: The schema allows ``null``: the SDK may send ``None`` for it.
|
|
38
|
+
nullable: bool = False
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def flag(self) -> str:
|
|
42
|
+
"""The CLI flag without dashes: the name in kebab case."""
|
|
43
|
+
return self.name.replace("_", "-").lower()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def identifier(self) -> str:
|
|
47
|
+
"""The Python identifier the SDK and the CLI callback use for it."""
|
|
48
|
+
return python_identifier(self.name)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class Body:
|
|
53
|
+
"""
|
|
54
|
+
An operation's JSON body.
|
|
55
|
+
|
|
56
|
+
A plain body has ``fields``. A discriminated body has one variant per value of
|
|
57
|
+
``discriminator``, each with its own fields; the CLI makes each variant a subcommand. With
|
|
58
|
+
``variant_field`` set, the variants are that one property of a plain body: a variant's fields
|
|
59
|
+
and the discriminator nest under it on the wire, and ``fields`` are the properties beside it.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
schema: str | None
|
|
63
|
+
fields: tuple[Param, ...] = ()
|
|
64
|
+
discriminator: str | None = None
|
|
65
|
+
variants: tuple[tuple[str, str, tuple[Param, ...]], ...] = ()
|
|
66
|
+
variant_field: str | None = None
|
|
67
|
+
|
|
68
|
+
def variant(self, key: str) -> tuple[str, tuple[Param, ...]]:
|
|
69
|
+
for variant_key, schema, fields in self.variants:
|
|
70
|
+
if variant_key == key:
|
|
71
|
+
return schema, fields
|
|
72
|
+
msg = f"{self.schema} has no variant {key!r}"
|
|
73
|
+
raise KeyError(msg)
|
|
74
|
+
|
|
75
|
+
def fields_of(self, variant: str | None) -> tuple[Param, ...]:
|
|
76
|
+
"""Every field a command of ``variant`` takes as a flag: the body's own, then the variant's."""
|
|
77
|
+
return self.fields if variant is None else (*self.fields, *self.variant(variant)[1])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class Condition:
|
|
82
|
+
"""How a status value that needs a second field is classified, by that field's value."""
|
|
83
|
+
|
|
84
|
+
field: str
|
|
85
|
+
pending: tuple[str, ...] = ()
|
|
86
|
+
success: tuple[str, ...] = ()
|
|
87
|
+
failure: tuple[str, ...] = ()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class Wait:
|
|
92
|
+
"""
|
|
93
|
+
How the client waits on what an operation started, by polling an exposed status operation.
|
|
94
|
+
|
|
95
|
+
``bind`` maps each status-operation parameter to a JSON pointer into this operation's response,
|
|
96
|
+
or to ``path:<name>``, one of this operation's own path parameters.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
status_operation: str
|
|
100
|
+
bind: tuple[tuple[str, str], ...]
|
|
101
|
+
field: str
|
|
102
|
+
pending: tuple[str, ...]
|
|
103
|
+
success: tuple[str, ...]
|
|
104
|
+
failure: tuple[str, ...]
|
|
105
|
+
error_field: str | None = None
|
|
106
|
+
conditional: tuple[tuple[str, Condition], ...] = ()
|
|
107
|
+
timeout_s: float | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class Verdict:
|
|
112
|
+
"""A 2xx body field whose ``not_ready`` values make the operation's answer a refusal."""
|
|
113
|
+
|
|
114
|
+
field: str
|
|
115
|
+
ok: tuple[str, ...]
|
|
116
|
+
not_ready: tuple[str, ...]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class Response:
|
|
121
|
+
kind: ResponseKind
|
|
122
|
+
#: ``module:Class`` under ``databay.models`` for a JSON body, or ``None`` when it has no schema.
|
|
123
|
+
model: str | None = None
|
|
124
|
+
#: The top-level JSON type the schema allows: ``object``, ``array`` or ``any``.
|
|
125
|
+
shape: str = "any"
|
|
126
|
+
#: For an object answer that holds the operation's list (``{"keys": [...]}``): the JSON pointer
|
|
127
|
+
#: of that list, whose items the declared ``columns`` name.
|
|
128
|
+
rows: str | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True, slots=True)
|
|
132
|
+
class Operation:
|
|
133
|
+
operation_id: str
|
|
134
|
+
noun: str
|
|
135
|
+
verb: str
|
|
136
|
+
method: str
|
|
137
|
+
path: str
|
|
138
|
+
summary: str
|
|
139
|
+
description: str
|
|
140
|
+
project: str | None
|
|
141
|
+
destructive: bool
|
|
142
|
+
idempotent: bool
|
|
143
|
+
since: str
|
|
144
|
+
params: tuple[Param, ...]
|
|
145
|
+
body: Body | None
|
|
146
|
+
response: Response
|
|
147
|
+
examples: tuple[str, ...]
|
|
148
|
+
columns: tuple[str, ...] = ()
|
|
149
|
+
wait: Wait | None = None
|
|
150
|
+
verdict: Verdict | None = None
|
|
151
|
+
#: Set on a status operation that a ``wait`` polls: the wait it can resume (``--wait`` on it).
|
|
152
|
+
resumes: Wait | None = None
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def secret_fields(self) -> frozenset[str]:
|
|
156
|
+
"""Names of every secret body field, across all variants: what verbose logs redact."""
|
|
157
|
+
if self.body is None:
|
|
158
|
+
return frozenset()
|
|
159
|
+
fields = list(self.body.fields) + [p for _, _, variant in self.body.variants for p in variant]
|
|
160
|
+
return frozenset(p.name for p in fields if p.secret)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def project_location(self) -> tuple[str, str] | None:
|
|
164
|
+
"""``(where, name)`` for ``path:``/``query:``/``body:`` locations; ``("header", "")``; ``None``."""
|
|
165
|
+
if self.project is None:
|
|
166
|
+
return None
|
|
167
|
+
where, _, name = self.project.partition(":")
|
|
168
|
+
return where, name
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def offers_wait(self) -> bool:
|
|
172
|
+
"""Whether the call can wait (``--wait``). A create answers at once; its ``wait`` only names ``next``."""
|
|
173
|
+
return self.resumes is not None or (self.wait is not None and self.verb != "create")
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def path_params(self) -> tuple[Param, ...]:
|
|
177
|
+
return tuple(p for p in self.params if p.location == "path")
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def command(self) -> str:
|
|
181
|
+
return f"databay {self.noun} {self.verb}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def python_identifier(name: str) -> str:
|
|
185
|
+
"""A wire name as a Python identifier: lower case, ``-`` and spaces as ``_``, a keyword suffixed ``_``."""
|
|
186
|
+
import keyword # noqa: PLC0415 — only the generator and the first call pay for it
|
|
187
|
+
|
|
188
|
+
ident = "".join(c if c.isalnum() else "_" for c in name.lower())
|
|
189
|
+
if not ident or ident[0].isdigit():
|
|
190
|
+
ident = f"_{ident}"
|
|
191
|
+
return f"{ident}_" if keyword.iskeyword(ident) else ident
|
|
File without changes
|