UncountablePythonSDK 0.0.59__py3-none-any.whl → 0.0.61__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.

Potentially problematic release.


This version of UncountablePythonSDK might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: UncountablePythonSDK
3
- Version: 0.0.59
3
+ Version: 0.0.61
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
@@ -33,6 +33,8 @@ Requires-Dist: opentelemetry-exporter-otlp-proto-http ==1.*
33
33
  Requires-Dist: opentelemetry-sdk ==1.*
34
34
  Requires-Dist: paramiko ==3.*
35
35
  Requires-Dist: boto3 ==1.*
36
+ Requires-Dist: flask ==3.*
37
+ Requires-Dist: simplejson ==3.*
36
38
  Provides-Extra: test
37
39
  Requires-Dist: mypy ==1.* ; extra == 'test'
38
40
  Requires-Dist: ruff ==0.* ; extra == 'test'
@@ -74,27 +74,28 @@ uncountable/__init__.py,sha256=8l8XWNCKsu7TG94c-xa2KHpDegvxDC2FyQISdWC763Y,89
74
74
  uncountable/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
75
75
  uncountable/core/__init__.py,sha256=RFv0kO6rKFf1PtBPu83hCGmxqkJamRtsgQ9_-ztw7tA,341
76
76
  uncountable/core/async_batch.py,sha256=Gur0VOS0AH2ugwvk65hwoX-iqwQAAyJaejY_LyAZZPo,1210
77
- uncountable/core/client.py,sha256=kKd9MvvlSKDWh69iZ6K2IfbLxMMQ8l0tFLk6p0YG6GM,10622
77
+ uncountable/core/client.py,sha256=KUJN3XcbawMg_GuJ5DvmDsmhRzsnafCjyq27vOD4jC4,10640
78
+ uncountable/core/environment.py,sha256=4rLWeUIDYB6w1iqrAiSEaD_OqMoo_ed3xcjz5jM4HGU,572
78
79
  uncountable/core/file_upload.py,sha256=qR7BBBWVxFNrb1_WICreo3dkZygE9lcE1fmZCQrDZU0,3469
79
80
  uncountable/core/types.py,sha256=s2CjqYJpsmbC7xMwxxT7kJ_V9bwokrjjWVVjpMcQpKI,333
80
- uncountable/core/version.py,sha256=SqQIHLhiVZXQBeOwygS2FRZ4WEO27JmWhse0lKm7fgU,274
81
81
  uncountable/integration/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
82
82
  uncountable/integration/construct_client.py,sha256=I2XTamht13vs-JYkV4PpNS_Pc4FJm-KVYqNNvxI4qNk,1916
83
- uncountable/integration/cron.py,sha256=e5456IYJF2ipiSsd1R2T334lfe7mtp-gwP7JpS645L0,1858
83
+ uncountable/integration/cron.py,sha256=U6aVIKwLy11n2_ez4MQo5XNZsGjb_Ykh582WG02SWCs,1424
84
84
  uncountable/integration/entrypoint.py,sha256=wgOXhTzErttRjOzV4rS4psZW5qUKIa5ez89QndQl61k,785
85
- uncountable/integration/job.py,sha256=UTzcMes2KrBBRLOM3u94imMKLLnv50glqOkNf8-JOZw,1022
85
+ uncountable/integration/job.py,sha256=UrsZHWXKE2wD5M3lFKJCXvDdWj7QMDtAREKv6RBnT3Q,1548
86
86
  uncountable/integration/scan_profiles.py,sha256=3o_h5Ta8ZQEX1epWyXhtyEof0M1b7MobVG7bRKcsFuM,1166
87
- uncountable/integration/server.py,sha256=bmX-ukLiNDq0ThVB2lUyXl-vtID5HI4gqJHxhsVNG3w,4440
88
- uncountable/integration/telemetry.py,sha256=lTaTwhf-suKM8AXSeOe3wdUG1mdV7gbi88UBIDkzs9o,6036
87
+ uncountable/integration/server.py,sha256=EhzfOyXVNOKUmS5_XN4TZVSke1n1vnGLfuDrVUlo1j4,4528
88
+ uncountable/integration/telemetry.py,sha256=9vIyV_4Hfxdh-EEP5dH3SalQOSCHjXchwgSB34M2ndI,7127
89
89
  uncountable/integration/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
90
90
  uncountable/integration/db/connect.py,sha256=YtQHJ1DBGPhxKFRCfiXqohOYUceKSxMVOJ88aPI48Ug,181
91
91
  uncountable/integration/executors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
