airbyte-agent-jira 0.1.22__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.
- airbyte_agent_jira/__init__.py +91 -0
- airbyte_agent_jira/_vendored/__init__.py +1 -0
- airbyte_agent_jira/_vendored/connector_sdk/__init__.py +82 -0
- airbyte_agent_jira/_vendored/connector_sdk/auth_strategies.py +1123 -0
- airbyte_agent_jira/_vendored/connector_sdk/auth_template.py +135 -0
- airbyte_agent_jira/_vendored/connector_sdk/cloud_utils/__init__.py +5 -0
- airbyte_agent_jira/_vendored/connector_sdk/cloud_utils/client.py +213 -0
- airbyte_agent_jira/_vendored/connector_sdk/connector_model_loader.py +965 -0
- airbyte_agent_jira/_vendored/connector_sdk/constants.py +78 -0
- airbyte_agent_jira/_vendored/connector_sdk/exceptions.py +23 -0
- airbyte_agent_jira/_vendored/connector_sdk/executor/__init__.py +31 -0
- airbyte_agent_jira/_vendored/connector_sdk/executor/hosted_executor.py +197 -0
- airbyte_agent_jira/_vendored/connector_sdk/executor/local_executor.py +1574 -0
- airbyte_agent_jira/_vendored/connector_sdk/executor/models.py +190 -0
- airbyte_agent_jira/_vendored/connector_sdk/extensions.py +694 -0
- airbyte_agent_jira/_vendored/connector_sdk/http/__init__.py +37 -0
- airbyte_agent_jira/_vendored/connector_sdk/http/adapters/__init__.py +9 -0
- airbyte_agent_jira/_vendored/connector_sdk/http/adapters/httpx_adapter.py +251 -0
- airbyte_agent_jira/_vendored/connector_sdk/http/config.py +98 -0
- airbyte_agent_jira/_vendored/connector_sdk/http/exceptions.py +119 -0
- airbyte_agent_jira/_vendored/connector_sdk/http/protocols.py +114 -0
- airbyte_agent_jira/_vendored/connector_sdk/http/response.py +102 -0
- airbyte_agent_jira/_vendored/connector_sdk/http_client.py +686 -0
- airbyte_agent_jira/_vendored/connector_sdk/introspection.py +262 -0
- airbyte_agent_jira/_vendored/connector_sdk/logging/__init__.py +11 -0
- airbyte_agent_jira/_vendored/connector_sdk/logging/logger.py +264 -0
- airbyte_agent_jira/_vendored/connector_sdk/logging/types.py +92 -0
- airbyte_agent_jira/_vendored/connector_sdk/observability/__init__.py +11 -0
- airbyte_agent_jira/_vendored/connector_sdk/observability/models.py +19 -0
- airbyte_agent_jira/_vendored/connector_sdk/observability/redactor.py +81 -0
- airbyte_agent_jira/_vendored/connector_sdk/observability/session.py +94 -0
- airbyte_agent_jira/_vendored/connector_sdk/performance/__init__.py +6 -0
- airbyte_agent_jira/_vendored/connector_sdk/performance/instrumentation.py +57 -0
- airbyte_agent_jira/_vendored/connector_sdk/performance/metrics.py +93 -0
- airbyte_agent_jira/_vendored/connector_sdk/schema/__init__.py +75 -0
- airbyte_agent_jira/_vendored/connector_sdk/schema/base.py +161 -0
- airbyte_agent_jira/_vendored/connector_sdk/schema/components.py +239 -0
- airbyte_agent_jira/_vendored/connector_sdk/schema/connector.py +131 -0
- airbyte_agent_jira/_vendored/connector_sdk/schema/extensions.py +109 -0
- airbyte_agent_jira/_vendored/connector_sdk/schema/operations.py +146 -0
- airbyte_agent_jira/_vendored/connector_sdk/schema/security.py +223 -0
- airbyte_agent_jira/_vendored/connector_sdk/secrets.py +182 -0
- airbyte_agent_jira/_vendored/connector_sdk/telemetry/__init__.py +10 -0
- airbyte_agent_jira/_vendored/connector_sdk/telemetry/config.py +32 -0
- airbyte_agent_jira/_vendored/connector_sdk/telemetry/events.py +58 -0
- airbyte_agent_jira/_vendored/connector_sdk/telemetry/tracker.py +151 -0
- airbyte_agent_jira/_vendored/connector_sdk/types.py +245 -0
- airbyte_agent_jira/_vendored/connector_sdk/utils.py +60 -0
- airbyte_agent_jira/_vendored/connector_sdk/validation.py +822 -0
- airbyte_agent_jira/connector.py +978 -0
- airbyte_agent_jira/connector_model.py +2827 -0
- airbyte_agent_jira/models.py +741 -0
- airbyte_agent_jira/types.py +117 -0
- airbyte_agent_jira-0.1.22.dist-info/METADATA +113 -0
- airbyte_agent_jira-0.1.22.dist-info/RECORD +56 -0
- airbyte_agent_jira-0.1.22.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security scheme models for OpenAPI 3.1.
|
|
3
|
+
|
|
4
|
+
References:
|
|
5
|
+
- https://spec.openapis.org/oas/v3.1.0#security-scheme-object
|
|
6
|
+
- https://spec.openapis.org/oas/v3.1.0#oauth-flows-object
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OAuth2Flow(BaseModel):
|
|
15
|
+
"""
|
|
16
|
+
OAuth 2.0 flow configuration.
|
|
17
|
+
|
|
18
|
+
OpenAPI Reference: https://spec.openapis.org/oas/v3.1.0#oauth-flow-object
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
|
22
|
+
|
|
23
|
+
authorization_url: Optional[str] = Field(None, alias="authorizationUrl")
|
|
24
|
+
token_url: Optional[str] = Field(None, alias="tokenUrl")
|
|
25
|
+
refresh_url: Optional[str] = Field(None, alias="refreshUrl")
|
|
26
|
+
scopes: Dict[str, str] = Field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OAuth2Flows(BaseModel):
|
|
30
|
+
"""
|
|
31
|
+
Collection of OAuth 2.0 flows.
|
|
32
|
+
|
|
33
|
+
OpenAPI Reference: https://spec.openapis.org/oas/v3.1.0#oauth-flows-object
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
|
37
|
+
|
|
38
|
+
implicit: Optional[OAuth2Flow] = None
|
|
39
|
+
password: Optional[OAuth2Flow] = None
|
|
40
|
+
client_credentials: Optional[OAuth2Flow] = Field(None, alias="clientCredentials")
|
|
41
|
+
authorization_code: Optional[OAuth2Flow] = Field(None, alias="authorizationCode")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthConfigFieldSpec(BaseModel):
|
|
45
|
+
"""
|
|
46
|
+
Specification for a user-facing authentication config field.
|
|
47
|
+
|
|
48
|
+
This defines a single input field that users provide for authentication.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
|
52
|
+
|
|
53
|
+
type: Literal["string", "integer", "boolean", "number"] = "string"
|
|
54
|
+
title: Optional[str] = None
|
|
55
|
+
description: Optional[str] = None
|
|
56
|
+
format: Optional[str] = None # e.g., "email", "uri"
|
|
57
|
+
pattern: Optional[str] = None # Regex validation
|
|
58
|
+
airbyte_secret: bool = Field(False, alias="airbyte_secret")
|
|
59
|
+
default: Optional[Any] = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AuthConfigOption(BaseModel):
|
|
63
|
+
"""
|
|
64
|
+
A single authentication configuration option.
|
|
65
|
+
|
|
66
|
+
Defines user-facing fields and how they map to auth parameters.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
|
70
|
+
|
|
71
|
+
title: Optional[str] = None
|
|
72
|
+
description: Optional[str] = None
|
|
73
|
+
type: Literal["object"] = "object"
|
|
74
|
+
required: List[str] = Field(default_factory=list)
|
|
75
|
+
properties: Dict[str, AuthConfigFieldSpec] = Field(default_factory=dict)
|
|
76
|
+
auth_mapping: Dict[str, str] = Field(
|
|
77
|
+
default_factory=dict,
|
|
78
|
+
description="Mapping from auth parameters (e.g., 'username', 'password', 'token') to template strings using ${field} syntax",
|
|
79
|
+
)
|
|
80
|
+
replication_auth_key_mapping: Optional[Dict[str, str]] = Field(
|
|
81
|
+
None,
|
|
82
|
+
description="Mapping from source config paths (e.g., 'credentials.api_key') to auth config keys for direct connectors",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AirbyteAuthConfig(BaseModel):
|
|
87
|
+
"""
|
|
88
|
+
Airbyte auth configuration extension (x-airbyte-auth-config).
|
|
89
|
+
|
|
90
|
+
Defines user-facing authentication configuration and how it maps to
|
|
91
|
+
the underlying OpenAPI security scheme.
|
|
92
|
+
|
|
93
|
+
Either a single auth option or multiple options via oneOf.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
|
97
|
+
|
|
98
|
+
# Single option fields
|
|
99
|
+
title: Optional[str] = None
|
|
100
|
+
description: Optional[str] = None
|
|
101
|
+
type: Optional[Literal["object"]] = None
|
|
102
|
+
required: Optional[List[str]] = None
|
|
103
|
+
properties: Optional[Dict[str, AuthConfigFieldSpec]] = None
|
|
104
|
+
auth_mapping: Optional[Dict[str, str]] = None
|
|
105
|
+
|
|
106
|
+
# Replication connector auth mapping
|
|
107
|
+
replication_auth_key_mapping: Optional[Dict[str, str]] = Field(
|
|
108
|
+
None,
|
|
109
|
+
description="Mapping from source config paths (e.g., 'credentials.api_key') to auth config keys for direct connectors",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Multiple options (oneOf)
|
|
113
|
+
one_of: Optional[List[AuthConfigOption]] = Field(None, alias="oneOf")
|
|
114
|
+
|
|
115
|
+
@model_validator(mode="after")
|
|
116
|
+
def validate_config_structure(self) -> "AirbyteAuthConfig":
|
|
117
|
+
"""Validate that either single option or oneOf is provided, not both."""
|
|
118
|
+
has_single = self.type is not None or self.properties is not None or self.auth_mapping is not None
|
|
119
|
+
has_one_of = self.one_of is not None and len(self.one_of) > 0
|
|
120
|
+
|
|
121
|
+
if not has_single and not has_one_of:
|
|
122
|
+
raise ValueError("Either single auth option (type/properties/auth_mapping) or oneOf must be provided")
|
|
123
|
+
|
|
124
|
+
if has_single and has_one_of:
|
|
125
|
+
raise ValueError("Cannot have both single auth option and oneOf")
|
|
126
|
+
|
|
127
|
+
if has_single:
|
|
128
|
+
# Validate single option has required fields
|
|
129
|
+
if self.type != "object":
|
|
130
|
+
raise ValueError("Single auth option must have type='object'")
|
|
131
|
+
if not self.properties:
|
|
132
|
+
raise ValueError("Single auth option must have properties")
|
|
133
|
+
if not self.auth_mapping:
|
|
134
|
+
raise ValueError("Single auth option must have auth_mapping")
|
|
135
|
+
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class SecurityScheme(BaseModel):
|
|
140
|
+
"""
|
|
141
|
+
Security scheme definition.
|
|
142
|
+
|
|
143
|
+
OpenAPI Reference: https://spec.openapis.org/oas/v3.1.0#security-scheme-object
|
|
144
|
+
|
|
145
|
+
Supported Types:
|
|
146
|
+
- apiKey: API key in header/query/cookie
|
|
147
|
+
- http: HTTP authentication (basic, bearer, digest, etc.)
|
|
148
|
+
- oauth2: OAuth 2.0 flows
|
|
149
|
+
|
|
150
|
+
Extensions:
|
|
151
|
+
- x-airbyte-token-path: JSON path to extract token from auth response (Airbyte extension)
|
|
152
|
+
- x-airbyte-token-refresh: OAuth2 token refresh configuration (dict with auth_style, body_format)
|
|
153
|
+
- x-airbyte-auth-config: User-facing authentication configuration (Airbyte extension)
|
|
154
|
+
|
|
155
|
+
Future extensions (not yet active):
|
|
156
|
+
- x-grant-type: OAuth grant type for refresh tokens
|
|
157
|
+
- x-refresh-endpoint: Custom refresh endpoint URL
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
161
|
+
|
|
162
|
+
# Standard OpenAPI fields
|
|
163
|
+
type: Literal["apiKey", "http", "oauth2", "openIdConnect"]
|
|
164
|
+
description: Optional[str] = None
|
|
165
|
+
|
|
166
|
+
# apiKey specific
|
|
167
|
+
name: Optional[str] = None
|
|
168
|
+
in_: Optional[Literal["query", "header", "cookie"]] = Field(None, alias="in")
|
|
169
|
+
|
|
170
|
+
# http specific
|
|
171
|
+
scheme: Optional[str] = None # e.g., "basic", "bearer", "digest"
|
|
172
|
+
bearer_format: Optional[str] = Field(None, alias="bearerFormat")
|
|
173
|
+
|
|
174
|
+
# oauth2 specific
|
|
175
|
+
flows: Optional[OAuth2Flows] = None
|
|
176
|
+
|
|
177
|
+
# openIdConnect specific
|
|
178
|
+
open_id_connect_url: Optional[str] = Field(None, alias="openIdConnectUrl")
|
|
179
|
+
|
|
180
|
+
# Airbyte extensions
|
|
181
|
+
x_token_path: Optional[str] = Field(None, alias="x-airbyte-token-path")
|
|
182
|
+
x_token_refresh: Optional[Dict[str, Any]] = Field(None, alias="x-airbyte-token-refresh")
|
|
183
|
+
x_airbyte_auth_config: Optional[AirbyteAuthConfig] = Field(None, alias="x-airbyte-auth-config")
|
|
184
|
+
x_airbyte_token_extract: Optional[List[str]] = Field(
|
|
185
|
+
None,
|
|
186
|
+
alias="x-airbyte-token-extract",
|
|
187
|
+
description="List of fields to extract from OAuth2 token responses and use as server variables",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
@field_validator("x_airbyte_token_extract", mode="after")
|
|
191
|
+
@classmethod
|
|
192
|
+
def validate_token_extract(cls, v: Optional[List[str]]) -> Optional[List[str]]:
|
|
193
|
+
"""Validate x-airbyte-token-extract has no duplicates."""
|
|
194
|
+
if v is not None:
|
|
195
|
+
if len(v) != len(set(v)):
|
|
196
|
+
duplicates = [x for x in v if v.count(x) > 1]
|
|
197
|
+
raise ValueError(f"x-airbyte-token-extract contains duplicate fields: {set(duplicates)}")
|
|
198
|
+
return v
|
|
199
|
+
|
|
200
|
+
# Future extensions (commented out, defined for future use)
|
|
201
|
+
# x_grant_type: Optional[Literal["refresh_token", "client_credentials"]] = Field(None, alias="x-grant-type")
|
|
202
|
+
# x_refresh_endpoint: Optional[str] = Field(None, alias="x-refresh-endpoint")
|
|
203
|
+
|
|
204
|
+
@model_validator(mode="after")
|
|
205
|
+
def validate_security_scheme(self) -> "SecurityScheme":
|
|
206
|
+
"""Validate that required fields are present based on security type."""
|
|
207
|
+
if self.type == "apiKey":
|
|
208
|
+
if not self.name or not self.in_:
|
|
209
|
+
raise ValueError("apiKey type requires 'name' and 'in' fields")
|
|
210
|
+
elif self.type == "http":
|
|
211
|
+
if not self.scheme:
|
|
212
|
+
raise ValueError("http type requires 'scheme' field")
|
|
213
|
+
elif self.type == "oauth2":
|
|
214
|
+
if not self.flows:
|
|
215
|
+
raise ValueError("oauth2 type requires 'flows' field")
|
|
216
|
+
elif self.type == "openIdConnect":
|
|
217
|
+
if not self.open_id_connect_url:
|
|
218
|
+
raise ValueError("openIdConnect type requires 'openIdConnectUrl' field")
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# SecurityRequirement is a dict mapping security scheme name to list of scopes
|
|
223
|
+
SecurityRequirement = Dict[str, List[str]]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Secret handling utilities for the Airbyte SDK.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for handling sensitive data like API keys, tokens,
|
|
4
|
+
and passwords. It uses Pydantic's SecretStr to ensure secrets are obfuscated in
|
|
5
|
+
logs, error messages, and string representations.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from .secrets import SecretStr
|
|
9
|
+
>>> api_key = SecretStr("my-secret-key")
|
|
10
|
+
>>> print(api_key) # Outputs: **********
|
|
11
|
+
>>> print(repr(api_key)) # Outputs: SecretStr('**********')
|
|
12
|
+
>>> api_key.get_secret_value() # Returns: 'my-secret-key'
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
from pydantic import SecretStr
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"SecretStr",
|
|
23
|
+
"convert_to_secret_dict",
|
|
24
|
+
"get_secret_values",
|
|
25
|
+
"resolve_env_var_references",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def convert_to_secret_dict(secrets: Dict[str, str]) -> Dict[str, SecretStr]:
|
|
30
|
+
"""Convert a dictionary of plain string secrets to SecretStr values.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
secrets: Dictionary with string keys and plain string secret values
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Dictionary with string keys and SecretStr values
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
>>> plain_secrets = {"api_key": "secret123", "token": "token456"}
|
|
40
|
+
>>> secret_dict = convert_to_secret_dict(plain_secrets)
|
|
41
|
+
>>> print(secret_dict["api_key"]) # Outputs: **********
|
|
42
|
+
"""
|
|
43
|
+
return {key: SecretStr(value) for key, value in secrets.items()}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_secret_values(secrets: Dict[str, SecretStr]) -> Dict[str, str]:
|
|
47
|
+
"""Extract plain string values from a dictionary of SecretStr values.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
secrets: Dictionary with string keys and SecretStr values
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Dictionary with string keys and plain string values
|
|
54
|
+
|
|
55
|
+
Warning:
|
|
56
|
+
Use with caution. This exposes the actual secret values.
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
>>> secret_dict = {"api_key": SecretStr("secret123")}
|
|
60
|
+
>>> plain_dict = get_secret_values(secret_dict)
|
|
61
|
+
>>> print(plain_dict["api_key"]) # Outputs: secret123
|
|
62
|
+
"""
|
|
63
|
+
return {key: value.get_secret_value() for key, value in secrets.items()}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SecretResolutionError(Exception):
|
|
67
|
+
"""Raised when environment variable resolution fails."""
|
|
68
|
+
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_env_var_references(
|
|
73
|
+
secret_mappings: Dict[str, Any],
|
|
74
|
+
strict: bool = True,
|
|
75
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
76
|
+
) -> Dict[str, str]:
|
|
77
|
+
"""Resolve environment variable references in secret values.
|
|
78
|
+
|
|
79
|
+
This function processes a dictionary of secret mappings and resolves any
|
|
80
|
+
environment variable references using the ${ENV_VAR_NAME} syntax. All
|
|
81
|
+
environment variable references in the values will be replaced with their
|
|
82
|
+
actual values from the provided environment variable map or os.environ.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
secret_mappings: Dictionary mapping secret keys to values that may contain
|
|
86
|
+
environment variable references (e.g., {"token": "${API_KEY}"})
|
|
87
|
+
strict: If True, raises SecretResolutionError when a referenced environment
|
|
88
|
+
variable is not found. If False, leaves unresolved references as-is.
|
|
89
|
+
env_vars: Optional dictionary of environment variables to use for resolution.
|
|
90
|
+
If None, uses os.environ.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Dictionary with the same keys but environment variable references resolved
|
|
94
|
+
to their actual values as plain strings
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
SecretResolutionError: When strict=True and a referenced environment variable
|
|
98
|
+
is not found or is empty
|
|
99
|
+
ValueError: When an environment variable name doesn't match valid naming
|
|
100
|
+
conventions (must start with letter or underscore, followed by
|
|
101
|
+
alphanumeric characters or underscores)
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
>>> import os
|
|
105
|
+
>>> os.environ["MY_TOKEN"] = "secret_value_123"
|
|
106
|
+
>>> mappings = {"token": "${MY_TOKEN}", "literal": "plain_value"}
|
|
107
|
+
>>> resolved = resolve_env_var_references(mappings)
|
|
108
|
+
>>> print(resolved)
|
|
109
|
+
{'token': 'secret_value_123', 'literal': 'plain_value'}
|
|
110
|
+
|
|
111
|
+
>>> # Using custom env_vars dict
|
|
112
|
+
>>> custom_env = {"CUSTOM_TOKEN": "my_secret"}
|
|
113
|
+
>>> mappings = {"token": "${CUSTOM_TOKEN}"}
|
|
114
|
+
>>> resolved = resolve_env_var_references(mappings, env_vars=custom_env)
|
|
115
|
+
>>> print(resolved)
|
|
116
|
+
{'token': 'my_secret'}
|
|
117
|
+
|
|
118
|
+
>>> # Multiple references in one value
|
|
119
|
+
>>> mappings = {"combined": "${PREFIX}_${SUFFIX}"}
|
|
120
|
+
>>> os.environ["PREFIX"] = "api"
|
|
121
|
+
>>> os.environ["SUFFIX"] = "key"
|
|
122
|
+
>>> resolved = resolve_env_var_references(mappings)
|
|
123
|
+
>>> print(resolved["combined"])
|
|
124
|
+
'api_key'
|
|
125
|
+
|
|
126
|
+
>>> # Missing variable with strict=True (raises error)
|
|
127
|
+
>>> try:
|
|
128
|
+
... resolve_env_var_references({"token": "${MISSING_VAR}"})
|
|
129
|
+
... except SecretResolutionError as e:
|
|
130
|
+
... print(f"Error: {e}")
|
|
131
|
+
Error: Environment variable 'MISSING_VAR' not found or empty
|
|
132
|
+
|
|
133
|
+
>>> # Missing variable with strict=False (keeps reference)
|
|
134
|
+
>>> resolved = resolve_env_var_references(
|
|
135
|
+
... {"token": "${MISSING_VAR}"},
|
|
136
|
+
... strict=False
|
|
137
|
+
... )
|
|
138
|
+
>>> print(resolved["token"])
|
|
139
|
+
${MISSING_VAR}
|
|
140
|
+
"""
|
|
141
|
+
resolved = {}
|
|
142
|
+
|
|
143
|
+
# Use provided env_vars or default to os.environ
|
|
144
|
+
env_source = env_vars if env_vars is not None else os.environ
|
|
145
|
+
|
|
146
|
+
# Environment variable name pattern: starts with letter or underscore,
|
|
147
|
+
# followed by alphanumeric or underscores
|
|
148
|
+
env_var_pattern = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
149
|
+
|
|
150
|
+
for key, value in secret_mappings.items():
|
|
151
|
+
if isinstance(value, str):
|
|
152
|
+
|
|
153
|
+
def replacer(match: re.Match) -> str:
|
|
154
|
+
"""Replace a single ${ENV_VAR} reference with its value."""
|
|
155
|
+
env_var_name = match.group(1)
|
|
156
|
+
|
|
157
|
+
# Validate environment variable name format
|
|
158
|
+
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", env_var_name):
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"Invalid environment variable name: '{env_var_name}'. "
|
|
161
|
+
"Must start with a letter or underscore, followed by "
|
|
162
|
+
"alphanumeric characters or underscores."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
env_value = env_source.get(env_var_name)
|
|
166
|
+
|
|
167
|
+
if env_value is None or env_value == "":
|
|
168
|
+
if strict:
|
|
169
|
+
raise SecretResolutionError(f"Environment variable '{env_var_name}' not found or empty")
|
|
170
|
+
# In non-strict mode, keep the original reference
|
|
171
|
+
return match.group(0)
|
|
172
|
+
|
|
173
|
+
return env_value
|
|
174
|
+
|
|
175
|
+
# Replace all ${ENV_VAR} references in the value
|
|
176
|
+
resolved_value = env_var_pattern.sub(replacer, value)
|
|
177
|
+
resolved[key] = resolved_value
|
|
178
|
+
else:
|
|
179
|
+
# Non-string values are converted to strings
|
|
180
|
+
resolved[key] = str(value)
|
|
181
|
+
|
|
182
|
+
return resolved
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Telemetry configuration from environment variables."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
# Hardcoded Segment write key for Airbyte telemetry
|
|
7
|
+
SEGMENT_WRITE_KEY = "sFM7q98HtHTMmCW3d6nsPWYCIdrbs7gq"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TelemetryMode(Enum):
|
|
11
|
+
"""Telemetry tracking modes."""
|
|
12
|
+
|
|
13
|
+
BASIC = "basic"
|
|
14
|
+
DISABLED = "disabled"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TelemetryConfig:
|
|
18
|
+
"""Telemetry configuration from environment variables."""
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def get_mode() -> TelemetryMode:
|
|
22
|
+
"""Get telemetry mode from environment variable."""
|
|
23
|
+
mode_str = os.getenv("AIRBYTE_TELEMETRY_MODE", "basic").lower()
|
|
24
|
+
try:
|
|
25
|
+
return TelemetryMode(mode_str)
|
|
26
|
+
except ValueError:
|
|
27
|
+
return TelemetryMode.BASIC
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def is_enabled() -> bool:
|
|
31
|
+
"""Telemetry is enabled if mode is not disabled."""
|
|
32
|
+
return TelemetryConfig.get_mode() != TelemetryMode.DISABLED
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Telemetry event models."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class BaseEvent:
|
|
10
|
+
"""Base class for all telemetry events."""
|
|
11
|
+
|
|
12
|
+
timestamp: datetime
|
|
13
|
+
session_id: str
|
|
14
|
+
user_id: str
|
|
15
|
+
execution_context: str
|
|
16
|
+
|
|
17
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
18
|
+
"""Convert event to dictionary with ISO formatted timestamp."""
|
|
19
|
+
data = asdict(self)
|
|
20
|
+
data["timestamp"] = self.timestamp.isoformat()
|
|
21
|
+
return data
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ConnectorInitEvent(BaseEvent):
|
|
26
|
+
"""Connector initialization event."""
|
|
27
|
+
|
|
28
|
+
connector_name: str
|
|
29
|
+
python_version: str
|
|
30
|
+
os_name: str
|
|
31
|
+
os_version: str
|
|
32
|
+
public_ip: Optional[str] = None
|
|
33
|
+
connector_version: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class OperationEvent(BaseEvent):
|
|
38
|
+
"""API operation event."""
|
|
39
|
+
|
|
40
|
+
connector_name: str
|
|
41
|
+
entity: str
|
|
42
|
+
action: str
|
|
43
|
+
timing_ms: float
|
|
44
|
+
public_ip: Optional[str] = None
|
|
45
|
+
status_code: Optional[int] = None
|
|
46
|
+
error_type: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class SessionEndEvent(BaseEvent):
|
|
51
|
+
"""Session end event."""
|
|
52
|
+
|
|
53
|
+
connector_name: str
|
|
54
|
+
duration_seconds: float
|
|
55
|
+
operation_count: int
|
|
56
|
+
success_count: int
|
|
57
|
+
failure_count: int
|
|
58
|
+
public_ip: Optional[str] = None
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Anonymous telemetry tracker using Segment."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import platform
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ..observability import ObservabilitySession
|
|
10
|
+
|
|
11
|
+
from .config import SEGMENT_WRITE_KEY, TelemetryConfig, TelemetryMode
|
|
12
|
+
from .events import ConnectorInitEvent, OperationEvent, SessionEndEvent
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SegmentTracker:
|
|
18
|
+
"""Anonymous telemetry tracker using Segment."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
session: ObservabilitySession,
|
|
23
|
+
mode: Optional[TelemetryMode] = None,
|
|
24
|
+
):
|
|
25
|
+
self.session = session
|
|
26
|
+
self.mode = mode or TelemetryConfig.get_mode()
|
|
27
|
+
self.success_count = 0
|
|
28
|
+
self.failure_count = 0
|
|
29
|
+
self.enabled = TelemetryConfig.is_enabled()
|
|
30
|
+
self._analytics = None
|
|
31
|
+
|
|
32
|
+
if self.enabled:
|
|
33
|
+
try:
|
|
34
|
+
import segment.analytics as analytics
|
|
35
|
+
|
|
36
|
+
analytics.write_key = SEGMENT_WRITE_KEY
|
|
37
|
+
self._analytics = analytics
|
|
38
|
+
self._log_startup_message()
|
|
39
|
+
except ImportError:
|
|
40
|
+
logger.warning("Telemetry disabled: segment-analytics-python not installed")
|
|
41
|
+
self.enabled = False
|
|
42
|
+
|
|
43
|
+
def _log_startup_message(self):
|
|
44
|
+
"""Log message when telemetry is enabled."""
|
|
45
|
+
logger.info(f"Anonymous telemetry enabled (mode: {self.mode.value})")
|
|
46
|
+
logger.info("To opt-out: export AIRBYTE_TELEMETRY_MODE=disabled")
|
|
47
|
+
|
|
48
|
+
def track_connector_init(
|
|
49
|
+
self,
|
|
50
|
+
connector_version: Optional[str] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Track connector initialization."""
|
|
53
|
+
if not self.enabled or not self._analytics:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
event = ConnectorInitEvent(
|
|
58
|
+
timestamp=datetime.utcnow(),
|
|
59
|
+
session_id=self.session.session_id,
|
|
60
|
+
user_id=self.session.user_id,
|
|
61
|
+
execution_context=self.session.execution_context,
|
|
62
|
+
public_ip=self.session.public_ip,
|
|
63
|
+
connector_name=self.session.connector_name,
|
|
64
|
+
connector_version=connector_version,
|
|
65
|
+
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
|
66
|
+
os_name=platform.system(),
|
|
67
|
+
os_version=platform.release(),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self._analytics.track(
|
|
71
|
+
user_id=self.session.user_id,
|
|
72
|
+
anonymous_id=event.session_id,
|
|
73
|
+
event="Connector Initialized",
|
|
74
|
+
properties=event.to_dict(),
|
|
75
|
+
)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
# Never fail on tracking errors
|
|
78
|
+
logger.error(f"Telemetry error: {e}")
|
|
79
|
+
|
|
80
|
+
def track_operation(
|
|
81
|
+
self,
|
|
82
|
+
entity: str,
|
|
83
|
+
action: str,
|
|
84
|
+
status_code: Optional[int],
|
|
85
|
+
timing_ms: float,
|
|
86
|
+
error_type: Optional[str] = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Track API operation."""
|
|
89
|
+
# Always track success/failure counts (useful even when tracking is disabled)
|
|
90
|
+
if status_code and 200 <= status_code < 300:
|
|
91
|
+
self.success_count += 1
|
|
92
|
+
else:
|
|
93
|
+
self.failure_count += 1
|
|
94
|
+
|
|
95
|
+
if not self.enabled or not self._analytics:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
event = OperationEvent(
|
|
100
|
+
timestamp=datetime.utcnow(),
|
|
101
|
+
session_id=self.session.session_id,
|
|
102
|
+
user_id=self.session.user_id,
|
|
103
|
+
execution_context=self.session.execution_context,
|
|
104
|
+
public_ip=self.session.public_ip,
|
|
105
|
+
connector_name=self.session.connector_name,
|
|
106
|
+
entity=entity,
|
|
107
|
+
action=action,
|
|
108
|
+
status_code=status_code,
|
|
109
|
+
timing_ms=timing_ms,
|
|
110
|
+
error_type=error_type,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
self._analytics.track(
|
|
114
|
+
user_id=self.session.user_id,
|
|
115
|
+
anonymous_id=event.session_id,
|
|
116
|
+
event="Operation Executed",
|
|
117
|
+
properties=event.to_dict(),
|
|
118
|
+
)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.error(f"Telemetry error: {e}")
|
|
121
|
+
|
|
122
|
+
def track_session_end(self) -> None:
|
|
123
|
+
"""Track session end."""
|
|
124
|
+
if not self.enabled or not self._analytics:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
event = SessionEndEvent(
|
|
129
|
+
timestamp=datetime.utcnow(),
|
|
130
|
+
session_id=self.session.session_id,
|
|
131
|
+
user_id=self.session.user_id,
|
|
132
|
+
execution_context=self.session.execution_context,
|
|
133
|
+
public_ip=self.session.public_ip,
|
|
134
|
+
connector_name=self.session.connector_name,
|
|
135
|
+
duration_seconds=self.session.duration_seconds(),
|
|
136
|
+
operation_count=self.session.operation_count,
|
|
137
|
+
success_count=self.success_count,
|
|
138
|
+
failure_count=self.failure_count,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
self._analytics.track(
|
|
142
|
+
user_id=self.session.user_id,
|
|
143
|
+
anonymous_id=event.session_id,
|
|
144
|
+
event="Session Ended",
|
|
145
|
+
properties=event.to_dict(),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Ensure events are sent before shutdown
|
|
149
|
+
self._analytics.flush()
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.error(f"Telemetry error: {e}")
|