UncountablePythonSDK 0.0.181__py3-none-any.whl → 0.0.183__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.
@@ -1,4 +1,5 @@
1
1
  base_url: http://host.docker.internal:5000
2
+ integration_server_base_url: http://localhost:5001
2
3
  auth_retrieval:
3
4
  type: basic
4
5
  api_id_secret:
@@ -60,6 +61,13 @@ jobs:
60
61
  executor:
61
62
  type: script
62
63
  import_path: example_wh
64
+ - id: example_unc_wh
65
+ type: uncountable_webhook
66
+ name: Uncountable Webhook
67
+ purpose: trigger
68
+ executor:
69
+ type: script
70
+ import_path: example_wh
63
71
  - id: example_http
64
72
  type: custom_http
65
73
  name: Custom HTTP
@@ -2,7 +2,7 @@ from io import BytesIO
2
2
  from typing import Self
3
3
 
4
4
  from azure.core.credentials import AzureSasCredential
5
- from azure.storage.blob import BlobServiceClient, ContainerClient
5
+ from azure.storage.blob import BlobPrefix, BlobServiceClient, ContainerClient
6
6
 
7
7
  from pkgs.filesystem_utils.file_type_utils import (
8
8
  FileObjectData,
@@ -78,6 +78,24 @@ class BlobSession(FileSystemSession):
78
78
 
79
79
  return filesystem_file_references
80
80
 
81
+ def list_directories(self, dir_path: FileSystemObject) -> list[FileSystemObject]:
82
+ if not isinstance(dir_path, FileSystemFileReference):
83
+ raise IncompatibleFileReferenceError()
84
+
85
+ assert self.service_client is not None and self.container_client is not None, (
86
+ "call to list_directories on uninitialized blob session"
87
+ )
88
+
89
+ prefix = _add_slash(dir_path.filepath)
90
+ directories: list[FileSystemObject] = []
91
+ for item in self.container_client.walk_blobs(
92
+ name_starts_with=prefix, delimiter="/"
93
+ ):
94
+ if isinstance(item, BlobPrefix):
95
+ directories.append(FileSystemFileReference(item.name.rstrip("/")))
96
+
97
+ return directories
98
+
81
99
  def download_files(
82
100
  self,
83
101
  filepaths: list[FileSystemObject],
@@ -108,6 +126,22 @@ class BlobSession(FileSystemSession):
108
126
 
109
127
  return downloaded_files
110
128
 
129
+ def _move_blob(self, src_name: str, dest_name: str) -> None:
130
+ assert self.container_client is not None, (
131
+ "call to _move_blob on uninitialized blob session"
132
+ )
133
+ source_blob_client = self.container_client.get_blob_client(src_name)
134
+ dest_blob_client = self.container_client.get_blob_client(dest_name)
135
+
136
+ source_url = (
137
+ f"{source_blob_client.url}?{self.config.credential.signature}"
138
+ if isinstance(self.config.credential, AzureSasCredential)
139
+ else source_blob_client.url
140
+ )
141
+
142
+ dest_blob_client.start_copy_from_url(source_url)
143
+ source_blob_client.delete_blob()
144
+
111
145
  def move_files(self, file_mappings: list[FileTransfer]) -> None:
112
146
  assert self.service_client is not None and self.container_client is not None, (
113
147
  "call to move_files on uninitialized blob session"
@@ -119,19 +153,25 @@ class BlobSession(FileSystemSession):
119
153
  ):
120
154
  raise IncompatibleFileReferenceError()
121
155
 
122
- source_blob_client = self.container_client.get_blob_client(
123
- src_file.filepath
124
- )
125
- dest_blob_client = self.container_client.get_blob_client(dest_file.filepath)
156
+ self._move_blob(src_file.filepath, dest_file.filepath)
126
157
 
127
- source_url = (
128
- f"{source_blob_client.url}?{self.config.credential.signature}"
129
- if isinstance(self.config.credential, AzureSasCredential)
130
- else source_blob_client.url
131
- )
158
+ def move_directory(
159
+ self, src_dir: FileSystemObject, dest_dir: FileSystemObject
160
+ ) -> None:
161
+ if not isinstance(src_dir, FileSystemFileReference) or not isinstance(
162
+ dest_dir, FileSystemFileReference
163
+ ):
164
+ raise IncompatibleFileReferenceError()
165
+
166
+ assert self.service_client is not None and self.container_client is not None, (
167
+ "call to move_directory on uninitialized blob session"
168
+ )
132
169
 
133
- dest_blob_client.start_copy_from_url(source_url)
134
- source_blob_client.delete_blob()
170
+ src_prefix = _add_slash(src_dir.filepath)
171
+ dest_prefix = _add_slash(dest_dir.filepath)
172
+ for blob in self.container_client.list_blobs(name_starts_with=src_prefix):
173
+ dest_name = dest_prefix + blob.name[len(src_prefix) :]
174
+ self._move_blob(blob.name, dest_name)
135
175
 
136
176
  def delete_files(self, filepaths: list[FileSystemObject]) -> None:
137
177
  assert self.service_client is not None and self.container_client is not None, (
@@ -79,6 +79,49 @@ class FileShareSession(FileSystemSession):
79
79
  dir_client, recursive=recursive, valid_extensions=valid_extensions
80
80
  )
81
81
 
82
+ def list_directories(self, dir_path: FileSystemObject) -> list[FileSystemObject]:
83
+ if not isinstance(dir_path, FileSystemFileReference):
84
+ raise IncompatibleFileReferenceError()
85
+ assert self._share_client is not None, (
86
+ "call to list_directories on uninitialized file share session"
87
+ )
88
+ dir_client = self._share_client.get_directory_client(dir_path.filepath)
89
+ directories: list[FileSystemObject] = []
90
+ for item in dir_client.list_directories_and_files():
91
+ if item["is_directory"]:
92
+ item_path = f"{dir_client.directory_path}/{item['name']}".lstrip("/")
93
+ directories.append(FileSystemFileReference(filepath=item_path))
94
+ return directories
95
+
96
+ def create_directory(self, dir_path: FileSystemObject) -> None:
97
+ if not isinstance(dir_path, FileSystemFileReference):
98
+ raise IncompatibleFileReferenceError()
99
+ assert self._share_client is not None, (
100
+ "call to create_directory on uninitialized file share session"
101
+ )
102
+ current = ""
103
+ for segment in dir_path.filepath.strip("/").split("/"):
104
+ if not segment:
105
+ continue
106
+ current = f"{current}/{segment}".lstrip("/")
107
+ dir_client = self._share_client.get_directory_client(current)
108
+ if not dir_client.exists():
109
+ dir_client.create_directory()
110
+
111
+ def move_directory(
112
+ self, src_dir: FileSystemObject, dest_dir: FileSystemObject
113
+ ) -> None:
114
+ if not isinstance(src_dir, FileSystemFileReference) or not isinstance(
115
+ dest_dir, FileSystemFileReference
116
+ ):
117
+ raise IncompatibleFileReferenceError()
118
+ assert self._share_client is not None, (
119
+ "call to move_directory on uninitialized file share session"
120
+ )
121
+ self._share_client.get_directory_client(src_dir.filepath).rename_directory(
122
+ dest_dir.filepath
123
+ )
124
+
82
125
  def download_files(self, filepaths: list[FileSystemObject]) -> list[FileObjectData]:
83
126
  assert self._share_client is not None, (
84
127
  "call to download_files on uninitialized file share session"
@@ -86,6 +86,28 @@ class S3Session(FileSystemSession):
86
86
 
87
87
  return filesystem_references
88
88
 
89
+ def list_directories(self, dir_path: FileSystemObject) -> list[FileSystemObject]:
90
+ if not isinstance(dir_path, FileSystemFileReference):
91
+ raise IncompatibleFileReferenceError()
92
+
93
+ assert self.bucket is not None, (
94
+ "call to list_directories on uninitialized s3 session"
95
+ )
96
+
97
+ prefix = _add_slash(dir_path.filepath)
98
+ paginator = self.bucket.meta.client.get_paginator("list_objects_v2")
99
+
100
+ directories: list[FileSystemObject] = []
101
+ for page in paginator.paginate(
102
+ Bucket=self.bucket.name, Prefix=prefix, Delimiter="/"
103
+ ):
104
+ for common_prefix in page.get("CommonPrefixes", []):
105
+ directories.append(
106
+ FileSystemFileReference(common_prefix["Prefix"].rstrip("/"))
107
+ )
108
+
109
+ return directories
110
+
89
111
  def download_files(
90
112
  self,
91
113
  filepaths: list[FileSystemObject],
@@ -115,6 +137,18 @@ class S3Session(FileSystemSession):
115
137
 
116
138
  return downloaded_files
117
139
 
140
+ def _move_object(self, src_key: str, dest_key: str) -> None:
141
+ assert self.bucket is not None, (
142
+ "call to _move_object on uninitialized s3 session"
143
+ )
144
+ self.bucket.Object(dest_key).copy_from(
145
+ CopySource={
146
+ "Bucket": self.bucket.name,
147
+ "Key": src_key,
148
+ }
149
+ )
150
+ self.bucket.Object(src_key).delete()
151
+
118
152
  def move_files(self, file_mappings: list[FileTransfer]) -> None:
119
153
  assert self.bucket is not None, "call to move_files on uninitialized s3 session"
120
154
 
@@ -123,10 +157,22 @@ class S3Session(FileSystemSession):
123
157
  dest_file, FileSystemFileReference
124
158
  ):
125
159
  raise IncompatibleFileReferenceError()
126
- self.bucket.Object(dest_file.filepath).copy_from(
127
- CopySource={
128
- "Bucket": self.bucket.name,
129
- "Key": src_file.filepath,
130
- }
131
- )
132
- self.bucket.Object(src_file.filepath).delete()
160
+ self._move_object(src_file.filepath, dest_file.filepath)
161
+
162
+ def move_directory(
163
+ self, src_dir: FileSystemObject, dest_dir: FileSystemObject
164
+ ) -> None:
165
+ if not isinstance(src_dir, FileSystemFileReference) or not isinstance(
166
+ dest_dir, FileSystemFileReference
167
+ ):
168
+ raise IncompatibleFileReferenceError()
169
+
170
+ assert self.bucket is not None, (
171
+ "call to move_directory on uninitialized s3 session"
172
+ )
173
+
174
+ src_prefix = _add_slash(src_dir.filepath)
175
+ dest_prefix = _add_slash(dest_dir.filepath)
176
+ for obj in self.bucket.objects.filter(Prefix=src_prefix):
177
+ dest_key = dest_prefix + obj.key[len(src_prefix) :]
178
+ self._move_object(obj.key, dest_key)
@@ -32,6 +32,17 @@ class FileSystemSession(ABC):
32
32
  def delete_files(self, filepaths: list[FileSystemObject]) -> None:
33
33
  raise NotImplementedError
34
34
 
35
+ def list_directories(self, dir_path: FileSystemObject) -> list[FileSystemObject]:
36
+ raise NotImplementedError
37
+
38
+ def create_directory(self, dir_path: FileSystemObject) -> None:
39
+ raise NotImplementedError
40
+
41
+ def move_directory(
42
+ self, src_dir: FileSystemObject, dest_dir: FileSystemObject
43
+ ) -> None:
44
+ raise NotImplementedError
45
+
35
46
  @abstractmethod
36
47
  def __enter__(self) -> "FileSystemSession": ...
37
48
 
@@ -26,10 +26,6 @@ def get_local_admin_server_port() -> int:
26
26
  return int(os.environ.get("UNC_ADMIN_SERVER_PORT", "50051"))
27
27
 
28
28
 
29
- def get_integration_admin_token() -> str | None:
30
- return os.environ.get("UNC_INTEGRATION_ADMIN_TOKEN")
31
-
32
-
33
29
  def get_otel_enabled() -> bool:
34
30
  return os.environ.get("UNC_OTEL_ENABLED") == "true"
35
31
 
@@ -1,11 +1,14 @@
1
1
  from uncountable.integration.db.connect import IntegrationDBService, create_db_engine
2
2
  from uncountable.integration.scan_profiles import load_profiles
3
3
  from uncountable.integration.server import IntegrationServer
4
+ from uncountable.integration.webhook_bootstrap import start_webhook_entity_bootstrap
4
5
 
5
6
 
6
7
  def main() -> None:
8
+ profiles = load_profiles()
7
9
  with IntegrationServer(create_db_engine(IntegrationDBService.CRON)) as server:
8
- server.register_jobs(load_profiles())
10
+ start_webhook_entity_bootstrap(profiles)
11
+ server.register_jobs(profiles)
9
12
  server.serve_forever()
10
13
 
11
14
 
@@ -28,6 +28,10 @@ class HttpException(Exception):
28
28
  def body_parse_error() -> "HttpException":
29
29
  return HttpException(error_code=400, message="body parse error")
30
30
 
31
+ @staticmethod
32
+ def bad_request(message: str = "bad request") -> "HttpException":
33
+ return HttpException(error_code=400, message=message)
34
+
31
35
  @staticmethod
32
36
  def unauthorized() -> "HttpException":
33
37
  return HttpException(error_code=401, message="unauthorized")
@@ -27,7 +27,7 @@ from uncountable.integration.queue_runner.command_server.types import (
27
27
  from uncountable.integration.request_context import extract_request_context
28
28
  from uncountable.integration.secret_retrieval import retrieve_secret
29
29
  from uncountable.integration.telemetry import JobLogger
30
- from uncountable.integration.webhook_signature_key import (
30
+ from uncountable.integration.webhook_entity import (
31
31
  webhook_signature_key_retrieval,
32
32
  )
33
33
  from uncountable.types import (
@@ -3,9 +3,7 @@ from opentelemetry.trace import get_current_span
3
3
  from uncountable.integration.construct_client import construct_uncountable_client
4
4
  from uncountable.integration.scan_profiles import load_profiles
5
5
  from uncountable.integration.telemetry import Logger
6
- from uncountable.integration.webhook_signature_key import (
7
- bootstrap_webhook_signature_key,
8
- )
6
+ from uncountable.integration.webhook_entity import create_missing_webhook_entities
9
7
  from uncountable.types import job_definition_t
10
8
 
11
9
 
@@ -17,12 +15,10 @@ def provision_webhook(*, job_id: str) -> str | None:
17
15
  isinstance(job, job_definition_t.UncountableWebhookJobDefinition)
18
16
  and job.id == job_id
19
17
  ):
20
- client = construct_uncountable_client(profile_metadata, logger)
21
- bootstrap_webhook_signature_key(
22
- client=client,
18
+ create_missing_webhook_entities(
19
+ client=construct_uncountable_client(profile_metadata, logger),
23
20
  profile_metadata=profile_metadata,
24
- job_id=job.id,
25
- purpose=job.purpose,
21
+ jobs=[job],
26
22
  )
27
23
  return profile_metadata.name
28
24
  return None
@@ -0,0 +1,67 @@
1
+ import threading
2
+
3
+ from opentelemetry.trace import get_current_span
4
+
5
+ from uncountable.integration.construct_client import construct_uncountable_client
6
+ from uncountable.integration.telemetry import Logger
7
+ from uncountable.integration.webhook_entity import create_missing_webhook_entities
8
+ from uncountable.types import job_definition_t
9
+
10
+ _BOOTSTRAP_SCOPE_NAME = "bootstrap_webhook_entities"
11
+ _BOOTSTRAP_THREAD_NAME = "webhook-entity-bootstrap"
12
+
13
+
14
+ def _bootstrap_profile_webhook_entities(
15
+ *,
16
+ profile_metadata: job_definition_t.ProfileMetadata,
17
+ jobs: list[job_definition_t.UncountableWebhookJobDefinition],
18
+ logger: Logger,
19
+ ) -> None:
20
+ created_job_ids = create_missing_webhook_entities(
21
+ client=construct_uncountable_client(profile_metadata, logger),
22
+ profile_metadata=profile_metadata,
23
+ jobs=jobs,
24
+ )
25
+ logger.log_debug(
26
+ f"bootstrapped {len(created_job_ids)} webhook entities for profile {profile_metadata.name}",
27
+ attributes={
28
+ "profile.name": profile_metadata.name,
29
+ "webhook.created_job_ids": created_job_ids,
30
+ },
31
+ )
32
+
33
+
34
+ def _bootstrap_webhook_entities(
35
+ profiles: list[job_definition_t.ProfileMetadata],
36
+ ) -> None:
37
+ logger = Logger(get_current_span())
38
+ with logger.push_scope(_BOOTSTRAP_SCOPE_NAME):
39
+ for profile_metadata in profiles:
40
+ webhook_jobs = [
41
+ job
42
+ for job in profile_metadata.jobs
43
+ if isinstance(job, job_definition_t.UncountableWebhookJobDefinition)
44
+ ]
45
+ if len(webhook_jobs) == 0:
46
+ continue
47
+ try:
48
+ _bootstrap_profile_webhook_entities(
49
+ profile_metadata=profile_metadata, jobs=webhook_jobs, logger=logger
50
+ )
51
+ except Exception as e:
52
+ logger.log_exception(
53
+ e,
54
+ message=f"failed to bootstrap webhook entities for profile {profile_metadata.name}",
55
+ attributes={"profile.name": profile_metadata.name},
56
+ )
57
+
58
+
59
+ def start_webhook_entity_bootstrap(
60
+ profiles: list[job_definition_t.ProfileMetadata],
61
+ ) -> None:
62
+ threading.Thread(
63
+ target=_bootstrap_webhook_entities,
64
+ args=(profiles,),
65
+ name=_BOOTSTRAP_THREAD_NAME,
66
+ daemon=True,
67
+ ).start()
@@ -0,0 +1,147 @@
1
+ import secrets
2
+ from collections.abc import Sequence
3
+
4
+ from uncountable.core.client import Client
5
+ from uncountable.core.query.builder import QueryBuilder
6
+ from uncountable.core.query.column import (
7
+ NullableColumn,
8
+ TextColumn,
9
+ )
10
+ from uncountable.core.query.row import QueryRow
11
+ from uncountable.types import (
12
+ create_or_update_entities_t,
13
+ entity_t,
14
+ field_values_t,
15
+ identifier_t,
16
+ listing_t,
17
+ )
18
+ from uncountable.types.job_definition_t import (
19
+ ProfileMetadata,
20
+ UncountableWebhookJobDefinition,
21
+ )
22
+ from uncountable.types.secret_retrieval_t import SecretRetrievalEntityColumn
23
+
24
+ _WEBHOOK_DEFINITION_REF_NAME = "unc_webhook_definition"
25
+ _WEBHOOK_REF_NAME_FIELD_REF_NAME = "refName"
26
+ _WEBHOOK_NAME_FIELD_REF_NAME = "name"
27
+ _WEBHOOK_URL_FIELD_REF_NAME = "core_webhook_url"
28
+ _WEBHOOK_SIGNATURE_KEY_FIELD_REF_NAME = "core_webhook_signatureKey"
29
+ _WEBHOOK_PURPOSE_FIELD_REF_NAME = "core_webhook_purpose"
30
+ _SIGNATURE_KEY_NUM_BYTES = 32
31
+
32
+
33
+ class _WebhookEntityQueryRow(QueryRow):
34
+ ref_name = TextColumn(
35
+ identifier=listing_t.ColumnIdentifierEntityRefName(
36
+ entity_type=entity_t.EntityType.WEBHOOK,
37
+ ref_name=_WEBHOOK_REF_NAME_FIELD_REF_NAME,
38
+ ),
39
+ nullable=NullableColumn,
40
+ )
41
+
42
+
43
+ def webhook_signature_key_retrieval(*, job_id: str) -> SecretRetrievalEntityColumn:
44
+ return SecretRetrievalEntityColumn(
45
+ entity_key=identifier_t.IdentifierKeyRefName(ref_name=job_id),
46
+ column_identifier=listing_t.ColumnIdentifierEntityRefName(
47
+ entity_type=entity_t.EntityType.WEBHOOK,
48
+ ref_name=_WEBHOOK_SIGNATURE_KEY_FIELD_REF_NAME,
49
+ ),
50
+ )
51
+
52
+
53
+ def _resolve_webhook_url(*, profile_metadata: ProfileMetadata, job_id: str) -> str:
54
+ integration_server_base_url = profile_metadata.integration_server_base_url
55
+ if integration_server_base_url is None:
56
+ raise ValueError(
57
+ "integration_server_base_url is required to resolve the webhook entity "
58
+ f"for {profile_metadata.name}/{job_id}"
59
+ )
60
+ return f"{integration_server_base_url.rstrip('/')}/{profile_metadata.name}/{job_id}"
61
+
62
+
63
+ def _fetch_existing_webhook_ref_names(
64
+ *, client: Client, job_ids: Sequence[str]
65
+ ) -> set[str]:
66
+ existing_webhooks = (
67
+ QueryBuilder(client=client, model=_WebhookEntityQueryRow)
68
+ .filter(_WebhookEntityQueryRow.ref_name.includes(tuple(job_ids)))
69
+ .all()
70
+ )
71
+ return {
72
+ webhook.ref_name
73
+ for webhook in existing_webhooks
74
+ if webhook.ref_name is not None
75
+ }
76
+
77
+
78
+ def _text_field_value(
79
+ *, field_ref_name: str, value: str
80
+ ) -> field_values_t.FieldArgumentValue:
81
+ return field_values_t.FieldArgumentValue(
82
+ field_key=identifier_t.IdentifierKeyRefName(ref_name=field_ref_name),
83
+ value=field_values_t.FieldValueText(value=value),
84
+ )
85
+
86
+
87
+ def _build_webhook_operation(
88
+ *, profile_metadata: ProfileMetadata, job: UncountableWebhookJobDefinition
89
+ ) -> create_or_update_entities_t.EntityToCreateOrUpdate:
90
+ # entity_key is deliberately omitted so the operation always takes the create
91
+ # path. Supplying a ref_name key that resolves to nothing raises not-found
92
+ # rather than creating the entity, and callers only pass jobs already known
93
+ # to have no webhook entity.
94
+ return create_or_update_entities_t.EntityToCreateOrUpdate(
95
+ field_values=[],
96
+ on_create_init_field_values=[
97
+ _text_field_value(
98
+ field_ref_name=_WEBHOOK_REF_NAME_FIELD_REF_NAME, value=job.id
99
+ ),
100
+ _text_field_value(
101
+ field_ref_name=_WEBHOOK_NAME_FIELD_REF_NAME,
102
+ value=f"{profile_metadata.name}/{job.id}",
103
+ ),
104
+ _text_field_value(
105
+ field_ref_name=_WEBHOOK_URL_FIELD_REF_NAME,
106
+ value=_resolve_webhook_url(
107
+ profile_metadata=profile_metadata, job_id=job.id
108
+ ),
109
+ ),
110
+ _text_field_value(
111
+ field_ref_name=_WEBHOOK_SIGNATURE_KEY_FIELD_REF_NAME,
112
+ value=secrets.token_urlsafe(_SIGNATURE_KEY_NUM_BYTES),
113
+ ),
114
+ field_values_t.FieldArgumentValue(
115
+ field_key=identifier_t.IdentifierKeyRefName(
116
+ ref_name=_WEBHOOK_PURPOSE_FIELD_REF_NAME
117
+ ),
118
+ value=field_values_t.FieldValueFieldOption(value=job.purpose.value),
119
+ ),
120
+ ],
121
+ )
122
+
123
+
124
+ def create_missing_webhook_entities(
125
+ *,
126
+ client: Client,
127
+ profile_metadata: ProfileMetadata,
128
+ jobs: Sequence[UncountableWebhookJobDefinition],
129
+ ) -> list[str]:
130
+ existing_ref_names = _fetch_existing_webhook_ref_names(
131
+ client=client, job_ids=[job.id for job in jobs]
132
+ )
133
+ missing_jobs = [job for job in jobs if job.id not in existing_ref_names]
134
+ if len(missing_jobs) == 0:
135
+ return []
136
+
137
+ client.create_or_update_entities(
138
+ entity_type=entity_t.EntityType.WEBHOOK,
139
+ definition_key=identifier_t.IdentifierKeyRefName(
140
+ ref_name=_WEBHOOK_DEFINITION_REF_NAME
141
+ ),
142
+ operations=[
143
+ _build_webhook_operation(profile_metadata=profile_metadata, job=job)
144
+ for job in missing_jobs
145
+ ],
146
+ )
147
+ return [job.id for job in missing_jobs]
@@ -12,7 +12,6 @@ from uncountable.integration.http_server import GenericHttpRequest, HttpExceptio
12
12
  from uncountable.integration.job import CustomHttpJob, WebhookJob
13
13
  from uncountable.integration.scan_profiles import load_profiles
14
14
  from uncountable.integration.telemetry import Logger
15
- from uncountable.integration.webhook_server.admin_routes import register_admin_routes
16
15
  from uncountable.types import job_definition_t
17
16
 
18
17
  app = flask.Flask(__name__)
@@ -99,7 +98,6 @@ def register_route(
99
98
 
100
99
  def main() -> None:
101
100
  app.add_url_rule("/health", "health", lambda: ("OK", 200))
102
- register_admin_routes(app)
103
101
 
104
102
  profiles = load_profiles()
105
103
  for profile_metadata in profiles:
@@ -21,7 +21,7 @@ __all__: list[str] = [
21
21
 
22
22
  ENDPOINT_METHOD = "POST"
23
23
  ENDPOINT_PATH = "api/external/recipes/external_set_recipe_inputs"
24
- ENDPOINT_DESCRIPTION = "Sets input values for experiments. Supports numeric, text, and categorical types. Validates that the recipe step belongs to the correct recipe. Supports setting actual values, associating lots (creating them if needed), and removing inputs. Writes all values to the first workflow step when no step is specified. Fails if both a numeric and string value are provided."
24
+ ENDPOINT_DESCRIPTION = "Sets input values for experiments. Supports numeric, text, and categorical types. Validates that the recipe step belongs to the correct recipe. Each entry edits one input on one recipe with a single operation: set the value (the actual value when set_actual_value is true, otherwise the set value), associate a lot (lot_recipe_id, created if the recipe is not already a lot), or remove the input (remove). A lot association targets the set value; combining it with set_actual_value fails. Writes all values to the first workflow step when no step is specified. Fails if both a numeric and string value are provided."
25
25
 
26
26
 
27
27
  # DO NOT MODIFY -- This file is generated by type_spec
@@ -2177,7 +2177,7 @@ class ClientMethods(ABC):
2177
2177
  input_data: list[set_recipe_inputs_t.RecipeInputValue],
2178
2178
  _request_options: client_config_t.RequestOptions | None = None,
2179
2179
  ) -> set_recipe_inputs_t.Data:
2180
- """Sets input values for experiments. Supports numeric, text, and categorical types. Validates that the recipe step belongs to the correct recipe. Supports setting actual values, associating lots (creating them if needed), and removing inputs. Writes all values to the first workflow step when no step is specified. Fails if both a numeric and string value are provided.
2180
+ """Sets input values for experiments. Supports numeric, text, and categorical types. Validates that the recipe step belongs to the correct recipe. Each entry edits one input on one recipe with a single operation: set the value (the actual value when set_actual_value is true, otherwise the set value), associate a lot (lot_recipe_id, created if the recipe is not already a lot), or remove the input (remove). A lot association targets the set value; combining it with set_actual_value fails. Writes all values to the first workflow step when no step is specified. Fails if both a numeric and string value are provided.
2181
2181
 
2182
2182
  :param input_data: The inputs to set. Must be at most 100 entries long
2183
2183
  """
@@ -9,6 +9,7 @@ from .generic_upload_t import UploadDestinationBase as UploadDestinationBase
9
9
  from .generic_upload_t import UploadDestinationProject as UploadDestinationProject
10
10
  from .generic_upload_t import UploadDestinationMaterialFamily as UploadDestinationMaterialFamily
11
11
  from .generic_upload_t import UploadDestinationRecipe as UploadDestinationRecipe
12
+ from .generic_upload_t import UploadDestinationAutoMatch as UploadDestinationAutoMatch
12
13
  from .generic_upload_t import UploadDestination as UploadDestination
13
14
  from .generic_upload_t import GenericUploadStrategy as GenericUploadStrategy
14
15
  # DO NOT MODIFY -- This file is generated by type_spec
@@ -16,6 +16,7 @@ __all__: list[str] = [
16
16
  "GenericRemoteDirectoryScope",
17
17
  "GenericUploadStrategy",
18
18
  "UploadDestination",
19
+ "UploadDestinationAutoMatch",
19
20
  "UploadDestinationBase",
20
21
  "UploadDestinationMaterialFamily",
21
22
  "UploadDestinationProject",
@@ -47,6 +48,7 @@ class UploadDestinationType(StrEnum):
47
48
  PROJECT = "project"
48
49
  MATERIAL_FAMILY = "material_family"
49
50
  RECIPE = "recipe"
51
+ AUTO_MATCH = "auto_match"
50
52
 
51
53
 
52
54
  # DO NOT MODIFY -- This file is generated by type_spec
@@ -91,9 +93,19 @@ class UploadDestinationRecipe(UploadDestinationBase):
91
93
  recipe_key: identifier_t.IdentifierKey
92
94
 
93
95
 
96
+ # DO NOT MODIFY -- This file is generated by type_spec
97
+ @serial_class(
98
+ named_type_path="sdk.generic_upload.UploadDestinationAutoMatch",
99
+ parse_require={"type"},
100
+ )
101
+ @dataclasses.dataclass(slots=True, kw_only=True)
102
+ class UploadDestinationAutoMatch(UploadDestinationBase):
103
+ type: typing.Literal[UploadDestinationType.AUTO_MATCH] = UploadDestinationType.AUTO_MATCH
104
+
105
+
94
106
  # DO NOT MODIFY -- This file is generated by type_spec
95
107
  UploadDestination = typing.Annotated[
96
- UploadDestinationProject | UploadDestinationMaterialFamily | UploadDestinationRecipe,
108
+ UploadDestinationProject | UploadDestinationMaterialFamily | UploadDestinationRecipe | UploadDestinationAutoMatch,
97
109
  serial_union_annotation(
98
110
  named_type_path="sdk.generic_upload.UploadDestination",
99
111
  discriminator="type",
@@ -101,6 +113,7 @@ UploadDestination = typing.Annotated[
101
113
  "project": UploadDestinationProject,
102
114
  "material_family": UploadDestinationMaterialFamily,
103
115
  "recipe": UploadDestinationRecipe,
116
+ "auto_match": UploadDestinationAutoMatch,
104
117
  },
105
118
  ),
106
119
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: UncountablePythonSDK
3
- Version: 0.0.181
3
+ Version: 0.0.183
4
4
  Summary: Uncountable SDK
5
5
  Project-URL: Homepage, https://github.com/uncountableinc/uncountable-python-sdk
6
6
  Project-URL: Repository, https://github.com/uncountableinc/uncountable-python-sdk.git
@@ -42,7 +42,7 @@ examples/integration-server/jobs/materials_auto/example_parse.py,sha256=Z3iqAUdI
42
42
  examples/integration-server/jobs/materials_auto/example_predictions.py,sha256=5fO4rqRa80_968A1uVZn2TlMOUib54A8rumGW02sIMM,2112
43
43
  examples/integration-server/jobs/materials_auto/example_runsheet_wh.py,sha256=7FWDz3QpueVcf83fPrwGSqxs9pX06iKcQFte_vz3dC8,2944
44
44
  examples/integration-server/jobs/materials_auto/example_wh.py,sha256=PN-skP27yJwDZboWk5g5EZEc3AKfVayQLfnopjsDKJc,659
45
- examples/integration-server/jobs/materials_auto/profile.yaml,sha256=ywDrDRAyqiUdj_HvosNP5bXBL8mCWsvdJ1eYQd-mGYo,2369
45
+ examples/integration-server/jobs/materials_auto/profile.yaml,sha256=N9kX-xczfgBSqnfY5qsxmjxXkBXLhMnZU1TOMWI1hfg,2587
46
46
  pkgs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
47
  pkgs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
48
48
  pkgs/argument_parser/__init__.py,sha256=cEEOAQS8UJ4Kbuo0oCVAHHhkbYCfrH6cUTTt2Mq3PmI,1445
@@ -57,15 +57,15 @@ pkgs/argument_parser/parser_inner.py,sha256=0zepgTqQm6yFp9WMlHJmWV3S9WsxzRiN_nBw
57
57
  pkgs/argument_parser/parser_options.py,sha256=NAwNZSIH8EjyfJXJSxbn4kYmF3K7D3HuOedlSjjsF28,1332
58
58
  pkgs/argument_parser/type_predicates.py,sha256=CkQexiseN05XDz4e34K3gIOlER5LG5Ke8u0UHK4gC04,1157
59
59
  pkgs/filesystem_utils/__init__.py,sha256=b8NblpFX4rwyRSY3d8GGJRk9x3ET5jpsxDz0M2VnGlw,2071
60
- pkgs/filesystem_utils/_blob_session.py,sha256=2xcelJlME7bCNmBiTMnwycGDkwo0UtYfbGQEkCj0Ndo,5183
61
- pkgs/filesystem_utils/_file_share_session.py,sha256=27QmvifiwMYQ25eRVKWdr3VZDdZmBtYXTF6lhPUcld4,5155
60
+ pkgs/filesystem_utils/_blob_session.py,sha256=pdRWflJAQux06ZZZLjbH1kic4djG8YBTiAN3fZ8nSVA,6870
61
+ pkgs/filesystem_utils/_file_share_session.py,sha256=GyC9w762CbnvpC0ooZumZnzqrIULYOkHN7UIz3I7jXc,7131
62
62
  pkgs/filesystem_utils/_gdrive_session.py,sha256=pxZNTR5t8gJFS-HwMCZiQ-ZwCJV_ij5PWiBf1BUy9DE,11122
63
63
  pkgs/filesystem_utils/_local_session.py,sha256=FXxLn7CqmYZZib3mCGg0BuItIGwlvOIsKEJcZHfJ_iw,2359
64
- pkgs/filesystem_utils/_s3_session.py,sha256=jmc3UdOog_4cpnRvoqJgkzSAgBGYjmoMN92GIgUX-zw,4523
64
+ pkgs/filesystem_utils/_s3_session.py,sha256=0MS1RXPA7wnoLKG8eDV_ONFS4f_rhA5Ey3yn65-JZ_g,6250
65
65
  pkgs/filesystem_utils/_sftp_connection.py,sha256=XeRNdfdXPjpRYiirp7GGsmQOQ4W4WMZihuDKFqy9nRE,4854
66
66
  pkgs/filesystem_utils/_sftp_session.py,sha256=62E2OWHWvtAcKjxq2g5L4cCWiXiOFwRoyQX9Hr6d3RU,4927
67
67
  pkgs/filesystem_utils/file_type_utils.py,sha256=Hf-NP1E8lKn9QAx9LH2a6Fc5jSXfmE8l0CMCtqvjyFA,2140
68
- pkgs/filesystem_utils/filesystem_session.py,sha256=BQ2Go8Mu9-GcnaWh2Pm4x7ugLVsres6XrOQ8RoiEpcE,1045
68
+ pkgs/filesystem_utils/filesystem_session.py,sha256=zOddRkBZC5tsTiwquySeKTkKM5yNwW3Q_jDq9U0XcuI,1411
69
69
  pkgs/serialization/__init__.py,sha256=HWzdZ5CtMnZ-svy08eiDn6K9P_IveCjkjQ3whxPoCs4,1093
70
70
  pkgs/serialization/annotation.py,sha256=JXP2caXNoj8h_Pvx_26iarPlsaPcIqe1qssTksJiKjM,3408
71
71
  pkgs/serialization/missing_sentry.py,sha256=VCXiYs7s5MkfG8kbmwDAswdq0fL4zFRj4B2uSQzmTos,819
@@ -122,7 +122,7 @@ uncountable/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
122
122
  uncountable/core/__init__.py,sha256=RFv0kO6rKFf1PtBPu83hCGmxqkJamRtsgQ9_-ztw7tA,341
123
123
  uncountable/core/async_batch.py,sha256=9pYGFzVCQXt8059qFHgutweGIFPquJ5Xfq6NT5P-1K0,1206
124
124
  uncountable/core/client.py,sha256=53Voo2Y-q9iipwwJoHKb-idW41JUOeV8QeWnaLGFf48,16715
125
- uncountable/core/environment.py,sha256=OsUC5URj4VkYFbmZ89MF4gD3vMtIrFKKHSxljXi4oeI,1240
125
+ uncountable/core/environment.py,sha256=Z9vu7JtnSDgQB_KKcZnjTFNyARXjRr_PDW9krwxNNAo,1132
126
126
  uncountable/core/file_upload.py,sha256=BOixJ09uWjZBTrNAlJqNvJEFvaNS2dt7eFq2rG6xP5g,5987
127
127
  uncountable/core/types.py,sha256=s2CjqYJpsmbC7xMwxxT7kJ_V9bwokrjjWVVjpMcQpKI,333
128
128
  uncountable/core/query/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -134,14 +134,15 @@ uncountable/integration/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG
134
134
  uncountable/integration/cli.py,sha256=5RzlfScaZHapiXp4XoaYh0QqSenIjkEf-kyjEue1ycg,9301
135
135
  uncountable/integration/construct_client.py,sha256=9c8ZqMjyXebsptiGkJbg53_af4TLOumY1hOQtIbC15w,2229
136
136
  uncountable/integration/cron.py,sha256=6eH-kIs3sdYPCyb62_L2M7U_uQTdMTdwY5hreEJb0hw,887
137
- uncountable/integration/entrypoint.py,sha256=BHOYPQgKvZE6HG8Rv15MkdYl8lRkvfDgv1OdLo0oQ9Q,433
138
- uncountable/integration/job.py,sha256=eKaRiVagBR0qKUp96jR2NZdxOGKWQbJpBrkcRWoJcXw,9194
137
+ uncountable/integration/entrypoint.py,sha256=seXMXxUPjBLzpP6UAB6fRVodkgGea6vnl2oy4FWvCyE,591
138
+ uncountable/integration/job.py,sha256=Jeiiwvk-T3TxNYpesGbyTuQirw8gX1qQlmCIZ7r7Wuc,9187
139
139
  uncountable/integration/request_context.py,sha256=N_FJJxqvfUJ0yV9h3I3vFTGNJiDfyLYObczcYa44pw8,999
140
140
  uncountable/integration/scan_profiles.py,sha256=86CQgrQ-zXl3p41ERazzbzhTIIjIh_BprWVfYTSHHE4,3259
141
141
  uncountable/integration/scheduler.py,sha256=Z_CL2a-kOAJFpMWzFKtuvRz4AlCIRYbxWdN8fpM0RSQ,9150
142
142
  uncountable/integration/server.py,sha256=wnqpowr3xdAXfi4gCTdfFBvmQVyTro439kqrXewBauM,6002
143
143
  uncountable/integration/telemetry.py,sha256=W7z2h47Rrn7F3gF1aazbBdthNbSVi4dkQVbZFIs99-c,16155
144
- uncountable/integration/webhook_signature_key.py,sha256=vXlNtj0qLqZxjKDLmRvlChvLS5xoPAGmYcYcs4WIVaM,3882
144
+ uncountable/integration/webhook_bootstrap.py,sha256=RlMsdfoWhsOX8eQCVz9XhyQLqfr0hPPwW6pdGhhmfeI,2332
145
+ uncountable/integration/webhook_entity.py,sha256=As4x_W_zXuJ4uCAoVZwUKHPJILxP12ow0psPEv5sv9M,5220
145
146
  uncountable/integration/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
146
147
  uncountable/integration/db/connect.py,sha256=-xYWcJYxwKJMBvdQJ5J0-4vwBU-m24uTBtE-VrjwgKw,1479
147
148
  uncountable/integration/db/session.py,sha256=z9kor6wy8fdHhHSmdBj9HVvDOQWvs1QM_0kisD1A06E,702
@@ -150,7 +151,7 @@ uncountable/integration/executors/executors.py,sha256=E3a0_yrQesfigDzjhUAwD7-l-o
150
151
  uncountable/integration/executors/generic_upload_executor.py,sha256=MuNzrMnAaj6PBhrfTKX6e6vyQhwoDeBrYLz96ai653k,12149
151
152
  uncountable/integration/executors/script_executor.py,sha256=hKq09fOG89OAZ1Iq7wm2OgpUz1bh7DIYdZ2yn6ySr10,881
152
153
  uncountable/integration/http_server/__init__.py,sha256=ld7hD3wOGwAbnVRBrkTUCP5zNG-kptMd4wGJyQvUWVc,170
153
- uncountable/integration/http_server/types.py,sha256=eCdYZdEWjdFj9JDTQvhLF3W83HkdRowgX3X_axbA6-0,2274
154
+ uncountable/integration/http_server/types.py,sha256=9Hnt_rvrhGh0MCj1O2k2KHF81U85jURUnG6vt0boPDk,2425
154
155
  uncountable/integration/queue_runner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
155
156
  uncountable/integration/queue_runner/job_process.py,sha256=OONJ7aLsE80qIJ2ABLY3HYyKI7VdpBjI_pPQ3GCC13U,2586
156
157
  uncountable/integration/queue_runner/job_runner.py,sha256=Yh9hYfEYOya3BR6ypsal3LKPKrrEb4VQwfGuh4qyU2w,6115
@@ -162,7 +163,7 @@ uncountable/integration/queue_runner/command_server/__init__.py,sha256=hMCDLWct8
162
163
  uncountable/integration/queue_runner/command_server/command_client.py,sha256=hvioJc3XcJ4HsJmEwOFpQ2VZ56IqXHldZdR5yvWo39U,6361
163
164
  uncountable/integration/queue_runner/command_server/command_server.py,sha256=IsM_4YfuRqr8Ev2tbk6TpfdkBWh9mOTu-8dPUijfVQ8,8581
164
165
  uncountable/integration/queue_runner/command_server/constants.py,sha256=7J9mQIAMOfV50wnwpn7HgrPFEi3Ritj6HwrGYwxGLoU,88
165
- uncountable/integration/queue_runner/command_server/provision_webhook.py,sha256=yPYEqCi9lDFYOeGBJgwcLS3IdfYGcaZFEYo6_8sFiS8,1102
166
+ uncountable/integration/queue_runner/command_server/provision_webhook.py,sha256=TqH_6YjRZz8EHMKxOcCxvmK6vweR8nuY8ilWNEQoogk,1010
166
167
  uncountable/integration/queue_runner/command_server/types.py,sha256=ZNqJE6b6rfMZGdcPS-7umB_8x2a7dzTfRBsdGoCzDjY,2077
167
168
  uncountable/integration/queue_runner/command_server/protocol/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
168
169
  uncountable/integration/queue_runner/command_server/protocol/command_server.proto,sha256=mvgCHAIQocdtou7e8SZmmJIBK7jIzJ2fT4yDz6Bur3c,1869
@@ -179,9 +180,7 @@ uncountable/integration/queue_runner/datastore/model.py,sha256=06_fGS0QTALEA_381
179
180
  uncountable/integration/secret_retrieval/__init__.py,sha256=sisf8cxCYcJmCXCKjEPoT2xIxm1j-Ru9iTfMVxH6NM0,171
180
181
  uncountable/integration/secret_retrieval/basic_secret.py,sha256=PfpeaEDwdi8nFlHUJnzCob9GYgGG5O_3mmcaxPPY6zI,3272
181
182
  uncountable/integration/secret_retrieval/retrieve_secret.py,sha256=c0VkCvppWKXwgxdblQuj2WhRaEGBPa7vLDoS6mlNOAQ,3919
182
- uncountable/integration/webhook_server/admin_auth.py,sha256=RYyK54wH5SHfzAVS4CeGgaKHeD4qAhqsvNZqkWuN34Q,1234
183
- uncountable/integration/webhook_server/admin_routes.py,sha256=S73fIQu7-0U8RKHqvgKIkRMzkEcaYKwplKSexWA5vIQ,1862
184
- uncountable/integration/webhook_server/entrypoint.py,sha256=LT0IDQtCqNoZBtzb-x6Ev96e1Xug3GRv4hn5NUbG_A4,4385
183
+ uncountable/integration/webhook_server/entrypoint.py,sha256=_1360zLIvEVQVf8ToyK-E2fnaszGKs2HcxcZjNraADc,4268
185
184
  uncountable/types/__init__.py,sha256=7fIYhDsz995jFGK7wBXIVMzIxUJbltUmdqm9U6HA_QQ,14367
186
185
  uncountable/types/async_batch.py,sha256=yCCWrrLQfxXVqZp-KskxLBNkNmuELdz4PJjx8ULppgs,662
187
186
  uncountable/types/async_batch_processor.py,sha256=6Z1qx_H9IqLqp0rzpCtrRvw0DqQ2O5ctkEnw0yNVaV8,48727
@@ -196,7 +195,7 @@ uncountable/types/calculations.py,sha256=fApOFpgBemt_t7IVneVR0VdI3X5EOxiG6Xhzr6R
196
195
  uncountable/types/calculations_t.py,sha256=_K4MDVoxa6AgxlxPrEDBujWhEGWEhUQuE2-ZgiLg9OU,660
197
196
  uncountable/types/chemical_structure.py,sha256=ujyragaD26-QG5jgKnWhO7TN3N1V9b_04T2WhqNYxxo,281
198
197
  uncountable/types/chemical_structure_t.py,sha256=tbA7NBO-vv7Hnk3-ETxc6Pw3wqWeenBG27Y9_bdPCu4,785
199
- uncountable/types/client_base.py,sha256=X_1ccYzdrQHyn4tCyIGhxMlLHdoThx2lcav2tVvQE1A,138269
198
+ uncountable/types/client_base.py,sha256=eM3WCohb2OESAE6OoV7MMWbXV4S6vGMZrEC0Bw4wXGU,138517
200
199
  uncountable/types/client_config.py,sha256=M7FZ0m_lGmBsIYcMn8pm92DdoVzrLpzd8sH6DqTQLKo,456
201
200
  uncountable/types/client_config_t.py,sha256=-YNp-zFvk5OL6_WGwGSx30ToK2bGIOh0kv_HN7KcCys,1796
202
201
  uncountable/types/condition_match.py,sha256=ekDzij7-e1PtVwIslSjD1T9fuBIDr5c_7QPdaasiGJw,262
@@ -217,8 +216,8 @@ uncountable/types/field_values.py,sha256=iG4TvITLnlz023GuhFrlDwXB7oov5DPpAs_FBaM
217
216
  uncountable/types/field_values_t.py,sha256=jfvHmnMLnPz_q6bjdJvWi204uKN2F_KtIzXrfU701Jg,9096
218
217
  uncountable/types/fields.py,sha256=M0_ZZr0QdNLXkdHAGo5mfU90kEtHedCSKrcod-FG30Y,245
219
218
  uncountable/types/fields_t.py,sha256=MbV4S0hX137FZLe-fXEMRuTo53wjdy1TxodtJmjXbiI,656
220
- uncountable/types/generic_upload.py,sha256=bNep2nT0fbKAlJaGvHWPmuvfX5KtS8kgTqTh8FQk1NA,858
221
- uncountable/types/generic_upload_t.py,sha256=prMriVeukeiJa5gWfIOHuFad9rz_18FiOtlNjhb2WZ0,3828
219
+ uncountable/types/generic_upload.py,sha256=XhPnQDxsCqQec3lfq9Afs9t8lzQmVJYmY97Bg5CxvTE,945
220
+ uncountable/types/generic_upload_t.py,sha256=MAsNFqfcsbdCEsFkB4dQx7k7WyW0jX683e18eHVeGUo,4346
222
221
  uncountable/types/id_source.py,sha256=sBlDfUwHQ7bGWMschSD_aPQL7LVnCPiV2RAlPLXrAqk,546
223
222
  uncountable/types/id_source_t.py,sha256=KhOK1wXgVgbRy1vSdEdbwyz9z4prWRcAx8hOJyjCvyQ,1886
224
223
  uncountable/types/identifier.py,sha256=J-ptCFE0_R0bBAvrYp-gvHk8H9Qq9rhbmeyXgwb9nos,482
@@ -411,7 +410,7 @@ uncountable/types/api/recipes/get_recipe_output_metadata.py,sha256=IYm4DA7RN8gvx
411
410
  uncountable/types/api/recipes/get_recipes_data.py,sha256=B_rYOq7TfHVi4pyFN6r5zi5QDzMXhRDw2thlldwO0wQ,8859
412
411
  uncountable/types/api/recipes/lock_recipes.py,sha256=Ez5oWRwq_n16RHli56uaEURGrRYxyIS2VLOJEUQ9kaY,1885
413
412
  uncountable/types/api/recipes/remove_recipe_from_project.py,sha256=h1I3cLUb_zZ3QjpK7IKPT_1gTDPTPbGWg0OJN_rUn6o,1186
414
- uncountable/types/api/recipes/set_recipe_inputs.py,sha256=2NhVAfkIjVTtUKeV1aPLb42wP6XZBerbYKJ_K_HH3kA,2040
413
+ uncountable/types/api/recipes/set_recipe_inputs.py,sha256=Qf3Ny6EkUKZ2QmRba8-K4AfpKHTwGexlxyzCOCjIQsM,2288
415
414
  uncountable/types/api/recipes/set_recipe_metadata.py,sha256=8BT03B4LC5bAJ7lu5LSxdAML5Q58Rejagpm_EVuJUYk,1356
416
415
  uncountable/types/api/recipes/set_recipe_output_annotations.py,sha256=058uueGk-0ILboQy29nnj_jTD6suROEXuXuYsoAib2w,3823
417
416
  uncountable/types/api/recipes/set_recipe_output_file.py,sha256=Jv6zkjvIxWM8o9RiX5p2SRkw1UJt9pzYC-KgG0BZoVY,1915
@@ -434,7 +433,7 @@ uncountable/types/api/uploader/complete_async_parse.py,sha256=Os1HAubxTlbut6Gylj
434
433
  uncountable/types/api/uploader/invoke_uploader.py,sha256=QU1KmAFgGHSWskZEKfaAww8rCj-hVNP0i4kMQE3x3Fc,1822
435
434
  uncountable/types/api/user/__init__.py,sha256=gCgbynxG3jA8FQHzercKtrHKHkiIKr8APdZYUniAor8,55
436
435
  uncountable/types/api/user/get_current_user_info.py,sha256=BT7bGp5SOHz3XdKWhhdZ6acnInafT2PgdJ-H4YH3W7c,1181
437
- uncountablepythonsdk-0.0.181.dist-info/METADATA,sha256=3K5y5CfPXJkAFZQvtT-N4ziTKT3hNqTUovVVjbaucfg,2211
438
- uncountablepythonsdk-0.0.181.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
439
- uncountablepythonsdk-0.0.181.dist-info/top_level.txt,sha256=1UVGjAU-6hJY9qw2iJ7nCBeEwZ793AEN5ZfKX9A1uj4,31
440
- uncountablepythonsdk-0.0.181.dist-info/RECORD,,
436
+ uncountablepythonsdk-0.0.183.dist-info/METADATA,sha256=eX9guGMbvzFEFaLPrwMk5yJxSz8FW0_LUJz9tJ01hto,2211
437
+ uncountablepythonsdk-0.0.183.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
438
+ uncountablepythonsdk-0.0.183.dist-info/top_level.txt,sha256=1UVGjAU-6hJY9qw2iJ7nCBeEwZ793AEN5ZfKX9A1uj4,31
439
+ uncountablepythonsdk-0.0.183.dist-info/RECORD,,
@@ -1,38 +0,0 @@
1
- import functools
2
- import hmac
3
- from collections.abc import Callable
4
-
5
- import flask
6
- from flask.typing import ResponseReturnValue
7
- from uncountable.core.environment import get_integration_admin_token
8
- from uncountable.integration.http_server import HttpException
9
-
10
- _BEARER_PREFIX = "Bearer "
11
-
12
-
13
- def _extract_bearer_token(authorization_header: str | None) -> str | None:
14
- if authorization_header is None:
15
- return None
16
- if not authorization_header.startswith(_BEARER_PREFIX):
17
- return None
18
- return authorization_header[len(_BEARER_PREFIX) :]
19
-
20
-
21
- def require_admin_auth(
22
- view: Callable[..., ResponseReturnValue],
23
- ) -> Callable[..., ResponseReturnValue]:
24
- @functools.wraps(view)
25
- def wrapper(*args: object, **kwargs: object) -> ResponseReturnValue:
26
- admin_token = get_integration_admin_token()
27
- if admin_token is None:
28
- raise HttpException.service_unavailable()
29
- provided_token = _extract_bearer_token(
30
- flask.request.headers.get("Authorization")
31
- )
32
- if provided_token is None or not hmac.compare_digest(
33
- provided_token, admin_token
34
- ):
35
- raise HttpException.unauthorized()
36
- return view(*args, **kwargs)
37
-
38
- return wrapper
@@ -1,49 +0,0 @@
1
- import flask
2
- from flask.typing import ResponseReturnValue
3
- from opentelemetry.trace import get_current_span
4
- from uncountable.core.environment import get_local_admin_server_port
5
- from uncountable.integration.http_server import HttpException
6
- from uncountable.integration.queue_runner.command_server.command_client import (
7
- check_health,
8
- )
9
- from uncountable.integration.queue_runner.command_server.types import (
10
- CommandServerException,
11
- )
12
- from uncountable.integration.telemetry import Logger
13
- from uncountable.integration.webhook_server.admin_auth import require_admin_auth
14
-
15
- _ADMIN_HEALTH_ROUTE = "/admin/health"
16
-
17
-
18
- def register_admin_routes(app: flask.Flask) -> None:
19
- server_logger = Logger(get_current_span())
20
-
21
- @require_admin_auth
22
- def handle_admin_health() -> ResponseReturnValue:
23
- healthy = check_health(port=get_local_admin_server_port())
24
- return flask.jsonify({"healthy": healthy})
25
-
26
- def admin_health() -> ResponseReturnValue:
27
- with server_logger.push_scope(_ADMIN_HEALTH_ROUTE):
28
- try:
29
- return handle_admin_health()
30
- except HttpException as e:
31
- return e.make_error_response()
32
- except CommandServerException as e:
33
- server_logger.log_exception(
34
- e,
35
- message="admin health check could not reach the command server",
36
- )
37
- return HttpException.bad_gateway().make_error_response()
38
- except Exception as e:
39
- server_logger.log_exception(
40
- e, message=f"unexpected error on GET {_ADMIN_HEALTH_ROUTE}"
41
- )
42
- return HttpException.unknown_error().make_error_response()
43
-
44
- app.add_url_rule(
45
- _ADMIN_HEALTH_ROUTE,
46
- endpoint="admin_health",
47
- view_func=admin_health,
48
- methods=["GET"],
49
- )
@@ -1,110 +0,0 @@
1
- import secrets
2
-
3
- from uncountable.core.client import Client
4
- from uncountable.core.query.builder import QueryBuilder
5
- from uncountable.core.query.column import (
6
- NonNullableColumn,
7
- TextColumn,
8
- )
9
- from uncountable.core.query.row import QueryRow
10
- from uncountable.types import (
11
- entity_t,
12
- field_values_t,
13
- identifier_t,
14
- listing_t,
15
- webhook_t,
16
- )
17
- from uncountable.types.job_definition_t import ProfileMetadata
18
- from uncountable.types.secret_retrieval_t import SecretRetrievalEntityColumn
19
-
20
- _WEBHOOK_DEFINITION_REF_NAME = "unc_webhook_definition"
21
- _WEBHOOK_REF_NAME_FIELD_REF_NAME = "refName"
22
- _WEBHOOK_NAME_FIELD_REF_NAME = "name"
23
- _WEBHOOK_URL_FIELD_REF_NAME = "core_webhook_url"
24
- _WEBHOOK_SIGNATURE_KEY_FIELD_REF_NAME = "core_webhook_signatureKey"
25
- _WEBHOOK_PURPOSE_FIELD_REF_NAME = "core_webhook_purpose"
26
-
27
-
28
- class _WebhookEntityQueryRow(QueryRow):
29
- ref_name = TextColumn(
30
- identifier=listing_t.ColumnIdentifierEntityRefName(
31
- entity_type=entity_t.EntityType.WEBHOOK,
32
- ref_name=_WEBHOOK_REF_NAME_FIELD_REF_NAME,
33
- ),
34
- nullable=NonNullableColumn,
35
- )
36
- signature_key = TextColumn(
37
- identifier=listing_t.ColumnIdentifierEntityRefName(
38
- entity_type=entity_t.EntityType.WEBHOOK,
39
- ref_name=_WEBHOOK_SIGNATURE_KEY_FIELD_REF_NAME,
40
- ),
41
- nullable=NonNullableColumn,
42
- )
43
-
44
-
45
- def webhook_signature_key_retrieval(*, job_id: str) -> SecretRetrievalEntityColumn:
46
- return SecretRetrievalEntityColumn(
47
- entity_key=identifier_t.IdentifierKeyRefName(ref_name=job_id),
48
- column_identifier=listing_t.ColumnIdentifierEntityRefName(
49
- entity_type=entity_t.EntityType.WEBHOOK,
50
- ref_name=_WEBHOOK_SIGNATURE_KEY_FIELD_REF_NAME,
51
- ),
52
- )
53
-
54
-
55
- def _resolve_webhook_url(*, profile_metadata: ProfileMetadata, job_id: str) -> str:
56
- integration_server_base_url = profile_metadata.integration_server_base_url
57
- if integration_server_base_url is None:
58
- raise ValueError(
59
- "integration_server_base_url is required to resolve the webhook entity "
60
- f"for {profile_metadata.name}/{job_id}"
61
- )
62
- return f"{integration_server_base_url.rstrip('/')}/{profile_metadata.name}/{job_id}"
63
-
64
-
65
- def bootstrap_webhook_signature_key(
66
- *,
67
- client: Client,
68
- profile_metadata: ProfileMetadata,
69
- job_id: str,
70
- purpose: webhook_t.WebhookPurpose,
71
- ) -> str:
72
- existing_webhook = (
73
- QueryBuilder(client=client, model=_WebhookEntityQueryRow)
74
- .filter(_WebhookEntityQueryRow.ref_name == job_id)
75
- .one_or_none()
76
- )
77
- if existing_webhook is not None:
78
- return existing_webhook.signature_key
79
-
80
- webhook_url = _resolve_webhook_url(profile_metadata=profile_metadata, job_id=job_id)
81
- signature_key = secrets.token_urlsafe(32)
82
- client.create_entity(
83
- entity_type=entity_t.EntityType.WEBHOOK,
84
- definition_key=identifier_t.IdentifierKeyRefName(
85
- ref_name=_WEBHOOK_DEFINITION_REF_NAME
86
- ),
87
- field_values=[
88
- field_values_t.FieldRefNameValue(
89
- field_ref_name=_WEBHOOK_REF_NAME_FIELD_REF_NAME,
90
- value=job_id,
91
- ),
92
- field_values_t.FieldRefNameValue(
93
- field_ref_name=_WEBHOOK_NAME_FIELD_REF_NAME,
94
- value=f"{profile_metadata.name}/{job_id}",
95
- ),
96
- field_values_t.FieldRefNameValue(
97
- field_ref_name=_WEBHOOK_URL_FIELD_REF_NAME,
98
- value=webhook_url,
99
- ),
100
- field_values_t.FieldRefNameValue(
101
- field_ref_name=_WEBHOOK_SIGNATURE_KEY_FIELD_REF_NAME,
102
- value=signature_key,
103
- ),
104
- field_values_t.FieldRefNameValue(
105
- field_ref_name=_WEBHOOK_PURPOSE_FIELD_REF_NAME,
106
- value=purpose.value,
107
- ),
108
- ],
109
- )
110
- return signature_key