92
- uncountable/integration/executors/executors.py,sha256=v5ClGVUlvrZcMdmGQa8Ll668G_HGTnKpGOnTM7UMZCQ,956
93
- uncountable/integration/executors/generic_upload_executor.py,sha256=nB-kgLWXePbl8u6UwCKFXRrXnFpguMR-91ylnfeWPSA,10280
92
+ uncountable/integration/executors/executors.py,sha256=CbwatKkHrLhnqYr_nsBjr0KYeOn8KqijxrZPHbxChBY,1961
93
+ uncountable/integration/executors/generic_upload_executor.py,sha256=PpxZmGUac4PtwlEJi7-S7scSa3y6HrdBPs_p2QDyqZs,10286
94
94
  uncountable/integration/executors/script_executor.py,sha256=OmSBOtU48G3mqza9c2lCm84pGGyaDk-ZBJCx3RsdJXc,846
95
95
  uncountable/integration/secret_retrieval/__init__.py,sha256=3QXVj35w8rRMxVvmmsViFYDi3lcb3g70incfalOEm6o,87
96
96
  uncountable/integration/secret_retrieval/retrieve_secret.py,sha256=eoPWbkUtCn_63A4TFlK_nvEDvfm4u2fiOoglmAkBG3U,3004
97
- uncountable/types/__init__.py,sha256=0KN0QKnwQgEHP90-BfnW67OF77L4Ino-hN4ea-Tx1M0,8324
97
+ uncountable/integration/webhook_server/entrypoint.py,sha256=gmbGgHpQqrxrfE4GHl-uXNNjET8QVO7Ym5usjyY8MgU,5749
98
+ uncountable/types/__init__.py,sha256=zejeAhEYwf5Jbs-n7S9Wr6NPFXfhWIFdyARB1v40scc,8390
98
99
  uncountable/types/async_batch.py,sha256=_OhT25_dEVts_z_n1kqfJH3xlZg3btLqR6TNkfFLlXE,609
99
100
  uncountable/types/async_batch_processor.py,sha256=obVzN-PcYLV2pHScszfCGjSq6-Xc34WM1ysx6Fv6tZk,11293
100
101
  uncountable/types/async_batch_t.py,sha256=ipSGz93O1KB-WE2dvlvflTKS51rJrf3bJkUojyxos7I,2193
@@ -110,7 +111,7 @@ uncountable/types/client_config_t.py,sha256=_HdS37gMSTIiD4qLnW9dIgt8_Rt5A6xhwMGG
110
111
  uncountable/types/curves.py,sha256=W6uMpG5SyW1MS82szNpxkFEn1MnxNpBFyFbQb2Ysfng,366
111
112
  uncountable/types/curves_t.py,sha256=TDpsThz4lKmiBmS9b4ItUSCp64TGv8-qDkxb4B2RoTo,1314
112
113
  uncountable/types/entity.py,sha256=3XhLteFDRDZvHejDuYh-KvB65hpwrBygljFfiUcOAM8,315
113
- uncountable/types/entity_t.py,sha256=kH3RyuAXEs6moMKwDGSQokzHaRBDcfuO-tHkFbre2zk,14537
114
+ uncountable/types/entity_t.py,sha256=D-Z92DPWObIYdqaQE4UDQX2RB3GBUuyAgcAkKaSBuWw,14599
114
115
  uncountable/types/experiment_groups.py,sha256=_0OXcPzSAbkE-rfKt5tPx178YJ4pcEKZvrCxUHgDnvw,309
115
116
  uncountable/types/experiment_groups_t.py,sha256=0IGAXwkYiwdjj6aFjLMihxwauACQTyuRU_1usJTeUg4,593
116
117
  uncountable/types/field_values.py,sha256=uuIWX-xmfvcinYPdfkWJeb56zzQY01mc9rmotMPMh24,503
@@ -128,7 +129,7 @@ uncountable/types/input_attributes_t.py,sha256=wE1ekiQfb72Z9VpF5SHipKJkgaJFUHJrN
128
129
  uncountable/types/inputs.py,sha256=6RIEFfCxLqpeHEGOpu63O4i8zPogjGeB7wiV_rPBw_g,404
129
130
  uncountable/types/inputs_t.py,sha256=RW7gF9zTOwByu-nMTMVuBabLOuWKx4O1nvfgvx_R55o,1611
130
131
  uncountable/types/job_definition.py,sha256=HXfaYl5Nafm9C0teQLBtqzroe1HlfKJtfGVm2-40hvg,1937
131
- uncountable/types/job_definition_t.py,sha256=0BKIqg1fSJKXHxLAWm11bgz7OC4ye1k4EPaY439_Mns,7549
132
+ uncountable/types/job_definition_t.py,sha256=frtTpcJJCNXzdDlz8OWLMBFvsnS9nmqJVn9l7fBI7uc,7616
132
133
  uncountable/types/outputs.py,sha256=sUZx_X-TKCZtLm1YCEH8OISX9DdPlv9ZuUfM3-askCc,281
133
134
  uncountable/types/outputs_t.py,sha256=2aORUOr0ls1ZYo-ddkWax3D1ZndmQsWtHfJxpYozlhg,656
