UncountablePythonSDK 0.0.113__py3-none-any.whl → 0.0.115__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.
- examples/integration-server/jobs/materials_auto/concurrent_cron.py +11 -0
- examples/integration-server/jobs/materials_auto/example_http.py +35 -0
- examples/integration-server/jobs/materials_auto/profile.yaml +25 -0
- pkgs/argument_parser/argument_parser.py +8 -3
- pkgs/type_spec/builder.py +8 -2
- pkgs/type_spec/non_discriminated_union_exceptions.py +14 -0
- pkgs/type_spec/parts/base.py.prepart +5 -8
- pkgs/type_spec/ui_entry_actions/generate_ui_entry_actions.py +18 -5
- pkgs/type_spec/value_spec/__main__.py +2 -2
- uncountable/core/environment.py +1 -1
- uncountable/integration/http_server/__init__.py +5 -0
- uncountable/integration/http_server/types.py +67 -0
- uncountable/integration/job.py +129 -5
- uncountable/integration/queue_runner/job_scheduler.py +10 -2
- uncountable/integration/server.py +2 -2
- uncountable/integration/telemetry.py +1 -1
- uncountable/integration/webhook_server/entrypoint.py +37 -112
- uncountable/types/__init__.py +6 -0
- uncountable/types/api/entity/export_entities.py +46 -0
- uncountable/types/api/entity/lookup_entity.py +15 -1
- uncountable/types/api/recipes/create_mix_order.py +44 -0
- uncountable/types/api/recipes/set_recipe_outputs.py +1 -0
- uncountable/types/async_batch_processor.py +34 -0
- uncountable/types/async_batch_t.py +1 -0
- uncountable/types/base_t.py +5 -8
- uncountable/types/client_base.py +49 -0
- uncountable/types/entity_t.py +3 -1
- uncountable/types/exports.py +8 -0
- uncountable/types/exports_t.py +33 -0
- uncountable/types/integration_server_t.py +2 -0
- uncountable/types/job_definition.py +2 -0
- uncountable/types/job_definition_t.py +26 -2
- {uncountablepythonsdk-0.0.113.dist-info → uncountablepythonsdk-0.0.115.dist-info}/METADATA +1 -1
- {uncountablepythonsdk-0.0.113.dist-info → uncountablepythonsdk-0.0.115.dist-info}/RECORD +36 -27
- {uncountablepythonsdk-0.0.113.dist-info → uncountablepythonsdk-0.0.115.dist-info}/WHEEL +1 -1
- {uncountablepythonsdk-0.0.113.dist-info → uncountablepythonsdk-0.0.115.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from uncountable.integration.job import CronJob, JobArguments, register_job
|
|
4
|
+
from uncountable.types.job_definition_t import JobResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register_job
|
|
8
|
+
class MyConcurrentCronJob(CronJob):
|
|
9
|
+
def run(self, args: JobArguments) -> JobResult:
|
|
10
|
+
time.sleep(10)
|
|
11
|
+
return JobResult(success=True)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from uncountable.integration.http_server import (
|
|
4
|
+
GenericHttpRequest,
|
|
5
|
+
GenericHttpResponse,
|
|
6
|
+
)
|
|
7
|
+
from uncountable.integration.job import CustomHttpJob, register_job
|
|
8
|
+
from uncountable.types import job_definition_t
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(kw_only=True)
|
|
12
|
+
class ExampleWebhookPayload:
|
|
13
|
+
id: int
|
|
14
|
+
message: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@register_job
|
|
18
|
+
class HttpExample(CustomHttpJob):
|
|
19
|
+
@staticmethod
|
|
20
|
+
def validate_request(
|
|
21
|
+
*,
|
|
22
|
+
request: GenericHttpRequest,
|
|
23
|
+
job_definition: job_definition_t.HttpJobDefinitionBase,
|
|
24
|
+
profile_meta: job_definition_t.ProfileMetadata,
|
|
25
|
+
) -> None:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def handle_request(
|
|
30
|
+
*,
|
|
31
|
+
request: GenericHttpRequest,
|
|
32
|
+
job_definition: job_definition_t.HttpJobDefinitionBase,
|
|
33
|
+
profile_meta: job_definition_t.ProfileMetadata,
|
|
34
|
+
) -> GenericHttpResponse:
|
|
35
|
+
return GenericHttpResponse(response="OK", status_code=200)
|
|
@@ -22,6 +22,25 @@ jobs:
|
|
|
22
22
|
- admin:
|
|
23
23
|
type: ref_name
|
|
24
24
|
ref_name: admin
|
|
25
|
+
- id: concurrent_cron_1
|
|
26
|
+
enabled: true
|
|
27
|
+
type: cron
|
|
28
|
+
name: MyConcurrentCron - 1
|
|
29
|
+
cron_spec: "* * * * *"
|
|
30
|
+
executor:
|
|
31
|
+
type: script
|
|
32
|
+
import_path: concurrent_cron
|
|
33
|
+
subqueue_name: subqueue_1
|
|
34
|
+
- id: concurrent_cron_2
|
|
35
|
+
enabled: true
|
|
36
|
+
type: cron
|
|
37
|
+
name: MyConcurrentCron - 2
|
|
38
|
+
cron_spec: "* * * * *"
|
|
39
|
+
executor:
|
|
40
|
+
type: script
|
|
41
|
+
import_path: concurrent_cron
|
|
42
|
+
subqueue_name: subqueue_2
|
|
43
|
+
|
|
25
44
|
|
|
26
45
|
- id: example_wh1
|
|
27
46
|
type: webhook
|
|
@@ -41,6 +60,12 @@ jobs:
|
|
|
41
60
|
executor:
|
|
42
61
|
type: script
|
|
43
62
|
import_path: example_wh
|
|
63
|
+
- id: example_http
|
|
64
|
+
type: custom_http
|
|
65
|
+
name: Custom HTTP
|
|
66
|
+
executor:
|
|
67
|
+
type: script
|
|
68
|
+
import_path: example_http
|
|
44
69
|
- id: example_runsheet_wh
|
|
45
70
|
type: webhook
|
|
46
71
|
name: Runsheet Webhook
|
|
@@ -170,9 +170,15 @@ def _invoke_membership_parser(
|
|
|
170
170
|
|
|
171
171
|
def _build_parser_discriminated_union(
|
|
172
172
|
context: ParserContext,
|
|
173
|
-
|
|
173
|
+
discriminator_raw: str,
|
|
174
174
|
discriminator_map: dict[str, ParserFunction[T]],
|
|
175
175
|
) -> ParserFunction[T]:
|
|
176
|
+
discriminator = (
|
|
177
|
+
snake_to_camel_case(discriminator_raw)
|
|
178
|
+
if context.options.from_camel_case
|
|
179
|
+
else discriminator_raw
|
|
180
|
+
)
|
|
181
|
+
|
|
176
182
|
def parse(value: typing.Any) -> typing.Any:
|
|
177
183
|
if context.options.allow_direct_type and dataclasses.is_dataclass(value):
|
|
178
184
|
discriminant = getattr(value, discriminator)
|
|
@@ -414,8 +420,7 @@ def _build_parser_dataclass(
|
|
|
414
420
|
cur_parser = context.cache.get(parsed_type)
|
|
415
421
|
if cur_parser is not None:
|
|
416
422
|
return cur_parser
|
|
417
|
-
|
|
418
|
-
type_hints = typing.get_type_hints(parsed_type)
|
|
423
|
+
type_hints = typing.get_type_hints(parsed_type, include_extras=True)
|
|
419
424
|
dc_field_parsers: list[
|
|
420
425
|
tuple[
|
|
421
426
|
dataclasses.Field[typing.Any],
|
pkgs/type_spec/builder.py
CHANGED
|
@@ -14,6 +14,7 @@ from typing import Any, Self
|
|
|
14
14
|
|
|
15
15
|
from . import util
|
|
16
16
|
from .cross_output_links import CrossOutputPaths
|
|
17
|
+
from .non_discriminated_union_exceptions import NON_DISCRIMINATED_UNION_EXCEPTIONS
|
|
17
18
|
from .util import parse_type_str, unused
|
|
18
19
|
|
|
19
20
|
RawDict = dict[Any, Any]
|
|
@@ -636,6 +637,11 @@ class SpecTypeDefnUnion(SpecTypeDefn):
|
|
|
636
637
|
self.discriminator_map[discriminant] = sub_type
|
|
637
638
|
|
|
638
639
|
builder.pop_where()
|
|
640
|
+
elif (
|
|
641
|
+
f"{self.namespace.name}.{self.name}"
|
|
642
|
+
not in NON_DISCRIMINATED_UNION_EXCEPTIONS
|
|
643
|
+
):
|
|
644
|
+
raise Exception(f"union requires a discriminator: {self.name}")
|
|
639
645
|
|
|
640
646
|
def get_referenced_types(self) -> list[SpecType]:
|
|
641
647
|
return self.types
|
|
@@ -960,8 +966,8 @@ class SpecEndpoint:
|
|
|
960
966
|
elif isinstance(is_sdk, str):
|
|
961
967
|
try:
|
|
962
968
|
is_sdk = EndpointEmitType(is_sdk)
|
|
963
|
-
except ValueError:
|
|
964
|
-
raise ValueError(f"Invalid value for is_sdk: {is_sdk}")
|
|
969
|
+
except ValueError as e:
|
|
970
|
+
raise ValueError(f"Invalid value for is_sdk: {is_sdk}") from e
|
|
965
971
|
|
|
966
972
|
assert isinstance(is_sdk, EndpointEmitType)
|
|
967
973
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
NON_DISCRIMINATED_UNION_EXCEPTIONS = [
|
|
2
|
+
"generate_tool_parameters.UnionWithoutDiscrim",
|
|
3
|
+
"output_calculation_entities.ConditionParameterFilterCondition",
|
|
4
|
+
"output_parameters.AnalyticalMethodParameterOptions",
|
|
5
|
+
"output_parameters.AnalyticalMethodLinkedOptionValue",
|
|
6
|
+
"recipes_redirect.RecipesRedirectResult",
|
|
7
|
+
"value_spec.ResolvedPathAll",
|
|
8
|
+
"weighted_sum.WeightedSumEntities",
|
|
9
|
+
"workflows.WorkflowTotalDisplay",
|
|
10
|
+
"type_info.TypeFormActionConstraint",
|
|
11
|
+
"structured_loading.CompatibleFilterNode",
|
|
12
|
+
"structured_display_element.DisplayElementColumn",
|
|
13
|
+
"field_values.FieldValue",
|
|
14
|
+
]
|
|
@@ -58,15 +58,12 @@ def is_pure_json_value(value: ExtJsonValue) -> bool:
|
|
|
58
58
|
return True
|
|
59
59
|
|
|
60
60
|
if isinstance(value, list):
|
|
61
|
-
for item in value
|
|
62
|
-
if not is_pure_json_value(item):
|
|
63
|
-
return False
|
|
64
|
-
return True
|
|
61
|
+
return all(is_pure_json_value(item) for item in value)
|
|
65
62
|
|
|
66
63
|
if isinstance(value, dict):
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
return all(
|
|
65
|
+
is_pure_json_value(key) and is_pure_json_value(item)
|
|
66
|
+
for key, item in value.items()
|
|
67
|
+
)
|
|
71
68
|
|
|
72
69
|
return False
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import re
|
|
2
3
|
from dataclasses import dataclass
|
|
3
4
|
from io import StringIO
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import assert_never
|
|
6
7
|
|
|
7
8
|
from main.base.types import (
|
|
9
|
+
base_t,
|
|
8
10
|
ui_entry_actions_t,
|
|
9
11
|
)
|
|
10
|
-
from pkgs.argument_parser import snake_to_camel_case
|
|
11
12
|
from pkgs.serialization_util import serialize_for_api
|
|
12
13
|
from pkgs.type_spec import emit_typescript_util
|
|
13
14
|
from pkgs.type_spec.builder import (
|
|
@@ -45,8 +46,8 @@ def ui_entry_variable_to_type_spec_type(
|
|
|
45
46
|
match variable:
|
|
46
47
|
case ui_entry_actions_t.UiEntryActionVariableString():
|
|
47
48
|
return BaseTypeName.s_string
|
|
48
|
-
case ui_entry_actions_t.
|
|
49
|
-
return "
|
|
49
|
+
case ui_entry_actions_t.UiEntryActionVariableSingleEntity():
|
|
50
|
+
return "ObjectId"
|
|
50
51
|
case _:
|
|
51
52
|
assert_never(variable)
|
|
52
53
|
|
|
@@ -157,6 +158,15 @@ def emit_entry_action_definition(
|
|
|
157
158
|
)
|
|
158
159
|
|
|
159
160
|
|
|
161
|
+
def _validate_input(input: ui_entry_actions_t.UiEntryActionVariable) -> None:
|
|
162
|
+
if "_" in input.vs_var_name:
|
|
163
|
+
raise ValueError(f"Expected camelCase for variable {input.vs_var_name}")
|
|
164
|
+
if not re.fullmatch(base_t.REF_NAME_STRICT_REGEX, input.vs_var_name):
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"Variable {input.vs_var_name} has invalid syntax. See REF_NAME_STRICT_REGEX"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
160
170
|
def emit_query_index(
|
|
161
171
|
ctx: emit_typescript_util.EmitTypescriptContext,
|
|
162
172
|
defn_infos: list[EntryActionTypeInfo],
|
|
@@ -182,6 +192,7 @@ def emit_query_index(
|
|
|
182
192
|
"type": BaseTypeName.s_object,
|
|
183
193
|
"properties": {
|
|
184
194
|
"value_spec_var": {"type": "String"},
|
|
195
|
+
"type": {"type": "ui_entry_actions.UiEntryActionDataType"},
|
|
185
196
|
"variable": {"type": "ui_entry_actions.UiEntryActionVariable"},
|
|
186
197
|
},
|
|
187
198
|
},
|
|
@@ -215,10 +226,12 @@ def emit_query_index(
|
|
|
215
226
|
for scope, defn in definitions.items():
|
|
216
227
|
inputs = []
|
|
217
228
|
outputs = []
|
|
218
|
-
for
|
|
229
|
+
for input in defn.inputs.values():
|
|
230
|
+
_validate_input(input)
|
|
219
231
|
inputs.append(
|
|
220
232
|
serialize_for_api({
|
|
221
|
-
"value_spec_var":
|
|
233
|
+
"value_spec_var": input.vs_var_name,
|
|
234
|
+
"type": input.type,
|
|
222
235
|
"variable": input,
|
|
223
236
|
})
|
|
224
237
|
)
|
|
@@ -104,8 +104,8 @@ def parse_function_signature(text: str) -> ParsedFunctionSignature:
|
|
|
104
104
|
|
|
105
105
|
type_str = source.extract_type()
|
|
106
106
|
ref_name = arg_group.group(1)
|
|
107
|
-
is_missing = arg_group.group(2) == "?"
|
|
108
|
-
is_repeating = arg_group.group(2) == "+"
|
|
107
|
+
# is_missing = arg_group.group(2) == "?"
|
|
108
|
+
# is_repeating = arg_group.group(2) == "+"
|
|
109
109
|
type_path = parse_type_str(type_str)
|
|
110
110
|
|
|
111
111
|
match arg_group.group(3):
|
uncountable/core/environment.py
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import functools
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from flask.wrappers import Response
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HttpException(Exception):
|
|
9
|
+
error_code: int
|
|
10
|
+
message: str
|
|
11
|
+
|
|
12
|
+
def __init__(self, *, error_code: int, message: str) -> None:
|
|
13
|
+
self.error_code = error_code
|
|
14
|
+
self.message = message
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def payload_failed_signature() -> "HttpException":
|
|
18
|
+
return HttpException(
|
|
19
|
+
error_code=401, message="webhook payload did not match signature"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def no_signature_passed() -> "HttpException":
|
|
24
|
+
return HttpException(error_code=400, message="missing signature")
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def body_parse_error() -> "HttpException":
|
|
28
|
+
return HttpException(error_code=400, message="body parse error")
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def unknown_error() -> "HttpException":
|
|
32
|
+
return HttpException(error_code=500, message="internal server error")
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def configuration_error(
|
|
36
|
+
message: str = "internal configuration error",
|
|
37
|
+
) -> "HttpException":
|
|
38
|
+
return HttpException(error_code=500, message=message)
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
return f"[{self.error_code}]: {self.message}"
|
|
42
|
+
|
|
43
|
+
def make_error_response(self) -> Response:
|
|
44
|
+
return Response(
|
|
45
|
+
status=self.error_code, response={"error": {"message": str(self)}}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(kw_only=True, frozen=True)
|
|
50
|
+
class GenericHttpRequest:
|
|
51
|
+
body_base64: str
|
|
52
|
+
headers: dict[str, str]
|
|
53
|
+
|
|
54
|
+
@functools.cached_property
|
|
55
|
+
def body_bytes(self) -> bytes:
|
|
56
|
+
return base64.b64decode(self.body_base64)
|
|
57
|
+
|
|
58
|
+
@functools.cached_property
|
|
59
|
+
def body_text(self) -> str:
|
|
60
|
+
return self.body_bytes.decode()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(kw_only=True)
|
|
64
|
+
class GenericHttpResponse:
|
|
65
|
+
response: str
|
|
66
|
+
status_code: int
|
|
67
|
+
headers: dict[str, str] | None = None
|
uncountable/integration/job.py
CHANGED
|
@@ -1,15 +1,43 @@
|
|
|
1
1
|
import functools
|
|
2
|
+
import hmac
|
|
2
3
|
import typing
|
|
3
4
|
from abc import ABC, abstractmethod
|
|
4
5
|
from dataclasses import dataclass
|
|
5
6
|
|
|
7
|
+
import simplejson
|
|
8
|
+
|
|
6
9
|
from pkgs.argument_parser import CachedParser
|
|
10
|
+
from pkgs.serialization_util import serialize_for_api
|
|
7
11
|
from uncountable.core.async_batch import AsyncBatchProcessor
|
|
8
12
|
from uncountable.core.client import Client
|
|
13
|
+
from uncountable.core.environment import get_local_admin_server_port
|
|
9
14
|
from uncountable.core.file_upload import FileUpload
|
|
15
|
+
from uncountable.integration.http_server import (
|
|
16
|
+
GenericHttpRequest,
|
|
17
|
+
GenericHttpResponse,
|
|
18
|
+
HttpException,
|
|
19
|
+
)
|
|
20
|
+
from uncountable.integration.queue_runner.command_server.command_client import (
|
|
21
|
+
send_job_queue_message,
|
|
22
|
+
)
|
|
23
|
+
from uncountable.integration.queue_runner.command_server.types import (
|
|
24
|
+
CommandServerException,
|
|
25
|
+
)
|
|
26
|
+
from uncountable.integration.secret_retrieval.retrieve_secret import retrieve_secret
|
|
10
27
|
from uncountable.integration.telemetry import JobLogger
|
|
11
|
-
from uncountable.types import
|
|
12
|
-
|
|
28
|
+
from uncountable.types import (
|
|
29
|
+
base_t,
|
|
30
|
+
entity_t,
|
|
31
|
+
job_definition_t,
|
|
32
|
+
queued_job_t,
|
|
33
|
+
webhook_job_t,
|
|
34
|
+
)
|
|
35
|
+
from uncountable.types.job_definition_t import (
|
|
36
|
+
HttpJobDefinitionBase,
|
|
37
|
+
JobDefinition,
|
|
38
|
+
JobResult,
|
|
39
|
+
ProfileMetadata,
|
|
40
|
+
)
|
|
13
41
|
|
|
14
42
|
|
|
15
43
|
@dataclass(kw_only=True)
|
|
@@ -26,9 +54,6 @@ class JobArguments:
|
|
|
26
54
|
CronJobArguments = JobArguments
|
|
27
55
|
|
|
28
56
|
|
|
29
|
-
PT = typing.TypeVar("PT")
|
|
30
|
-
|
|
31
|
-
|
|
32
57
|
class Job[PT](ABC):
|
|
33
58
|
_unc_job_registered: bool = False
|
|
34
59
|
|
|
@@ -63,6 +88,51 @@ class CronJob(Job):
|
|
|
63
88
|
WPT = typing.TypeVar("WPT")
|
|
64
89
|
|
|
65
90
|
|
|
91
|
+
@dataclass(kw_only=True)
|
|
92
|
+
class WebhookResponse:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CustomHttpJob(Job[GenericHttpRequest]):
|
|
97
|
+
@property
|
|
98
|
+
def payload_type(self) -> type[GenericHttpRequest]:
|
|
99
|
+
return GenericHttpRequest
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def validate_request(
|
|
104
|
+
*,
|
|
105
|
+
request: GenericHttpRequest,
|
|
106
|
+
job_definition: HttpJobDefinitionBase,
|
|
107
|
+
profile_meta: ProfileMetadata,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Validate that the request is valid. If the request is invalid, raise an
|
|
111
|
+
exception.
|
|
112
|
+
"""
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
@abstractmethod
|
|
117
|
+
def handle_request(
|
|
118
|
+
*,
|
|
119
|
+
request: GenericHttpRequest,
|
|
120
|
+
job_definition: HttpJobDefinitionBase,
|
|
121
|
+
profile_meta: ProfileMetadata,
|
|
122
|
+
) -> GenericHttpResponse:
|
|
123
|
+
"""
|
|
124
|
+
Handle the request synchronously. Normally this should just enqueue a job
|
|
125
|
+
and return immediately (see WebhookJob as an example).
|
|
126
|
+
"""
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
def run_outer(self, args: JobArguments) -> JobResult:
|
|
130
|
+
args.logger.log_warning(
|
|
131
|
+
message=f"Unexpected call to run_outer for CustomHttpJob: {args.job_definition.id}"
|
|
132
|
+
)
|
|
133
|
+
return JobResult(success=False)
|
|
134
|
+
|
|
135
|
+
|
|
66
136
|
class WebhookJob[WPT](Job[webhook_job_t.WebhookEventPayload]):
|
|
67
137
|
@property
|
|
68
138
|
def payload_type(self) -> type[webhook_job_t.WebhookEventPayload]:
|
|
@@ -72,6 +142,60 @@ class WebhookJob[WPT](Job[webhook_job_t.WebhookEventPayload]):
|
|
|
72
142
|
@abstractmethod
|
|
73
143
|
def webhook_payload_type(self) -> type[WPT]: ...
|
|
74
144
|
|
|
145
|
+
@staticmethod
|
|
146
|
+
def validate_request(
|
|
147
|
+
*,
|
|
148
|
+
request: GenericHttpRequest,
|
|
149
|
+
job_definition: job_definition_t.HttpJobDefinitionBase,
|
|
150
|
+
profile_meta: ProfileMetadata,
|
|
151
|
+
) -> None:
|
|
152
|
+
assert isinstance(job_definition, job_definition_t.WebhookJobDefinition)
|
|
153
|
+
signature_key = retrieve_secret(
|
|
154
|
+
profile_metadata=profile_meta,
|
|
155
|
+
secret_retrieval=job_definition.signature_key_secret,
|
|
156
|
+
)
|
|
157
|
+
passed_signature = request.headers.get("Uncountable-Webhook-Signature")
|
|
158
|
+
if passed_signature is None:
|
|
159
|
+
raise HttpException.no_signature_passed()
|
|
160
|
+
|
|
161
|
+
request_body_signature = hmac.new(
|
|
162
|
+
signature_key.encode("utf-8"), msg=request.body_bytes, digestmod="sha256"
|
|
163
|
+
).hexdigest()
|
|
164
|
+
|
|
165
|
+
if request_body_signature != passed_signature:
|
|
166
|
+
raise HttpException.payload_failed_signature()
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def handle_request(
|
|
170
|
+
*,
|
|
171
|
+
request: GenericHttpRequest,
|
|
172
|
+
job_definition: job_definition_t.HttpJobDefinitionBase,
|
|
173
|
+
profile_meta: ProfileMetadata, # noqa: ARG004
|
|
174
|
+
) -> GenericHttpResponse:
|
|
175
|
+
try:
|
|
176
|
+
request_body = simplejson.loads(request.body_text)
|
|
177
|
+
webhook_payload = typing.cast(base_t.JsonValue, request_body)
|
|
178
|
+
except (simplejson.JSONDecodeError, ValueError) as e:
|
|
179
|
+
raise HttpException.body_parse_error() from e
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
send_job_queue_message(
|
|
183
|
+
job_ref_name=job_definition.id,
|
|
184
|
+
payload=queued_job_t.QueuedJobPayload(
|
|
185
|
+
invocation_context=queued_job_t.InvocationContextWebhook(
|
|
186
|
+
webhook_payload=webhook_payload
|
|
187
|
+
)
|
|
188
|
+
),
|
|
189
|
+
port=get_local_admin_server_port(),
|
|
190
|
+
)
|
|
191
|
+
except CommandServerException as e:
|
|
192
|
+
raise HttpException.unknown_error() from e
|
|
193
|
+
|
|
194
|
+
return GenericHttpResponse(
|
|
195
|
+
response=simplejson.dumps(serialize_for_api(WebhookResponse())),
|
|
196
|
+
status_code=200,
|
|
197
|
+
)
|
|
198
|
+
|
|
75
199
|
def run_outer(self, args: JobArguments) -> JobResult:
|
|
76
200
|
webhook_body = self.get_payload(args.payload)
|
|
77
201
|
inner_payload = CachedParser(self.webhook_payload_type).parse_api(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import sys
|
|
2
3
|
import typing
|
|
3
4
|
from concurrent.futures import ProcessPoolExecutor
|
|
4
5
|
from dataclasses import dataclass
|
|
@@ -34,6 +35,10 @@ class JobListenerKey:
|
|
|
34
35
|
def _get_job_worker_key(
|
|
35
36
|
job_definition: job_definition_t.JobDefinition, profile_name: str
|
|
36
37
|
) -> JobListenerKey:
|
|
38
|
+
if job_definition.subqueue_name is not None:
|
|
39
|
+
return JobListenerKey(
|
|
40
|
+
profile_name=profile_name, subqueue_name=job_definition.subqueue_name
|
|
41
|
+
)
|
|
37
42
|
return JobListenerKey(profile_name=profile_name)
|
|
38
43
|
|
|
39
44
|
|
|
@@ -41,9 +46,12 @@ def on_worker_crash(
|
|
|
41
46
|
worker_key: JobListenerKey,
|
|
42
47
|
) -> typing.Callable[[asyncio.Task], None]:
|
|
43
48
|
def hook(task: asyncio.Task) -> None:
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
Logger(get_current_span()).log_exception(
|
|
50
|
+
Exception(
|
|
51
|
+
f"worker {worker_key.profile_name}_{worker_key.subqueue_name} crashed unexpectedly"
|
|
52
|
+
)
|
|
46
53
|
)
|
|
54
|
+
sys.exit(1)
|
|
47
55
|
|
|
48
56
|
return hook
|
|
49
57
|
|
|
@@ -16,7 +16,7 @@ from uncountable.integration.telemetry import Logger
|
|
|
16
16
|
from uncountable.types import base_t, job_definition_t
|
|
17
17
|
from uncountable.types.job_definition_t import (
|
|
18
18
|
CronJobDefinition,
|
|
19
|
-
|
|
19
|
+
HttpJobDefinitionBase,
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
_MAX_APSCHEDULER_CONCURRENT_JOBS = 1
|
|
@@ -86,7 +86,7 @@ class IntegrationServer:
|
|
|
86
86
|
misfire_grace_time=None,
|
|
87
87
|
**job_opts,
|
|
88
88
|
)
|
|
89
|
-
case
|
|
89
|
+
case HttpJobDefinitionBase():
|
|
90
90
|
pass
|
|
91
91
|
case _:
|
|
92
92
|
assert_never(job_defn)
|
|
@@ -177,7 +177,7 @@ class JobLogger(Logger):
|
|
|
177
177
|
patched_attributes["job.definition.cron_spec"] = (
|
|
178
178
|
self.job_definition.cron_spec
|
|
179
179
|
)
|
|
180
|
-
case job_definition_t.
|
|
180
|
+
case job_definition_t.HttpJobDefinitionBase():
|
|
181
181
|
pass
|
|
182
182
|
case _:
|
|
183
183
|
assert_never(self.job_definition)
|