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/transport.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""
|
|
2
|
+
One HTTP exchange with a Databay server: headers, retries, verbose logs, and errors by ``code``.
|
|
3
|
+
|
|
4
|
+
A GET, or a write sent with an ``Idempotency-Key``, is retried on 429/502/503/504 and on connect or
|
|
5
|
+
read failures (``RetryPolicy``); any other write is sent once. Each retry is reported to the event
|
|
6
|
+
sink. An error answer becomes the exception its ``code`` names (``databay.errors``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import secrets
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from email.utils import parsedate_to_datetime
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
19
|
+
|
|
20
|
+
from databay import redact
|
|
21
|
+
from databay.error_catalog import UNCODED_404_HINT
|
|
22
|
+
from databay.errors import (
|
|
23
|
+
DatabayError,
|
|
24
|
+
DatabayFeatureDisabled,
|
|
25
|
+
DatabayNetworkError,
|
|
26
|
+
DatabayProfileError,
|
|
27
|
+
DatabayServerUnavailable,
|
|
28
|
+
DatabayUncodedError,
|
|
29
|
+
DatabayUnknownCodeError,
|
|
30
|
+
)
|
|
31
|
+
from databay.settings import SETTINGS, ClientSettings
|
|
32
|
+
from databay.version import CLIENT_VERSION
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
import httpx
|
|
36
|
+
|
|
37
|
+
from databay.tokens import TokenProvider
|
|
38
|
+
|
|
39
|
+
CORRELATION_HEADER = "x-correlation-id"
|
|
40
|
+
IDEMPOTENCY_HEADER = "Idempotency-Key"
|
|
41
|
+
PROJECT_HEADER = "X-Databay-Project-ID"
|
|
42
|
+
#: 4xx answers that say "later", not "wrong": request timeout and rate limiting.
|
|
43
|
+
TRANSIENT_4XX = frozenset({408, 429})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_transient(status: int) -> bool:
|
|
47
|
+
"""Whether an error status says "try later": any 5xx, 408 or 429. A wait polls through these."""
|
|
48
|
+
return status >= 500 or status in TRANSIENT_4XX # noqa: PLR2004
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EventSink(Protocol):
|
|
52
|
+
def __call__(self, event: str, fields: dict[str, Any]) -> None: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def no_events(_event: str, _fields: dict[str, Any]) -> None:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class Answer:
|
|
61
|
+
status: int
|
|
62
|
+
headers: dict[str, str]
|
|
63
|
+
content: bytes
|
|
64
|
+
request_id: str
|
|
65
|
+
attempts: int
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def is_json(self) -> bool:
|
|
69
|
+
return self.headers.get("content-type", "").split(";")[0].strip().endswith("json")
|
|
70
|
+
|
|
71
|
+
def json(self) -> Any: # noqa: ANN401 — any JSON value
|
|
72
|
+
return json.loads(self.content)
|
|
73
|
+
|
|
74
|
+
def text(self) -> str:
|
|
75
|
+
return self.content.decode("utf-8")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True)
|
|
79
|
+
class Request:
|
|
80
|
+
method: str
|
|
81
|
+
path: str
|
|
82
|
+
query: list[tuple[str, str]] = field(default_factory=list)
|
|
83
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
84
|
+
json_body: Any = None
|
|
85
|
+
has_body: bool = False
|
|
86
|
+
#: Body fields ``--verbose`` replaces with ``<redacted>``.
|
|
87
|
+
secret_fields: frozenset[str] = frozenset()
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def retryable(self) -> bool:
|
|
91
|
+
return self.method == "GET" or IDEMPOTENCY_HEADER in self.headers
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class Transport:
|
|
96
|
+
"""
|
|
97
|
+
Sends requests to one server as one caller.
|
|
98
|
+
|
|
99
|
+
``client_kind`` is ``cli`` or ``sdk`` in the ``User-Agent``. ``http`` is the httpx client to
|
|
100
|
+
send with; tests hand one built on ``httpx.MockTransport``.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
server: str
|
|
104
|
+
tokens: TokenProvider
|
|
105
|
+
client_kind: str
|
|
106
|
+
retries: bool = True
|
|
107
|
+
verbose: bool = False
|
|
108
|
+
events: EventSink = no_events
|
|
109
|
+
settings: ClientSettings = SETTINGS
|
|
110
|
+
http: httpx.Client | None = None
|
|
111
|
+
#: One id for every request this transport sends, echoed by the server as ``x-correlation-id``.
|
|
112
|
+
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
113
|
+
sleep: Any = time.sleep
|
|
114
|
+
#: The command a ``login_required`` names as ``next``: the profile's login, when there is a profile.
|
|
115
|
+
login_command: str | None = None
|
|
116
|
+
_owned: httpx.Client | None = field(default=None, init=False, repr=False)
|
|
117
|
+
|
|
118
|
+
def _client(self) -> httpx.Client:
|
|
119
|
+
if self.http is not None:
|
|
120
|
+
return self.http
|
|
121
|
+
if self._owned is None:
|
|
122
|
+
self._owned = new_http_client(self.settings)
|
|
123
|
+
return self._owned
|
|
124
|
+
|
|
125
|
+
def close(self) -> None:
|
|
126
|
+
"""Close the client this transport opened; one the caller handed in stays the caller's."""
|
|
127
|
+
if self._owned is not None:
|
|
128
|
+
self._owned.close()
|
|
129
|
+
self._owned = None
|
|
130
|
+
|
|
131
|
+
def _headers(self, request: Request) -> dict[str, str]:
|
|
132
|
+
token = self.tokens.token()
|
|
133
|
+
return {
|
|
134
|
+
**({"Authorization": f"Bearer {token}"} if token is not None else {}),
|
|
135
|
+
"User-Agent": f"databay-python/{CLIENT_VERSION} ({self.client_kind})",
|
|
136
|
+
"Accept": "application/json",
|
|
137
|
+
CORRELATION_HEADER: self.request_id,
|
|
138
|
+
"traceparent": f"00-{secrets.token_hex(16)}-{secrets.token_hex(8)}-01",
|
|
139
|
+
**request.headers,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
def send(self, request: Request, *, retries: bool | None = None) -> Answer:
|
|
143
|
+
"""Send ``request``, retrying it when it is safe and allowed; raise its error answer."""
|
|
144
|
+
answer = self.exchange(request, retries=retries)
|
|
145
|
+
if answer.status >= 400: # noqa: PLR2004
|
|
146
|
+
raise error_from(answer, request)
|
|
147
|
+
return answer
|
|
148
|
+
|
|
149
|
+
def exchange(self, request: Request, *, retries: bool | None = None) -> Answer:
|
|
150
|
+
"""
|
|
151
|
+
Send ``request`` and return the last answer, error or not; a network failure raises.
|
|
152
|
+
|
|
153
|
+
A 401 is sent once more after the token source refreshes, when it can; a 401 on the refreshed
|
|
154
|
+
token is ``login_required``.
|
|
155
|
+
"""
|
|
156
|
+
answer = self._exchange(request, retries=retries)
|
|
157
|
+
if answer.status == 401 and self.tokens.refreshed() is not None: # noqa: PLR2004
|
|
158
|
+
answer = self._exchange(request, retries=retries)
|
|
159
|
+
if answer.status == 401: # noqa: PLR2004
|
|
160
|
+
raise DatabayProfileError(
|
|
161
|
+
f"{request.method} {request.path}: {self.server} refused the token even after a refresh; "
|
|
162
|
+
"sign in again with databay login",
|
|
163
|
+
code="login_required",
|
|
164
|
+
status=401,
|
|
165
|
+
attempts=answer.attempts,
|
|
166
|
+
next_command=self.login_command,
|
|
167
|
+
)
|
|
168
|
+
return answer
|
|
169
|
+
|
|
170
|
+
def _exchange(self, request: Request, *, retries: bool | None) -> Answer:
|
|
171
|
+
allowed = (self.retries if retries is None else retries) and request.retryable
|
|
172
|
+
policy = self.settings.retry
|
|
173
|
+
attempt = 0
|
|
174
|
+
while True:
|
|
175
|
+
attempt += 1
|
|
176
|
+
failure: DatabayError | None = None
|
|
177
|
+
answer: Answer | None = None
|
|
178
|
+
try:
|
|
179
|
+
answer = self._once(request, attempt)
|
|
180
|
+
except DatabayNetworkError as exc:
|
|
181
|
+
failure = exc
|
|
182
|
+
retryable = failure is not None or (answer is not None and answer.status in policy.statuses)
|
|
183
|
+
if not (allowed and retryable and attempt <= policy.max_retries):
|
|
184
|
+
if failure is not None:
|
|
185
|
+
failure.attempts = attempt
|
|
186
|
+
raise failure
|
|
187
|
+
assert answer is not None
|
|
188
|
+
return answer
|
|
189
|
+
delay = self._delay(answer, attempt)
|
|
190
|
+
self.events(
|
|
191
|
+
"retry",
|
|
192
|
+
{
|
|
193
|
+
"attempt": attempt + 1,
|
|
194
|
+
"status": answer.status if answer else None,
|
|
195
|
+
"error": failure.code if failure else None,
|
|
196
|
+
"delay_s": delay,
|
|
197
|
+
"request_id": self.request_id,
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
self.sleep(delay)
|
|
201
|
+
|
|
202
|
+
def _delay(self, answer: Answer | None, attempt: int) -> float:
|
|
203
|
+
policy = self.settings.retry
|
|
204
|
+
backoff = policy.backoff_s[min(attempt, len(policy.backoff_s)) - 1]
|
|
205
|
+
after = retry_after_seconds(answer.headers.get("retry-after") if answer else None)
|
|
206
|
+
return backoff if after is None else min(after, policy.retry_after_cap_s)
|
|
207
|
+
|
|
208
|
+
def _once(self, request: Request, attempt: int) -> Answer:
|
|
209
|
+
import httpx # noqa: PLC0415
|
|
210
|
+
|
|
211
|
+
headers = self._headers(request)
|
|
212
|
+
url = f"{self.server}{request.path}"
|
|
213
|
+
content = json.dumps(request.json_body).encode() if request.has_body else None
|
|
214
|
+
if content is not None:
|
|
215
|
+
headers["Content-Type"] = "application/json"
|
|
216
|
+
started = time.monotonic()
|
|
217
|
+
if self.verbose:
|
|
218
|
+
self._log_request(request, url, headers, attempt)
|
|
219
|
+
try:
|
|
220
|
+
response = self._client().request(
|
|
221
|
+
request.method, url, params=request.query or None, headers=headers, content=content
|
|
222
|
+
)
|
|
223
|
+
except httpx.TimeoutException as exc:
|
|
224
|
+
raise DatabayNetworkError(
|
|
225
|
+
f"{request.method} {request.path}: the server did not answer in time ({type(exc).__name__})"
|
|
226
|
+
) from exc
|
|
227
|
+
except httpx.TransportError as exc:
|
|
228
|
+
raise DatabayNetworkError(
|
|
229
|
+
f"{request.method} {request.path}: could not reach {self.server} ({type(exc).__name__})"
|
|
230
|
+
) from exc
|
|
231
|
+
answer = Answer(
|
|
232
|
+
status=response.status_code,
|
|
233
|
+
headers={k.lower(): v for k, v in response.headers.items()},
|
|
234
|
+
content=response.content,
|
|
235
|
+
request_id=response.headers.get(CORRELATION_HEADER, self.request_id),
|
|
236
|
+
attempts=attempt,
|
|
237
|
+
)
|
|
238
|
+
if self.verbose:
|
|
239
|
+
self.events(
|
|
240
|
+
"response",
|
|
241
|
+
{
|
|
242
|
+
"status": answer.status,
|
|
243
|
+
"elapsed_ms": round((time.monotonic() - started) * 1000),
|
|
244
|
+
"bytes": len(answer.content),
|
|
245
|
+
"request_id": answer.request_id,
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
return answer
|
|
249
|
+
|
|
250
|
+
def _log_request(self, request: Request, url: str, headers: dict[str, str], attempt: int) -> None:
|
|
251
|
+
fields: dict[str, Any] = {
|
|
252
|
+
"method": request.method,
|
|
253
|
+
"url": url,
|
|
254
|
+
"query": request.query,
|
|
255
|
+
"headers": redact.headers(headers),
|
|
256
|
+
"attempt": attempt,
|
|
257
|
+
}
|
|
258
|
+
if request.has_body:
|
|
259
|
+
fields["body"] = redact.body(request.json_body, request.secret_fields)
|
|
260
|
+
self.events("request", fields)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def new_http_client(settings: ClientSettings) -> httpx.Client:
|
|
264
|
+
"""The httpx client requests go through unless a caller hands one in: the settings' timeouts, no redirects."""
|
|
265
|
+
import httpx # noqa: PLC0415 — imported only when a command talks to a server
|
|
266
|
+
|
|
267
|
+
timeout = httpx.Timeout(settings.read_timeout_s, connect=settings.connect_timeout_s)
|
|
268
|
+
return httpx.Client(timeout=timeout, follow_redirects=False)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def retry_after_seconds(value: str | None) -> float | None:
|
|
272
|
+
"""
|
|
273
|
+
``Retry-After`` as seconds, from a number or an HTTP date.
|
|
274
|
+
|
|
275
|
+
``None`` when the header is absent or unparsable: the caller's own backoff applies then.
|
|
276
|
+
"""
|
|
277
|
+
if value is None:
|
|
278
|
+
return None
|
|
279
|
+
try:
|
|
280
|
+
seconds = float(value)
|
|
281
|
+
except ValueError:
|
|
282
|
+
pass
|
|
283
|
+
else:
|
|
284
|
+
return max(seconds, 0.0) if math.isfinite(seconds) else None
|
|
285
|
+
try:
|
|
286
|
+
when = parsedate_to_datetime(value)
|
|
287
|
+
except (TypeError, ValueError):
|
|
288
|
+
return None
|
|
289
|
+
return max(when.timestamp() - time.time(), 0.0)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _message(detail: Any, status: int, code: str | None) -> str: # noqa: ANN401 — any JSON value
|
|
293
|
+
if isinstance(detail, str):
|
|
294
|
+
return detail
|
|
295
|
+
if isinstance(detail, dict) and isinstance(detail.get("message"), str):
|
|
296
|
+
# The envelope some services answer under ``detail``: ``{"message": ..., ...}``.
|
|
297
|
+
return detail["message"]
|
|
298
|
+
return f"HTTP {status}" + (f" {code}" if code else "")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def error_from(answer: Answer, request: Request) -> DatabayError:
|
|
302
|
+
"""The exception an error answer names: by its ``code``, or the named error for a body that does not fit."""
|
|
303
|
+
from databay.error_codes import EXCEPTIONS # noqa: PLC0415 — loaded only on an error
|
|
304
|
+
from databay.feature_header import FEATURE_HEADER, parse_feature_header # noqa: PLC0415 — likewise
|
|
305
|
+
|
|
306
|
+
where = f"{request.method} {request.path}"
|
|
307
|
+
common: dict[str, Any] = {"status": answer.status, "attempts": answer.attempts}
|
|
308
|
+
body: Any = None
|
|
309
|
+
if answer.is_json:
|
|
310
|
+
try:
|
|
311
|
+
body = answer.json()
|
|
312
|
+
except ValueError:
|
|
313
|
+
body = None
|
|
314
|
+
if not isinstance(body, dict):
|
|
315
|
+
if is_transient(answer.status):
|
|
316
|
+
return DatabayServerUnavailable(f"{where}: HTTP {answer.status} without a Databay error body", **common)
|
|
317
|
+
# A 4xx no Databay service answered (a wrong server URL, a proxy's page) is not transient.
|
|
318
|
+
return DatabayError(
|
|
319
|
+
f"{where}: HTTP {answer.status} without a Databay error body; check the profile's server URL",
|
|
320
|
+
code="non_databay_answer",
|
|
321
|
+
**common,
|
|
322
|
+
)
|
|
323
|
+
code = body.get("code")
|
|
324
|
+
detail = body.get("detail")
|
|
325
|
+
extra = {k: v for k, v in body.items() if k not in {"code", "detail"}}
|
|
326
|
+
if code is None:
|
|
327
|
+
is_404 = answer.status == 404 # noqa: PLR2004
|
|
328
|
+
off = parse_feature_header(answer.headers.get(FEATURE_HEADER)) if is_404 else None
|
|
329
|
+
if off is not None:
|
|
330
|
+
return DatabayFeatureDisabled(
|
|
331
|
+
f"{where}: the feature {off.feature} is off (source: {off.source})",
|
|
332
|
+
feature=off.feature,
|
|
333
|
+
source=off.source,
|
|
334
|
+
**common,
|
|
335
|
+
)
|
|
336
|
+
return DatabayUncodedError(
|
|
337
|
+
f"{where}: HTTP {answer.status} without a code: {_message(detail, answer.status, None)}",
|
|
338
|
+
hint=UNCODED_404_HINT if is_404 else None,
|
|
339
|
+
details={**extra, "detail": detail},
|
|
340
|
+
**common,
|
|
341
|
+
)
|
|
342
|
+
if not isinstance(code, str) or code not in EXCEPTIONS:
|
|
343
|
+
return DatabayUnknownCodeError(
|
|
344
|
+
f"{where}: the server answered code {code!r}, which this CLI does not know",
|
|
345
|
+
details={**extra, "server_code": code, "detail": detail},
|
|
346
|
+
**common,
|
|
347
|
+
)
|
|
348
|
+
if not isinstance(detail, str):
|
|
349
|
+
extra["detail"] = detail
|
|
350
|
+
docs = detail.get("docs_url") if isinstance(detail, dict) else None
|
|
351
|
+
return EXCEPTIONS[code](
|
|
352
|
+
_message(detail, answer.status, code),
|
|
353
|
+
details=extra,
|
|
354
|
+
support_ref=body.get("support_ref") if isinstance(body.get("support_ref"), str) else None,
|
|
355
|
+
docs=docs if isinstance(docs, str) else None,
|
|
356
|
+
**common,
|
|
357
|
+
)
|
databay/update_check.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The update notice: ``databay <new> is available: uv tool upgrade databay-cli`` on stderr.
|
|
3
|
+
|
|
4
|
+
Only on a terminal (stdout and stderr), never under ``$CI``, and off with
|
|
5
|
+
``DATABAY_NO_UPDATE_CHECK=1``. PyPI is asked at most once per ``update_check_interval_s``, in a
|
|
6
|
+
background thread that runs beside the command; when the command is done it waits for the thread
|
|
7
|
+
at most ``update_check_timeout_s`` more. The answer is cached in the state dir, and the notice is
|
|
8
|
+
printed from that cache at most once per interval. A failed or unfinished check prints nothing,
|
|
9
|
+
except the ``update_check_failed`` event under ``--verbose``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import TYPE_CHECKING, Any, TextIO
|
|
20
|
+
|
|
21
|
+
from databay.paths import state_dir
|
|
22
|
+
from databay.settings import ENV_CI, ENV_NO_UPDATE_CHECK, SETTINGS
|
|
23
|
+
from databay.version import CLIENT_VERSION
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
PACKAGE = "databay-cli"
|
|
30
|
+
PYPI_URL = f"https://pypi.org/pypi/{PACKAGE}/json"
|
|
31
|
+
UPGRADE_COMMAND = f"uv tool upgrade {PACKAGE}"
|
|
32
|
+
CACHE_NAME = "update-check.json"
|
|
33
|
+
#: A cache file named for the package, which some installs still have; every cache write deletes it.
|
|
34
|
+
RETIRED_CACHE_NAME = "update-check-databay-cli.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def enabled(environ: dict[str, str], stdout: TextIO, stderr: TextIO) -> bool:
|
|
38
|
+
if environ.get(ENV_NO_UPDATE_CHECK) == "1" or environ.get(ENV_CI):
|
|
39
|
+
return False
|
|
40
|
+
return stdout.isatty() and stderr.isatty()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _release(text: str) -> tuple[int, ...]:
|
|
44
|
+
"""``1.2.3`` as ``(1, 2, 3)``; a pre-release or anything else non-numeric is a ``ValueError``."""
|
|
45
|
+
return tuple(int(part) for part in text.split("."))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cache_file() -> Path:
|
|
49
|
+
return state_dir() / CACHE_NAME
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_cache() -> dict[str, Any]:
|
|
53
|
+
"""
|
|
54
|
+
The cached answer about ``PACKAGE``; nothing when the cache is about another package.
|
|
55
|
+
|
|
56
|
+
An ``update-check.json`` without ``package`` holds an answer about the unrelated ``databay`` on
|
|
57
|
+
PyPI. It is ignored here, and the next check replaces it.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
data = json.loads(_cache_file().read_text(encoding="utf-8"))
|
|
61
|
+
except (OSError, ValueError):
|
|
62
|
+
return {}
|
|
63
|
+
return data if isinstance(data, dict) and data.get("package") == PACKAGE else {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _write_cache(data: dict[str, Any]) -> None:
|
|
67
|
+
path = _cache_file()
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
tmp = path.with_suffix(f".{os.getpid()}.tmp")
|
|
70
|
+
tmp.write_text(json.dumps({**data, "package": PACKAGE}), encoding="utf-8")
|
|
71
|
+
tmp.replace(path)
|
|
72
|
+
(path.parent / RETIRED_CACHE_NAME).unlink(missing_ok=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fetch_latest() -> str:
|
|
76
|
+
"""The newest release on PyPI."""
|
|
77
|
+
import httpx # noqa: PLC0415
|
|
78
|
+
|
|
79
|
+
response = httpx.get(PYPI_URL, timeout=SETTINGS.update_check_timeout_s)
|
|
80
|
+
response.raise_for_status()
|
|
81
|
+
version = response.json()["info"]["version"]
|
|
82
|
+
if not isinstance(version, str):
|
|
83
|
+
raise TypeError("info.version is not a string")
|
|
84
|
+
return version
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class UpdateCheck:
|
|
89
|
+
fetch: Callable[[], str] = fetch_latest
|
|
90
|
+
now: Callable[[], float] = time.time
|
|
91
|
+
#: How long ``finish`` waits for a check still running.
|
|
92
|
+
wait_s: float = SETTINGS.update_check_timeout_s
|
|
93
|
+
#: The exception a check in this process raised, reported under ``--verbose``.
|
|
94
|
+
failure: BaseException | None = None
|
|
95
|
+
_thread: threading.Thread | None = field(default=None, repr=False)
|
|
96
|
+
|
|
97
|
+
def start(self) -> None:
|
|
98
|
+
"""Ask PyPI in the background when the cached answer is older than the interval."""
|
|
99
|
+
checked = _read_cache().get("checked_at")
|
|
100
|
+
if isinstance(checked, int | float) and self.now() - checked < SETTINGS.update_check_interval_s:
|
|
101
|
+
return
|
|
102
|
+
self._thread = threading.Thread(target=self._check, name="databay-update-check", daemon=True)
|
|
103
|
+
self._thread.start()
|
|
104
|
+
|
|
105
|
+
def _check(self) -> None:
|
|
106
|
+
latest: str | None = None
|
|
107
|
+
try:
|
|
108
|
+
latest = self.fetch()
|
|
109
|
+
_release(latest)
|
|
110
|
+
except Exception as exc: # noqa: BLE001 — any failure only skips the notice
|
|
111
|
+
self.failure = exc
|
|
112
|
+
latest = None
|
|
113
|
+
# Read just before writing, so a notice shown meanwhile keeps its noticed_at.
|
|
114
|
+
cache = _read_cache()
|
|
115
|
+
if latest is not None:
|
|
116
|
+
cache["latest"] = latest
|
|
117
|
+
cache["checked_at"] = self.now()
|
|
118
|
+
try:
|
|
119
|
+
_write_cache(cache)
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
self.failure = exc
|
|
122
|
+
|
|
123
|
+
def finish(self, stderr: TextIO, event: Callable[[str, dict[str, Any]], None] | None) -> None:
|
|
124
|
+
"""Print the notice from the cache when it names a newer release and none was shown this interval."""
|
|
125
|
+
if self._thread is not None:
|
|
126
|
+
self._thread.join(timeout=self.wait_s)
|
|
127
|
+
if self._thread.is_alive() and self.failure is None:
|
|
128
|
+
self.failure = TimeoutError("the PyPI check did not finish")
|
|
129
|
+
self._notice(stderr)
|
|
130
|
+
if self.failure is not None and event is not None:
|
|
131
|
+
event("update_check_failed", {"error": type(self.failure).__name__})
|
|
132
|
+
|
|
133
|
+
def _notice(self, stderr: TextIO) -> None:
|
|
134
|
+
cache = _read_cache()
|
|
135
|
+
latest, noticed = cache.get("latest"), cache.get("noticed_at")
|
|
136
|
+
if not isinstance(latest, str):
|
|
137
|
+
return
|
|
138
|
+
try:
|
|
139
|
+
newer = _release(latest) > _release(CLIENT_VERSION)
|
|
140
|
+
except ValueError:
|
|
141
|
+
return
|
|
142
|
+
if not newer:
|
|
143
|
+
return
|
|
144
|
+
if isinstance(noticed, int | float) and self.now() - noticed < SETTINGS.update_check_interval_s:
|
|
145
|
+
return
|
|
146
|
+
stderr.write(f"databay {latest} is available: {UPGRADE_COMMAND}\n")
|
|
147
|
+
cache["noticed_at"] = self.now()
|
|
148
|
+
try:
|
|
149
|
+
_write_cache(cache)
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
self.failure = exc
|