134
135
  uncountable/types/overrides.py,sha256=Mv-smwK1B3pvbt48fNOiqkeQn9wMgYlBFJKUBOJqceE,431
@@ -163,6 +164,8 @@ uncountable/types/units.py,sha256=R_TBhxWCIWSSXK9J3S0Omtj3t5BZNK9C80MyqFjMO7k,27
163
164
  uncountable/types/units_t.py,sha256=w_k7SQ_J_-XTGY-BMcWC5sQi2z3uInqtrRUPHgniuew,559
164
165
  uncountable/types/users.py,sha256=YEk8v0vDOBFvmOQMQw7MAOicSGzMui8Hb9hdFX2Vw3E,275
165
166
  uncountable/types/users_t.py,sha256=ATsF1EGKw49jfcspf9OWHL0tSgOR76x80XloKk624fk,582
167
+ uncountable/types/webhook_job.py,sha256=Y8IVmL311Hd4Jvdl1d7ND_OCygyC0dAoV-_E4A-lI6A,363
168
+ uncountable/types/webhook_job_t.py,sha256=MM-0epv4nIp4lgtLBTr-gs51PVjBuAMqpHfCPXDnYc0,847
166
169
  uncountable/types/workflows.py,sha256=uSZWsdDe2jpXnBnRunEen7_7pbKBrE0ds_ez98EzyPg,353
167
170
  uncountable/types/workflows_t.py,sha256=Nz9z_eerEDEP_Ieuz7gQEv1SabkFdCiZmu6RL8w3kyE,831
168
171
  uncountable/types/api/__init__.py,sha256=gCgbynxG3jA8FQHzercKtrHKHkiIKr8APdZYUniAor8,55
@@ -244,7 +247,7 @@ uncountable/types/api/triggers/__init__.py,sha256=gCgbynxG3jA8FQHzercKtrHKHkiIKr
244
247
  uncountable/types/api/triggers/run_trigger.py,sha256=_Rpha9nxXI3Xr17CrGDtofg4HZ81x2lt0rMZ6As0qfE,893
245
248
  uncountable/types/api/uploader/__init__.py,sha256=gCgbynxG3jA8FQHzercKtrHKHkiIKr8APdZYUniAor8,55
246
249
  uncountable/types/api/uploader/invoke_uploader.py,sha256=Rc77y5q-3R9-SNQgm8P35zKaW2D1Hbtm7PDixnOn1G0,1025
247
- UncountablePythonSDK-0.0.59.dist-info/METADATA,sha256=NUmxAfNfwlnajLwx1OOVQiFEI0FZ7Crm7z2GP1oL3zI,1934
248
- UncountablePythonSDK-0.0.59.dist-info/WHEEL,sha256=cVxcB9AmuTcXqmwrtPhNK88dr7IR_b6qagTj0UvIEbY,91
249
- UncountablePythonSDK-0.0.59.dist-info/top_level.txt,sha256=1UVGjAU-6hJY9qw2iJ7nCBeEwZ793AEN5ZfKX9A1uj4,31
250
- UncountablePythonSDK-0.0.59.dist-info/RECORD,,
250
+ UncountablePythonSDK-0.0.61.dist-info/METADATA,sha256=k4u4vX8jG7Wh9ZP3jDBny1X1ohNKVNaYX1KZBhFXsoM,1993
251
+ UncountablePythonSDK-0.0.61.dist-info/WHEEL,sha256=cVxcB9AmuTcXqmwrtPhNK88dr7IR_b6qagTj0UvIEbY,91
252
+ UncountablePythonSDK-0.0.61.dist-info/top_level.txt,sha256=1UVGjAU-6hJY9qw2iJ7nCBeEwZ793AEN5ZfKX9A1uj4,31
253
+ UncountablePythonSDK-0.0.61.dist-info/RECORD,,
@@ -1,5 +1,4 @@
1
1
  import base64
2
- import json
3
2
  import typing
4
3
  from dataclasses import dataclass
5
4
  from datetime import datetime, timedelta
@@ -8,13 +7,14 @@ from urllib.parse import urljoin
8
7
  from uuid import uuid4
9
8
 
10
9
  import requests
10
+ import simplejson as json
11
11
  from opentelemetry.sdk.resources import Attributes
12
12
  from requests.exceptions import JSONDecodeError
13
13
 
14
14
  from pkgs.argument_parser import CachedParser
15
15
  from pkgs.serialization_util import serialize_for_api
16
16
  from pkgs.serialization_util.serialization_helpers import JsonValue
17
- from uncountable.core.version import get_version
17
+ from uncountable.core.environment import get_version
18
18
  from uncountable.integration.telemetry import JobLogger
19
19
  from uncountable.types.client_base import APIRequest, ClientMethods
20
20
  from uncountable.types.client_config import ClientConfigOptions
@@ -0,0 +1,24 @@
1
+ import functools
2
+ import os
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+
6
+ @functools.cache
7
+ def get_version() -> str:
8
+ try:
9
+ version_str = version("UncountablePythonSDK")
10
+ except PackageNotFoundError:
11
+ version_str = "unknown"
12
+ return version_str
13
+
14
+
15
+ def get_integration_env() -> str | None:
16
+ return os.environ.get("UNC_INTEGRATION_ENV")
17
+
18
+
19
+ def get_webhook_server_port() -> int:
20
+ return int(os.environ.get("UNC_WEBHOOK_SERVER_PORT") or 5001)
21
+
22
+
23
+ def get_otel_enabled() -> bool:
24
+ return os.environ.get("UNC_OTEL_ENABLED") == "true"
@@ -3,7 +3,7 @@ from dataclasses import dataclass
3
3
  from pkgs.argument_parser import CachedParser
4
4
  from uncountable.core.async_batch import AsyncBatchProcessor
5
5
  from uncountable.integration.construct_client import construct_uncountable_client
6
- from uncountable.integration.executors.executors import resolve_executor
6
+ from uncountable.integration.executors.executors import execute_job
7
7
  from uncountable.integration.job import CronJobArguments
8
8
  from uncountable.integration.telemetry import JobLogger
9
9
  from uncountable.types.job_definition_t import JobDefinition, ProfileMetadata
@@ -36,20 +36,8 @@ def cron_job_executor(**kwargs: dict) -> None:
36
36
  logger=job_logger,
37
37
  )
38
38
 
39
- with job_logger.push_scope(args_passed.definition.name) as job_logger:
40
- job = resolve_executor(
41
- args_passed.definition.executor, args_passed.profile_metadata
42
- )
43
-
44
- job_logger.log_info("running job")
45
-
46
- job.run(args=args)
47
-
48
- if batch_processor.current_queue_size() != 0:
49
- batch_processor.send()
50
-
51
- submitted_batch_job_ids = batch_processor.get_submitted_job_ids()
52
- job_logger.log_info(
53
- "completed job",
54
- attributes={"submitted_batch_job_ids": submitted_batch_job_ids},
55
- )
39
+ execute_job(
40
+ args=args,
41
+ profile_metadata=args_passed.profile_metadata,
42
+ job_definition=args_passed.definition,
43
+ )
@@ -2,7 +2,7 @@ from typing import assert_never
2
2
 
3
3
  from uncountable.integration.executors.generic_upload_executor import GenericUploadJob
4
4
  from uncountable.integration.executors.script_executor import resolve_script_executor
5
- from uncountable.integration.job import Job
5
+ from uncountable.integration.job import Job, JobArguments
6
6
  from uncountable.types import job_definition_t
7
7
 
8
8
 
@@ -22,3 +22,35 @@ def resolve_executor(
22
22
  data_source=job_executor.data_source,
23
23
  )
24
24
  assert_never(job_executor)
25
+
26
+
27
+ def execute_job(
28
+ *,
29
+ job_definition: job_definition_t.JobDefinition,
30
+ profile_metadata: job_definition_t.ProfileMetadata,
31
+ args: JobArguments,
32
+ ) -> job_definition_t.JobResult:
33
+ with args.logger.push_scope(job_definition.name) as job_logger:
34
+ job = resolve_executor(job_definition.executor, profile_metadata)
35
+
36
+ job_logger.log_info("running job")
37
+
38
+ try:
39
+ result = job.run_outer(args=args)
40
+ except Exception as e:
41
+ job_logger.log_exception(e)
42
+ return job_definition_t.JobResult(success=False)
43
+
44
+ if args.batch_processor.current_queue_size() != 0:
45
+ args.batch_processor.send()
46
+
47
+ submitted_batch_job_ids = args.batch_processor.get_submitted_job_ids()
48
+ job_logger.log_info(
49
+ "completed job",
50
+ attributes={
51
+ "submitted_batch_job_ids": submitted_batch_job_ids,
52
+ "success": result.success,
53
+ },
54
+ )
55
+
56
+ return result
@@ -226,7 +226,7 @@ class GenericUploadJob(Job):
226
226
 
227
227
  return S3Session(s3_config=s3_config)
228
228
 
229
- def run(self, args: JobArguments) -> JobResult:
229
+ def run_outer(self, args: JobArguments) -> JobResult:
230
230
  client = args.client
231
231
  batch_processor = args.batch_processor
232
232
  logger = args.logger
@@ -4,10 +4,11 @@ from dataclasses import dataclass
4
4
  from uncountable.core.async_batch import AsyncBatchProcessor
5
5
  from uncountable.core.client import Client
6
6
  from uncountable.integration.telemetry import JobLogger
7
+ from uncountable.types import webhook_job_t
7
8
  from uncountable.types.job_definition_t import JobDefinition, JobResult, ProfileMetadata
8
9
 
9
10
 
10
- @dataclass
11
+ @dataclass(kw_only=True)
11
12
  class JobArgumentsBase:
12
13
  job_definition: JobDefinition
13
14
  profile_metadata: ProfileMetadata
@@ -16,27 +17,44 @@ class JobArgumentsBase:
16
17
  logger: JobLogger
17
18
 
18
19
 
19
- @dataclass
20
+ @dataclass(kw_only=True)
20
21
  class CronJobArguments(JobArgumentsBase):
21
- # can imagine passing additional data such as in the sftp or webhook cases
22
22
  pass
23
23
 
24
24
 
25
- JobArguments = CronJobArguments
25
+ @dataclass(kw_only=True)
26
+ class WebhookJobArguments(JobArgumentsBase):
27
+ payload: webhook_job_t.WebhookEventBody
28
+
29
+
30
+ JobArguments = CronJobArguments | WebhookJobArguments
26
31
 
27
32
 
28
33
  class Job(ABC):
29
34
  _unc_job_registered: bool = False
30
35
 
31
36
  @abstractmethod
32
- def run(self, args: JobArguments) -> JobResult: ...
37
+ def run_outer(self, args: JobArguments) -> JobResult: ...
33
38
 
34
39
 
35
40
  class CronJob(Job):
41
+ def run_outer(self, args: JobArguments) -> JobResult:
42
+ assert isinstance(args, CronJobArguments)
43
+ return self.run(args)
44
+
36
45
  @abstractmethod
37
46
  def run(self, args: CronJobArguments) -> JobResult: ...
38
47
 
39
48
 
49
+ class WebhookJob(Job):
50
+ def run_outer(self, args: JobArguments) -> JobResult:
51
+ assert isinstance(args, WebhookJobArguments)
52
+ return self.run(args)
53
+
54
+ @abstractmethod
55
+ def run(self, args: WebhookJobArguments) -> JobResult: ...
56
+
57
+
40
58
  def register_job(cls: type[Job]) -> type[Job]:
41
59
  cls._unc_job_registered = True
42
60
  return cls
@@ -19,6 +19,7 @@ from uncountable.types.job_definition_t import (
19
19
  CronJobDefinition,
20
20
  JobDefinition,
21
21
  ProfileMetadata,
22
+ WebhookJobDefinition,
22
23
  )
23
24
 
24
25
  _MAX_APSCHEDULER_CONCURRENT_JOBS = 1
@@ -97,8 +98,10 @@ class IntegrationServer:
97
98
  kwargs=job_kwargs,
98
99
  **job_opts,
99
100
  )
101
+ case WebhookJobDefinition():
102
+ pass
100
103
  case _:
101
- assert_never(job_defn.trigger)
104
+ assert_never(job_defn)
102
105
 
103
106
  def serve_forever(self) -> None:
104
107
  signal.pause()
@@ -1,6 +1,7 @@
1
1
  import functools
2
2
  import os
3
3
  import time
4
+ import typing
4
5
  from contextlib import contextmanager
5
6
  from enum import StrEnum
6
7
  from typing import Generator, assert_never, cast
@@ -16,9 +17,13 @@ from opentelemetry.sdk.trace import TracerProvider
16
17
  from opentelemetry.sdk.trace.export import (
17
18
  SimpleSpanProcessor,
18
19
  )
19
- from opentelemetry.trace import DEFAULT_TRACE_OPTIONS, Tracer
20
+ from opentelemetry.trace import DEFAULT_TRACE_OPTIONS, Span, Tracer
20
21
 
21
- from uncountable.core.version import get_version
22
+ from uncountable.core.environment import (
23
+ get_integration_env,
24
+ get_otel_enabled,
25
+ get_version,
26
+ )
22
27
  from uncountable.types import base_t, job_definition_t
23
28
 
24
29
 
@@ -35,7 +40,7 @@ def get_otel_resource() -> Resource:
35
40
  unc_version = os.environ.get("UNC_VERSION")
36
41
  if unc_version is not None:
37
42
  attributes["service.version"] = unc_version
38
- unc_env = os.environ.get("UNC_INTEGRATION_ENV")
43
+ unc_env = get_integration_env()
39
44
  if unc_env is not None:
40
45
  attributes["deployment.environment"] = unc_env
41
46
  resource = Resource.create(attributes=_cast_attributes(attributes))
@@ -45,7 +50,8 @@ def get_otel_resource() -> Resource:
45
50
  @functools.cache
46
51
  def get_otel_tracer() -> Tracer:
47
52
  provider = TracerProvider(resource=get_otel_resource())
48
- provider.add_span_processor(SimpleSpanProcessor(OTLPSpanExporter()))
53
+ if get_otel_enabled():
54
+ provider.add_span_processor(SimpleSpanProcessor(OTLPSpanExporter()))
49
55
  trace.set_tracer_provider(provider)
50
56
  return provider.get_tracer("integration.telemetry")
51
57
 
@@ -54,7 +60,8 @@ def get_otel_tracer() -> Tracer:
54
60
  def get_otel_logger() -> OTELLogger:
55
61
  provider = LoggerProvider(resource=get_otel_resource())
56
62
  provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter()))
57
- provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
63
+ if get_otel_enabled():
64
+ provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
58
65
  _logs.set_logger_provider(provider)
59
66
  return provider.get_logger("integration.telemetry")
60
67
 
@@ -66,8 +73,19 @@ class LogSeverity(StrEnum):
66
73
 
67
74
 
68
75
  class Logger:
69
- current_span_id: int | None = None
70
- current_trace_id: int | None = None
76
+ current_span: Span | None = None
77
+
78
+ @property
79
+ def current_span_id(self) -> int | None:
80
+ if self.current_span is None:
81
+ return None
82
+ return self.current_span.get_span_context().span_id
83
+
84
+ @property
85
+ def current_trace_id(self) -> int | None:
86
+ if self.current_span is None:
87
+ return None
88
+ return self.current_span.get_span_context().trace_id
71
89
 
72
90
  def _patch_attributes(self, attributes: Attributes | None) -> Attributes:
73
91
  return attributes or {}
@@ -84,6 +102,8 @@ class Logger:
84
102
  span_id=self.current_span_id,
85
103
  trace_id=self.current_trace_id,
86
104
  trace_flags=DEFAULT_TRACE_OPTIONS,
105
+ severity_number=_logs.SeverityNumber.UNSPECIFIED,
106
+ resource=get_otel_resource(),
87
107
  )
88
108
  otel_logger.emit(log_record)
89
109
 
@@ -104,6 +124,33 @@ class Logger:
104
124
  message=message, severity=LogSeverity.ERROR, attributes=attributes
105
125
  )
106
126
 
127
+ def log_exception(
128
+ self,
129
+ exception: BaseException,
130
+ *,
131
+ message: str | None = None,
132
+ attributes: Attributes | None = None,
133
+ ) -> None:
134
+ patched_attributes = self._patch_attributes(attributes)
135
+ if self.current_span is not None:
136
+ self.current_span.record_exception(
137
+ exception=exception, attributes=patched_attributes
138
+ )
139
+ self.log_error(
140
+ message=f"error: {message}\nexception: {exception}",
141
+ attributes=patched_attributes,
142
+ )
143
+
144
+ @contextmanager
145
+ def push_scope(
146
+ self, scope_name: str, *, attributes: Attributes | None = None
147
+ ) -> Generator[typing.Self, None, None]:
148
+ with get_otel_tracer().start_as_current_span(
149
+ scope_name, attributes=self._patch_attributes(attributes)
150
+ ) as span:
151
+ self.current_span = span
152
+ yield self
153
+
107
154
 
108
155
  class JobLogger(Logger):
109
156
  def __init__(
@@ -129,6 +176,8 @@ class JobLogger(Logger):
129
176
  patched_attributes["job.definition.cron_spec"] = (
130
177
  self.job_definition.cron_spec
131
178
  )
179
+ case job_definition_t.WebhookJobDefinition():
180
+ pass
132
181
  case _:
133
182
  assert_never(self.job_definition)
134
183
  patched_attributes["job.definition.executor.type"] = (
@@ -146,14 +195,3 @@ class JobLogger(Logger):
146
195
  case _:
147
196
  assert_never(self.job_definition.executor)
148
197
  return _cast_attributes(patched_attributes)
149
-
150
- @contextmanager
151
- def push_scope(
152
- self, scope_name: str, *, attributes: Attributes | None = None
153
- ) -> Generator["JobLogger", None, None]:
154
- with get_otel_tracer().start_as_current_span(
155
- scope_name, attributes=self._patch_attributes(attributes)
156
- ) as span:
157
- self.current_span_id = span.get_span_context().span_id
158
- self.current_trace_id = span.get_span_context().trace_id
159
- yield self
@@ -0,0 +1,164 @@
1
+ import hmac
2
+ from dataclasses import dataclass
3
+
4
+ import flask
5
+ import simplejson
6
+ from flask.typing import ResponseReturnValue
7
+ from flask.wrappers import Response
8
+ from uncountable.core.async_batch import AsyncBatchProcessor
9
+ from uncountable.core.environment import get_integration_env, get_webhook_server_port
10
+ from uncountable.integration.construct_client import construct_uncountable_client
11
+ from uncountable.integration.executors.executors import execute_job
12
+ from uncountable.integration.job import WebhookJobArguments
13
+ from uncountable.integration.scan_profiles import load_profiles
14
+ from uncountable.integration.secret_retrieval.retrieve_secret import retrieve_secret
15
+ from uncountable.integration.telemetry import JobLogger, Logger
16
+ from uncountable.types import job_definition_t, webhook_job_t
17
+
18
+ from pkgs.argument_parser import CachedParser
19
+
20
+ app = flask.Flask(__name__)
21
+
22
+
23
+ @dataclass(kw_only=True)
24
+ class WebhookResponse:
25
+ pass
26
+
27
+
28
+ webhook_payload_parser = CachedParser(webhook_job_t.WebhookEventBody)
29
+
30
+
31
+ class WebhookException(BaseException):
32
+ error_code: int
33
+ message: str
34
+
35
+ def __init__(self, *, error_code: int, message: str) -> None:
36
+ self.error_code = error_code
37
+ self.message = message
38
+
39
+ @staticmethod
40
+ def payload_failed_signature() -> "WebhookException":
41
+ return WebhookException(
42
+ error_code=401, message="webhook payload did not match signature"
43
+ )
44
+
45
+ @staticmethod
46
+ def no_signature_passed() -> "WebhookException":
47
+ return WebhookException(error_code=400, message="missing signature")
48
+
49
+ @staticmethod
50
+ def body_parse_error() -> "WebhookException":
51
+ return WebhookException(error_code=400, message="body parse error")
52
+
53
+ @staticmethod
54
+ def unknown_error() -> "WebhookException":
55
+ return WebhookException(error_code=500, message="internal server error")
56
+
57
+ def __str__(self) -> str:
58
+ return f"[{self.error_code}]: {self.message}"
59
+
60
+ def make_error_response(self) -> Response:
61
+ return Response(
62
+ status=self.error_code, response={"error": {"message": str(self)}}
63
+ )
64
+
65
+
66
+ def _parse_webhook_payload(
67
+ *, raw_request_body: bytes, signature_key: str, passed_signature: str
68
+ ) -> webhook_job_t.WebhookEventBody:
69
+ request_body_signature = hmac.new(
70
+ signature_key.encode("utf-8"), msg=raw_request_body, digestmod="sha256"
71
+ ).hexdigest()
72
+
73
+ if request_body_signature != passed_signature:
74
+ raise WebhookException.payload_failed_signature()
75
+
76
+ try:
77
+ request_body = simplejson.loads(raw_request_body.decode())
78
+ return webhook_payload_parser.parse_api(request_body)
79
+ except (simplejson.JSONDecodeError, ValueError) as e:
80
+ raise WebhookException.body_parse_error() from e
81
+
82
+
83
+ def register_route(
84
+ *,
85
+ server_logger: Logger,
86
+ profile_meta: job_definition_t.ProfileMetadata,
87
+ job: job_definition_t.WebhookJobDefinition,
88
+ ) -> None:
89
+ route = f"/{profile_meta.name}/{job.id}"
90
+
91
+ @app.route(route, methods=["POST"])
92
+ def handle_webhook() -> ResponseReturnValue:
93
+ with server_logger.push_scope(route):
94
+ try:
95
+ signature_key = retrieve_secret(
96
+ profile_metadata=profile_meta,
97
+ secret_retrieval=job.signature_key_secret,
98
+ )
99
+
100
+ passed_signature = flask.request.headers.get(
101
+ "Uncountable-Webhook-Signature"
102
+ )
103
+ if passed_signature is None:
104
+ raise WebhookException.no_signature_passed()
105
+
106
+ webhook_payload = _parse_webhook_payload(
107
+ raw_request_body=flask.request.data,
108
+ signature_key=signature_key,
109
+ passed_signature=passed_signature,
110
+ )
111
+
112
+ job_logger = JobLogger(
113
+ profile_metadata=profile_metadata, job_definition=job
114
+ )
115
+ client = construct_uncountable_client(
116
+ profile_meta=profile_meta, job_logger=job_logger
117
+ )
118
+ execute_job(
119
+ job_definition=job,
120
+ profile_metadata=profile_meta,
121
+ args=WebhookJobArguments(
122
+ job_definition=job,
123
+ profile_metadata=profile_metadata,
124
+ client=client,
125
+ batch_processor=AsyncBatchProcessor(client=client),
126
+ logger=job_logger,
127
+ payload=webhook_payload,
128
+ ),
129
+ )
130
+
131
+ return flask.jsonify(WebhookResponse())
132
+ except WebhookException as e:
133
+ server_logger.log_exception(e)
134
+ return e.make_error_response()
135
+ except Exception as e:
136
+ server_logger.log_exception(e)
137
+ return WebhookException.unknown_error().make_error_response()
138
+
139
+ server_logger.log_info(f"job {job.id} webhook registered at: {route}")
140
+
141
+
142
+ profiles = load_profiles()
143
+ for profile in profiles:
144
+ server_logger = Logger()
145
+ profile_metadata = job_definition_t.ProfileMetadata(
146
+ name=profile.name,
147
+ auth_retrieval=profile.definition.auth_retrieval,
148
+ base_url=profile.definition.base_url,
149
+ client_options=profile.definition.client_options,
150
+ )
151
+ for job in profile.definition.jobs:
152
+ if isinstance(job, job_definition_t.WebhookJobDefinition):
153
+ register_route(
154
+ server_logger=server_logger, profile_meta=profile_metadata, job=job
155
+ )
156
+
157
+
158
+ if __name__ == "__main__":
159
+ app.run(
160
+ host="0.0.0.0",
161
+ port=get_webhook_server_port(),
162
+ debug=get_integration_env() == "playground",
163
+ exclude_patterns=[],
164
+ )
@@ -97,6 +97,7 @@ from .api.recipes import unlock_recipes as unlock_recipes_t
97
97
  from .api.material_families import update_entity_material_families as update_entity_material_families_t
98
98
  from .api.field_options import upsert_field_options as upsert_field_options_t
99
99
  from . import users_t as users_t
100
+ from . import webhook_job_t as webhook_job_t
100
101
  from . import workflows_t as workflows_t
101
102
 
102
103
 
@@ -195,5 +196,6 @@ __all__: list[str] = [
195
196
  "update_entity_material_families_t",
196
197
  "upsert_field_options_t",
197
198
  "users_t",
199
+ "webhook_job_t",
198
200
  "workflows_t",
199
201
  ]
@@ -25,6 +25,7 @@ __all__: list[str] = [
25
25
  "analytical_method_category": "Analytical Method Category",
26
26
  "annotation_type": "Annotation Type",
27
27
  "approval": "Approval",
28
+ "async_job": "Async Job",
28
29
  "calculation": "Calculation",
29
30
  "calendar": "Calendar",
30
31
  "calendar_event": "Calendar Event",
@@ -176,6 +177,7 @@ class EntityType(StrEnum):
176
177
  ANALYTICAL_METHOD_CATEGORY = "analytical_method_category"
177
178
  ANNOTATION_TYPE = "annotation_type"
178
179
  APPROVAL = "approval"
180
+ ASYNC_JOB = "async_job"
179
181
  CALCULATION = "calculation"
180
182
  CALENDAR = "calendar"
181
183
  CALENDAR_EVENT = "calendar_event"
@@ -191,11 +191,12 @@ class WebhookJobDefinition(JobDefinitionBase):
191
191
 
192
192
  # DO NOT MODIFY -- This file is generated by type_spec
193
193
  JobDefinition = typing.Annotated[
194
- typing.Union[CronJobDefinition],
194
+ typing.Union[CronJobDefinition, WebhookJobDefinition],
195
195
  serial_union_annotation(
196
196
  discriminator="type",
197
197
  discriminator_map={
198
198
  "cron": CronJobDefinition,
199
+ "webhook": WebhookJobDefinition,
199
200
  },
200
201
  ),
201
202
  ]
@@ -0,0 +1,9 @@
1
+ # flake8: noqa: F821
2
+ # ruff: noqa: E402 Q003
3
+ # fmt: off
4
+ # isort: skip_file
5
+ # DO NOT MODIFY -- This file is generated by type_spec
6
+ # Kept only for SDK backwards compatibility
7
+ from .webhook_job_t import WebhookEventPayload as WebhookEventPayload
8
+ from .webhook_job_t import WebhookEventBody as WebhookEventBody
9
+ # DO NOT MODIFY -- This file is generated by type_spec
@@ -0,0 +1,33 @@
1
+ # DO NOT MODIFY -- This file is generated by type_spec
2
+ # flake8: noqa: F821
3
+ # ruff: noqa: E402 Q003
4
+ # fmt: off
5
+ # isort: skip_file
6
+ from __future__ import annotations
7
+ import typing # noqa: F401
8
+ import datetime # noqa: F401
9
+ from decimal import Decimal # noqa: F401
10
+ import dataclasses
11
+ from pkgs.serialization import serial_class
12
+ from . import base_t
13
+
14
+ __all__: list[str] = [
15
+ "WebhookEventBody",
16
+ "WebhookEventPayload",
17
+ ]
18
+
19
+
20
+ # DO NOT MODIFY -- This file is generated by type_spec
21
+ @serial_class(
22
+ unconverted_values={"data"},
23
+ )
24
+ @dataclasses.dataclass(kw_only=True)
25
+ class WebhookEventPayload:
26
+ data: dict[str, base_t.JsonValue]
27
+
28
+
29
+ # DO NOT MODIFY -- This file is generated by type_spec
30
+ @dataclasses.dataclass(kw_only=True)
31
+ class WebhookEventBody(WebhookEventPayload):
32
+ event_id: str
33
+ # DO NOT MODIFY -- This file is generated by type_spec
@@ -1,11 +0,0 @@
1
- import functools
2
- from importlib.metadata import PackageNotFoundError, version
3
-
4
-
5
- @functools.cache
6
- def get_version() -> str:
7
- try:
8
- version_str = version("UncountablePythonSDK")
9
- except PackageNotFoundError:
10
- version_str = "unknown"
11
- return version_str