UncountablePythonSDK 0.0.186__py3-none-any.whl → 0.0.187__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.
@@ -0,0 +1,150 @@
1
+ import asyncio
2
+ import json
3
+ import random
4
+ from dataclasses import dataclass
5
+ from decimal import Decimal
6
+
7
+ from uncountable.core.client import Client
8
+ from uncountable.integration.service import Service, ServiceArguments, register_service
9
+ from uncountable.types import entity_t, identifier_t, listing_t, sockets_t
10
+ from uncountable.types.base_t import ObjectId
11
+ from uncountable.types.integration_session_t import IntegrationSessionInstrument
12
+ from websockets.asyncio.client import connect
13
+
14
+ from pkgs.serialization_util import serialize_for_api
15
+
16
+ SCALE_EQUIPMENT_NAMES = [
17
+ "Instrument Scale One",
18
+ "Instrument Scale Two",
19
+ "Instrument Scale Three",
20
+ ]
21
+ EQUIPMENT_ID_COLUMN_REF_NAME = "id"
22
+ EQUIPMENT_NAME_COLUMN_REF_NAME = "name"
23
+ SOCKET_URL = "ws://host.docker.internal:8765"
24
+ READING_INTERVAL_SECONDS = 1.0
25
+
26
+
27
+ @dataclass(kw_only=True)
28
+ class InstrumentRoom:
29
+ equipment_name: str
30
+ token: str
31
+ room_key: str
32
+
33
+
34
+ @dataclass(kw_only=True)
35
+ class InstrumentReading:
36
+ equipment_name: str
37
+ value: Decimal
38
+
39
+
40
+ def _build_equipment_column(ref_name: str) -> listing_t.ColumnIdentifierEntityRefName:
41
+ return listing_t.ColumnIdentifierEntityRefName(
42
+ entity_type=entity_t.EntityType.EQUIPMENT, ref_name=ref_name
43
+ )
44
+
45
+
46
+ def _resolve_equipment_id(*, client: Client, equipment_name: str) -> ObjectId:
47
+ id_column = _build_equipment_column(EQUIPMENT_ID_COLUMN_REF_NAME)
48
+ name_column = _build_equipment_column(EQUIPMENT_NAME_COLUMN_REF_NAME)
49
+ results = client.fetch_listing(
50
+ entity_type=entity_t.EntityType.EQUIPMENT,
51
+ columns=[id_column],
52
+ filters=listing_t.FilterNodeColumnAnd(
53
+ filters=(
54
+ listing_t.FilterSpecEquals(
55
+ relation=listing_t.FilterRelation.EQUALS,
56
+ column=name_column,
57
+ value=equipment_name,
58
+ ),
59
+ )
60
+ ),
61
+ ).results
62
+ if len(results) != 1:
63
+ raise Exception(
64
+ f"expected exactly one equipment named {equipment_name}, found {len(results)}"
65
+ )
66
+ equipment_id = results[0].column_values[0]
67
+ if not isinstance(equipment_id, int):
68
+ raise Exception(
69
+ f"expected an integer id for equipment named {equipment_name}, found {equipment_id!r}"
70
+ )
71
+ return ObjectId(equipment_id)
72
+
73
+
74
+ @register_service
75
+ class ScaleConnectionService(Service):
76
+ def run(self, args: ServiceArguments) -> None:
77
+ rooms = [
78
+ self._register_room(args=args, equipment_name=equipment_name)
79
+ for equipment_name in SCALE_EQUIPMENT_NAMES
80
+ ]
81
+ asyncio.run(self._stream_readings(args=args, rooms=rooms))
82
+
83
+ def _register_room(
84
+ self, *, args: ServiceArguments, equipment_name: str
85
+ ) -> InstrumentRoom:
86
+ equipment_id = _resolve_equipment_id(
87
+ client=args.client, equipment_name=equipment_name
88
+ )
89
+ registration = args.client.register_sockets_token(
90
+ socket_request=sockets_t.SocketRequestIntegrationSession(
91
+ integration_session=IntegrationSessionInstrument(
92
+ equipment_key=identifier_t.IdentifierKeyId(id=equipment_id)
93
+ )
94
+ )
95
+ ).response
96
+ return InstrumentRoom(
97
+ equipment_name=equipment_name,
98
+ token=registration.token,
99
+ room_key=registration.room_key,
100
+ )
101
+
102
+ async def _stream_readings(
103
+ self, *, args: ServiceArguments, rooms: list[InstrumentRoom]
104
+ ) -> None:
105
+ rooms_by_name = {room.equipment_name: room for room in rooms}
106
+ async with connect(
107
+ SOCKET_URL, additional_headers={"X-UNC-EXTERNAL": "true"}
108
+ ) as ws:
109
+ for room in rooms:
110
+ await ws.send(
111
+ json.dumps(
112
+ serialize_for_api(
113
+ sockets_t.JoinRoomWithTokenSocketClientMessage(
114
+ token=room.token
115
+ )
116
+ )
117
+ )
118
+ )
119
+ args.logger.log_info(f"Joined room for instrument {room.equipment_name}")
120
+
121
+ readings: asyncio.Queue[InstrumentReading] = asyncio.Queue()
122
+ emit_task = asyncio.create_task(
123
+ self._emit_readings(rooms=rooms, readings=readings)
124
+ )
125
+ try:
126
+ while True:
127
+ reading = await readings.get()
128
+ room = rooms_by_name[reading.equipment_name]
129
+ await ws.send(
130
+ json.dumps(
131
+ serialize_for_api(
132
+ sockets_t.SendInstrumentReadingClientMessage(
133
+ value=reading.value, room_key=room.room_key
134
+ )
135
+ )
136
+ )
137
+ )
138
+ finally:
139
+ emit_task.cancel()
140
+
141
+ async def _emit_readings(
142
+ self, *, rooms: list[InstrumentRoom], readings: asyncio.Queue[InstrumentReading]
143
+ ) -> None:
144
+ while True:
145
+ room = random.choice(rooms)
146
+ value = Decimal(str(round(random.uniform(0, 500), 2)))
147
+ await readings.put(
148
+ InstrumentReading(equipment_name=room.equipment_name, value=value)
149
+ )
150
+ await asyncio.sleep(READING_INTERVAL_SECONDS)
@@ -117,3 +117,8 @@ jobs:
117
117
  executor:
118
118
  type: script
119
119
  import_path: example_parse
120
+ services:
121
+ - id: example_scale_connection
122
+ name: Example Scale Connection
123
+ import_path: example_scale_connection
124
+ enabled: true
@@ -5,6 +5,7 @@ import datetime
5
5
  import math
6
6
  import types
7
7
  import typing
8
+ from collections.abc import Mapping
8
9
  from datetime import date
9
10
  from decimal import Decimal
10
11
  from enum import Enum
@@ -140,18 +141,32 @@ def _build_parser_discriminated_union(
140
141
  else discriminator_raw
141
142
  )
142
143
 
144
+ accepted = ", ".join(sorted(discriminator_map))
145
+
143
146
  def parse(value: typing.Any) -> typing.Any:
144
147
  if context.options.allow_direct_type and dataclasses.is_dataclass(value):
145
148
  discriminant = getattr(value, discriminator)
146
- else:
149
+ elif isinstance(value, Mapping):
147
150
  discriminant = value.get(discriminator)
151
+ else:
152
+ raise ParserError(
153
+ f"expected-object-for-union; found {type(value).__name__} where an "
154
+ f"object with {discriminator} set to one of: {accepted} was expected",
155
+ value=value,
156
+ )
148
157
  if discriminant is None:
149
- raise ParserError("missing-union-discriminant")
158
+ raise ParserError(
159
+ f"missing-union-discriminant; set {discriminator} to one of: {accepted}"
160
+ )
150
161
  if not isinstance(discriminant, str):
151
162
  raise ParserError("union-discriminant-is-not-string", value=discriminant)
152
163
  parser = discriminator_map.get(discriminant)
153
164
  if parser is None:
154
- raise ParserError("missing-type-for-union-discriminant", value=discriminant)
165
+ raise ParserError(
166
+ f"missing-type-for-union-discriminant; {discriminator} must be one of: "
167
+ f"{accepted}",
168
+ value=discriminant,
169
+ )
155
170
  return parser(value)
156
171
 
157
172
  return parse
@@ -2,8 +2,6 @@ NON_DISCRIMINATED_UNION_EXCEPTIONS = [
2
2
  "audit_log.AuditLogDataValues",
3
3
  "generate_tool_parameters.UnionWithoutDiscrim",
4
4
  "output_calculation_entities.ConditionParameterFilterCondition",
5
- "output_parameters.AnalyticalMethodParameterOptions",
6
- "output_parameters.AnalyticalMethodLinkedOptionValue",
7
5
  "recipes_redirect.RecipesRedirectResult",
8
6
  "value_spec.ResolvedPathAll",
9
7
  "deprecated_calculation_types.DeprecatedWeightedSumEntitiesV0",
@@ -21,7 +21,7 @@ def load_profiles() -> list[job_definition_t.ProfileMetadata]:
21
21
  if entry.is_dir() and entry.name not in _IGNORED_PROFILE_FOLDERS
22
22
  ]
23
23
  profile_details: list[job_definition_t.ProfileMetadata] = []
24
- seen_job_ids: set[str] = set()
24
+ seen_ids: set[str] = set()
25
25
  for profile_file in profiles:
26
26
  profile_name = profile_file.name
27
27
  try:
@@ -30,9 +30,14 @@ def load_profiles() -> list[job_definition_t.ProfileMetadata]:
30
30
  resource="profile.yaml",
31
31
  )
32
32
  for job in definition.jobs:
33
- if job.id in seen_job_ids:
34
- raise Exception(f"multiple jobs with id {job.id}")
35
- seen_job_ids.add(job.id)
33
+ if job.id in seen_ids:
34
+ raise Exception(f"multiple jobs or services with id {job.id}")
35
+ seen_ids.add(job.id)
36
+ services = definition.services if definition.services is not None else []
37
+ for service in services:
38
+ if service.id in seen_ids:
39
+ raise Exception(f"multiple jobs or services with id {service.id}")
40
+ seen_ids.add(service.id)
36
41
 
37
42
  if definition.environments is not None:
38
43
  for integration_env in integration_envs:
@@ -42,6 +47,7 @@ def load_profiles() -> list[job_definition_t.ProfileMetadata]:
42
47
  job_definition_t.ProfileMetadata(
43
48
  name=profile_name,
44
49
  jobs=definition.jobs,
50
+ services=services,
45
51
  base_url=environment_config.base_url,
46
52
  integration_server_base_url=environment_config.integration_server_base_url,
47
53
  app_base_url=environment_config.app_base_url,
@@ -58,6 +64,7 @@ def load_profiles() -> list[job_definition_t.ProfileMetadata]:
58
64
  job_definition_t.ProfileMetadata(
59
65
  name=profile_name,
60
66
  jobs=definition.jobs,
67
+ services=services,
61
68
  base_url=definition.base_url,
62
69
  integration_server_base_url=definition.integration_server_base_url,
63
70
  app_base_url=definition.app_base_url,
@@ -19,6 +19,12 @@ from uncountable.integration.queue_runner.command_server import (
19
19
  )
20
20
  from uncountable.integration.queue_runner.queue_runner import start_queue_runner
21
21
  from uncountable.integration.queue_runner.types import RESTART_EXIT_CODE
22
+ from uncountable.integration.scan_profiles import load_profiles
23
+ from uncountable.integration.service_runner import (
24
+ ServiceToStart,
25
+ run_service,
26
+ select_enabled_services,
27
+ )
22
28
  from uncountable.integration.telemetry import Logger
23
29
 
24
30
  SHUTDOWN_TIMEOUT_SECS = 30
@@ -32,9 +38,30 @@ class ProcessName(StrEnum):
32
38
  UWSGI = "uwsgi"
33
39
 
34
40
 
41
+ @dataclass(frozen=True, kw_only=True)
42
+ class CoreProcessKey:
43
+ name: ProcessName
44
+
45
+ @property
46
+ def label(self) -> str:
47
+ return self.name
48
+
49
+
50
+ @dataclass(frozen=True, kw_only=True)
51
+ class ServiceProcessKey:
52
+ service_id: str
53
+
54
+ @property
55
+ def label(self) -> str:
56
+ return f"service:{self.service_id}"
57
+
58
+
59
+ ProcessKey = CoreProcessKey | ServiceProcessKey
60
+
61
+
35
62
  @dataclass(kw_only=True)
36
63
  class ProcessInfo:
37
- name: ProcessName
64
+ key: ProcessKey
38
65
  process: AnyProcess
39
66
 
40
67
  @property
@@ -77,16 +104,17 @@ def _wait_for_process(
77
104
  start = time.time()
78
105
  while time.time() - start < timeout_secs:
79
106
  if not proc_info.is_alive:
80
- logger.log_debug(f"{proc_info.name} shut down successfully")
107
+ logger.log_debug(f"{proc_info.key.label} shut down successfully")
81
108
  return True
82
109
  time.sleep(1)
83
110
  return False
84
111
 
85
112
 
86
- def handle_shutdown(logger: Logger, processes: dict[ProcessName, ProcessInfo]) -> None:
113
+ def handle_shutdown(logger: Logger, processes: dict[ProcessKey, ProcessInfo]) -> None:
87
114
  logger.log_debug("received shutdown command, shutting down sub-processes")
88
115
 
89
- cron = processes.get(ProcessName.CRON_SERVER)
116
+ cron_key = CoreProcessKey(name=ProcessName.CRON_SERVER)
117
+ cron = processes.get(cron_key)
90
118
  if cron is not None and cron.is_alive:
91
119
  logger.log_debug("stopping cron_server first to prevent new job submissions")
92
120
  cron.process.terminate()
@@ -94,7 +122,7 @@ def handle_shutdown(logger: Logger, processes: dict[ProcessName, ProcessInfo]) -
94
122
  logger.log_warning("cron_server did not stop in time, killing")
95
123
  cron.process.kill()
96
124
 
97
- remaining = [p for name, p in processes.items() if name != ProcessName.CRON_SERVER]
125
+ remaining = [p for key, p in processes.items() if key != cron_key]
98
126
  for proc_info in remaining:
99
127
  if proc_info.is_alive:
100
128
  proc_info.process.terminate()
@@ -106,70 +134,62 @@ def handle_shutdown(logger: Logger, processes: dict[ProcessName, ProcessInfo]) -
106
134
  ):
107
135
  logger.log_debug(
108
136
  "waiting for sub-processes to shut down",
109
- attributes={"still_living_processes": [p.name for p in still_living]},
137
+ attributes={"still_living_processes": [p.key.label for p in still_living]},
110
138
  )
111
139
  still_living = [p for p in still_living if p.is_alive]
112
140
  time.sleep(1)
113
141
 
114
142
  for proc_info in still_living:
115
143
  logger.log_warning(
116
- f"{proc_info.name} failed to shut down after {SHUTDOWN_TIMEOUT_SECS} seconds, forcefully terminating"
144
+ f"{proc_info.key.label} failed to shut down after {SHUTDOWN_TIMEOUT_SECS} seconds, forcefully terminating"
117
145
  )
118
146
  proc_info.process.kill()
119
147
 
120
148
 
121
- def restart_process(
122
- logger: Logger, proc_info: ProcessInfo, processes: dict[ProcessName, ProcessInfo]
123
- ) -> None:
124
- logger.log_error(
125
- f"process {proc_info.name} shut down unexpectedly - exit code {proc_info.exitcode}. Restarting..."
126
- )
127
-
128
- match proc_info.name:
129
- case ProcessName.QUEUE_RUNNER:
130
- queue_proc = multiprocessing.Process(target=start_queue_runner)
131
- queue_proc.start()
132
- new_info = ProcessInfo(name=ProcessName.QUEUE_RUNNER, process=queue_proc)
133
- processes[ProcessName.QUEUE_RUNNER] = new_info
134
- try:
135
- _wait_queue_runner_online()
136
- logger.log_debug("queue runner restarted successfully")
137
- except Exception as e:
138
- logger.log_exception(e)
139
- logger.log_error(
140
- "queue runner failed to restart, shutting down scheduler"
141
- )
142
- handle_shutdown(logger, processes)
143
- sys.exit(1)
149
+ class DeadProcessOutcome(StrEnum):
150
+ RESTART_REQUESTED = "restart_requested"
151
+ RESTART_AFTER_CRASH = "restart_after_crash"
152
+ SHUTDOWN_ALL = "shutdown_all"
144
153
 
145
- case ProcessName.CRON_SERVER:
146
- cron_proc = multiprocessing.Process(target=cron_target)
147
- cron_proc.start()
148
- new_info = ProcessInfo(name=ProcessName.CRON_SERVER, process=cron_proc)
149
- processes[ProcessName.CRON_SERVER] = new_info
150
- logger.log_debug("cron server restarted successfully")
151
154
 
152
- case ProcessName.UWSGI:
153
- uwsgi_proc: AnyProcess = subprocess.Popen(["uwsgi", "--die-on-term"])
154
- new_info = ProcessInfo(name=ProcessName.UWSGI, process=uwsgi_proc)
155
- processes[ProcessName.UWSGI] = new_info
156
- logger.log_debug("uwsgi restarted successfully")
155
+ def classify_dead_process(key: ProcessKey, exitcode: int | None) -> DeadProcessOutcome:
156
+ if exitcode == RESTART_EXIT_CODE:
157
+ return DeadProcessOutcome.RESTART_REQUESTED
158
+ match key:
159
+ case ServiceProcessKey():
160
+ return DeadProcessOutcome.RESTART_AFTER_CRASH
161
+ case CoreProcessKey():
162
+ return DeadProcessOutcome.SHUTDOWN_ALL
163
+ case _:
164
+ assert_never(key)
157
165
 
158
166
 
159
167
  def check_process_alarms(
160
- logger: Logger, processes: dict[ProcessName, ProcessInfo]
168
+ logger: Logger, processes: dict[ProcessKey, ProcessInfo]
161
169
  ) -> ProcessAlarm | None:
162
170
  for proc_info in processes.values():
163
- if not proc_info.is_alive:
164
- if proc_info.exitcode == RESTART_EXIT_CODE:
171
+ if proc_info.is_alive:
172
+ continue
173
+ outcome = classify_dead_process(proc_info.key, proc_info.exitcode)
174
+ match outcome:
175
+ case DeadProcessOutcome.RESTART_REQUESTED:
165
176
  logger.log_warning(
166
- f"process {proc_info.name} requested restart! restarting"
177
+ f"process {proc_info.key.label} requested restart! restarting"
167
178
  )
168
- return ProcessAlarmRestart(process=proc_info)
169
- logger.log_error(
170
- f"process {proc_info.name} shut down unexpectedly! shutting down scheduler; exit code is {proc_info.exitcode}"
171
- )
172
- return ProcessAlarmShutdownAll()
179
+ case DeadProcessOutcome.RESTART_AFTER_CRASH:
180
+ logger.log_error(
181
+ f"process {proc_info.key.label} exited unexpectedly with exit "
182
+ f"code {proc_info.exitcode}; cleaning up and restarting"
183
+ )
184
+ case DeadProcessOutcome.SHUTDOWN_ALL:
185
+ logger.log_error(
186
+ f"process {proc_info.key.label} shut down unexpectedly! shutting "
187
+ f"down scheduler; exit code is {proc_info.exitcode}"
188
+ )
189
+ return ProcessAlarmShutdownAll()
190
+ case _:
191
+ assert_never(outcome)
192
+ return ProcessAlarmRestart(process=proc_info)
173
193
  return None
174
194
 
175
195
 
@@ -194,20 +214,20 @@ def _wait_queue_runner_online() -> None:
194
214
 
195
215
  def main() -> None:
196
216
  logger = Logger(get_current_span())
197
- processes: dict[ProcessName, ProcessInfo] = {}
217
+ processes: dict[ProcessKey, ProcessInfo] = {}
198
218
 
199
219
  multiprocessing.set_start_method("forkserver")
200
220
 
201
221
  def add_process(process: ProcessInfo) -> None:
202
- processes[process.name] = process
203
- logger.log_debug(f"started process {process.name}")
222
+ processes[process.key] = process
223
+ logger.log_debug(f"started process {process.key.label}")
204
224
 
205
225
  def _start_queue_runner() -> None:
206
226
  runner_process = multiprocessing.Process(target=start_queue_runner)
207
227
  runner_process.start()
208
228
  add_process(
209
229
  ProcessInfo(
210
- name=ProcessName.QUEUE_RUNNER,
230
+ key=CoreProcessKey(name=ProcessName.QUEUE_RUNNER),
211
231
  process=runner_process,
212
232
  )
213
233
  )
@@ -219,17 +239,46 @@ def main() -> None:
219
239
  handle_shutdown(logger, processes=processes)
220
240
  return
221
241
 
242
+ def _start_service(service_to_start: ServiceToStart) -> None:
243
+ service_process = multiprocessing.Process(
244
+ target=run_service,
245
+ kwargs={
246
+ "service": service_to_start.service,
247
+ "profile_metadata": service_to_start.profile_metadata,
248
+ },
249
+ )
250
+ service_process.start()
251
+ add_process(
252
+ ProcessInfo(
253
+ key=ServiceProcessKey(service_id=service_to_start.service.id),
254
+ process=service_process,
255
+ )
256
+ )
257
+
222
258
  _start_queue_runner()
223
259
 
224
260
  cron_process = multiprocessing.Process(target=cron_target)
225
261
  cron_process.start()
226
- add_process(ProcessInfo(name=ProcessName.CRON_SERVER, process=cron_process))
262
+ add_process(
263
+ ProcessInfo(
264
+ key=CoreProcessKey(name=ProcessName.CRON_SERVER), process=cron_process
265
+ )
266
+ )
227
267
 
228
268
  uwsgi_process = subprocess.Popen([
229
269
  "uwsgi",
230
270
  "--die-on-term",
231
271
  ])
232
- add_process(ProcessInfo(name=ProcessName.UWSGI, process=uwsgi_process))
272
+ add_process(
273
+ ProcessInfo(key=CoreProcessKey(name=ProcessName.UWSGI), process=uwsgi_process)
274
+ )
275
+
276
+ services_by_id: dict[str, ServiceToStart] = {
277
+ service_to_start.service.id: service_to_start
278
+ for service_to_start in select_enabled_services(load_profiles())
279
+ }
280
+ for service_to_start in services_by_id.values():
281
+ _start_service(service_to_start)
233
282
 
234
283
  shutdown_requested = False
235
284
 
@@ -244,16 +293,25 @@ def main() -> None:
244
293
  process_alarm = check_process_alarms(logger, processes=processes)
245
294
  match process_alarm:
246
295
  case ProcessAlarmRestart():
247
- match process_alarm.process.name:
248
- case ProcessName.QUEUE_RUNNER:
249
- del processes[ProcessName.QUEUE_RUNNER]
250
- _start_queue_runner()
251
- case ProcessName.CRON_SERVER | ProcessName.UWSGI:
252
- raise NotImplementedError(
253
- f"restarting {process_alarm.process.name} not yet implemented"
254
- )
296
+ match process_alarm.process.key:
297
+ case CoreProcessKey() as core_key:
298
+ match core_key.name:
299
+ case ProcessName.QUEUE_RUNNER:
300
+ del processes[
301
+ CoreProcessKey(name=ProcessName.QUEUE_RUNNER)
302
+ ]
303
+ _start_queue_runner()
304
+ case ProcessName.CRON_SERVER | ProcessName.UWSGI:
305
+ raise NotImplementedError(
306
+ f"restarting {core_key.label} not yet implemented"
307
+ )
308
+ case _:
309
+ assert_never(core_key.name)
310
+ case ServiceProcessKey() as service_key:
311
+ del processes[service_key]
312
+ _start_service(services_by_id[service_key.service_id])
255
313
  case _:
256
- assert_never(process_alarm.process.name)
314
+ assert_never(process_alarm.process.key)
257
315
  case ProcessAlarmShutdownAll():
258
316
  handle_shutdown(logger, processes)
259
317
  sys.exit(1)
@@ -0,0 +1,74 @@
1
+ import importlib
2
+ import inspect
3
+ import os
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass
6
+
7
+ from uncountable.core.client import Client
8
+ from uncountable.integration.telemetry import Logger
9
+ from uncountable.types.job_definition_t import (
10
+ IntegrationService,
11
+ ProfileMetadata,
12
+ )
13
+
14
+ __all__ = [
15
+ "Service",
16
+ "ServiceArguments",
17
+ "register_service",
18
+ "resolve_service",
19
+ "resolve_service_class",
20
+ ]
21
+
22
+
23
+ @dataclass(kw_only=True)
24
+ class ServiceArguments:
25
+ service_definition: IntegrationService
26
+ profile_metadata: ProfileMetadata
27
+ client: Client
28
+ logger: Logger
29
+
30
+
31
+ class Service(ABC):
32
+ _unc_service_registered: bool = False
33
+
34
+ @abstractmethod
35
+ def run(self, args: ServiceArguments) -> None: ...
36
+
37
+
38
+ def register_service[ST: type[Service]](cls: ST) -> ST:
39
+ if not (isinstance(cls, type) and issubclass(cls, Service)):
40
+ raise TypeError(
41
+ f"@register_service can only be applied to Service subclasses, got {cls}"
42
+ )
43
+ cls._unc_service_registered = True
44
+ return cls
45
+
46
+
47
+ def resolve_service_class(
48
+ service: IntegrationService, profile_metadata: ProfileMetadata
49
+ ) -> type[Service]:
50
+ service_module_path = ".".join([
51
+ os.environ["UNC_PROFILES_MODULE"],
52
+ profile_metadata.name,
53
+ service.import_path,
54
+ ])
55
+ service_module = importlib.import_module(service_module_path)
56
+ found_services: list[type[Service]] = []
57
+ for _, service_class in inspect.getmembers(service_module, inspect.isclass):
58
+ if (
59
+ service_class.__module__ == service_module.__name__
60
+ and getattr(service_class, "_unc_service_registered", False)
61
+ and issubclass(service_class, Service)
62
+ ):
63
+ found_services.append(service_class)
64
+ assert len(found_services) == 1, (
65
+ f"expected exactly one registered service class in {service.import_path}, found {len(found_services)}"
66
+ )
67
+ return found_services[0]
68
+
69
+
70
+ def resolve_service(
71
+ service: IntegrationService, profile_metadata: ProfileMetadata
72
+ ) -> Service:
73
+ service_class = resolve_service_class(service, profile_metadata)
74
+ return service_class()
@@ -0,0 +1,44 @@
1
+ from dataclasses import dataclass
2
+
3
+ from uncountable.integration.construct_client import construct_uncountable_client
4
+ from uncountable.integration.service import ServiceArguments, resolve_service
5
+ from uncountable.integration.telemetry import Logger, get_otel_tracer
6
+ from uncountable.types.job_definition_t import IntegrationService, ProfileMetadata
7
+
8
+
9
+ @dataclass(kw_only=True)
10
+ class ServiceToStart:
11
+ profile_metadata: ProfileMetadata
12
+ service: IntegrationService
13
+
14
+
15
+ def select_enabled_services(
16
+ profiles: list[ProfileMetadata],
17
+ ) -> list[ServiceToStart]:
18
+ return [
19
+ ServiceToStart(profile_metadata=profile_metadata, service=service)
20
+ for profile_metadata in profiles
21
+ for service in profile_metadata.services
22
+ if service.enabled
23
+ ]
24
+
25
+
26
+ def run_service(
27
+ *, service: IntegrationService, profile_metadata: ProfileMetadata
28
+ ) -> None:
29
+ with get_otel_tracer().start_as_current_span(
30
+ name=f"run_service: {service.id}"
31
+ ) as span:
32
+ logger = Logger(span)
33
+ client = construct_uncountable_client(
34
+ profile_meta=profile_metadata, logger=logger
35
+ )
36
+ service_instance = resolve_service(service, profile_metadata)
37
+ service_instance.run(
38
+ ServiceArguments(
39
+ service_definition=service,
40
+ profile_metadata=profile_metadata,
41
+ client=client,
42
+ logger=logger,
43
+ )
44
+ )
@@ -28,6 +28,7 @@ __all__: list[str] = [
28
28
  @serial_string_enum(
29
29
  labels={
30
30
  "agent_memory": "Agent Memory",
31
+ "agent_persona": "Agent Persona",
31
32
  "aggregated_data_view": "Aggregated Data View",
32
33
  "analytical_method": "Analytical Method",
33
34
  "analytical_method_category": "Analytical Method Category",
@@ -64,6 +65,7 @@ __all__: list[str] = [
64
65
  "dataset_binding": "Dataset Binding",
65
66
  "default_permissions_new_project": "Default Permissions New Project",
66
67
  "document": "Document",
68
+ "document_mapping": "Document Mapping",
67
69
  "document_version": "Document Version",
68
70
  "doe_run": "Suggest Experiments",
69
71
  "draft_entity": "Draft Entity",
@@ -214,6 +216,7 @@ __all__: list[str] = [
214
216
  "regional_entity_group": "Regional Entity Group",
215
217
  "regulation": "Regulation",
216
218
  "regulation_cas": "Regulation CAS",
219
+ "regulation_source": "Regulation Source",
217
220
  "regulation_override": "Regulation Override",
218
221
  "regulation_product_category": "Regulation Product Category",
219
222
  "supplemental_regulation_ingredient": "Supplemental Regulation Ingredient",
@@ -310,6 +313,7 @@ __all__: list[str] = [
310
313
  )
311
314
  class EntityType(StrEnum):
312
315
  AGENT_MEMORY = "agent_memory"
316
+ AGENT_PERSONA = "agent_persona"
313
317
  AGGREGATED_DATA_VIEW = "aggregated_data_view"
314
318
  ANALYTICAL_METHOD = "analytical_method"
315
319
  ANALYTICAL_METHOD_CATEGORY = "analytical_method_category"
@@ -346,6 +350,7 @@ class EntityType(StrEnum):
346
350
  DATASET_BINDING = "dataset_binding"
347
351
  DEFAULT_PERMISSIONS_NEW_PROJECT = "default_permissions_new_project"
348
352
  DOCUMENT = "document"
353
+ DOCUMENT_MAPPING = "document_mapping"
349
354
  DOCUMENT_VERSION = "document_version"
350
355
  DOE_RUN = "doe_run"
351
356
  DRAFT_ENTITY = "draft_entity"
@@ -496,6 +501,7 @@ class EntityType(StrEnum):
496
501
  REGIONAL_ENTITY_GROUP = "regional_entity_group"
497
502
  REGULATION = "regulation"
498
503
  REGULATION_CAS = "regulation_cas"
504
+ REGULATION_SOURCE = "regulation_source"
499
505
  REGULATION_OVERRIDE = "regulation_override"
500
506
  REGULATION_PRODUCT_CATEGORY = "regulation_product_category"
501
507
  SUPPLEMENTAL_REGULATION_INGREDIENT = "supplemental_regulation_ingredient"
@@ -24,6 +24,7 @@ from .job_definition_t import CustomHttpJobDefinition as CustomHttpJobDefinition
24
24
  from .job_definition_t import WebhookJobDefinition as WebhookJobDefinition
25
25
  from .job_definition_t import UncountableWebhookJobDefinition as UncountableWebhookJobDefinition
26
26
  from .job_definition_t import JobDefinition as JobDefinition
27
+ from .job_definition_t import IntegrationService as IntegrationService
27
28
  from .job_definition_t import ProfileDefinition as ProfileDefinition
28
29
  from .job_definition_t import ProfileMetadata as ProfileMetadata
29
30
  from .job_definition_t import JobResult as JobResult
@@ -28,6 +28,7 @@ __all__: list[str] = [
28
28
  "GenericUploadDataSourceType",
29
29
  "HttpJobDefinitionBase",
30
30
  "HttpMethod",
31
+ "IntegrationService",
31
32
  "JobDefinition",
32
33
  "JobDefinitionBase",
33
34
  "JobDefinitionType",
@@ -272,6 +273,18 @@ JobDefinition = typing.Annotated[
272
273
  ]
273
274
 
274
275
 
276
+ # DO NOT MODIFY -- This file is generated by type_spec
277
+ @serial_class(
278
+ named_type_path="sdk.job_definition.IntegrationService",
279
+ )
280
+ @dataclasses.dataclass(slots=True, kw_only=True)
281
+ class IntegrationService:
282
+ id: str
283
+ name: str
284
+ import_path: str
285
+ enabled: bool = True
286
+
287
+
275
288
  # DO NOT MODIFY -- This file is generated by type_spec
276
289
  @serial_class(
277
290
  named_type_path="sdk.job_definition.ProfileDefinition",
@@ -285,6 +298,7 @@ class ProfileDefinition:
285
298
  app_base_url: str | None = None
286
299
  client_options: client_config_t.ClientConfigOptions | None = None
287
300
  environments: dict[integration_server_t.IntegrationEnvironment, integration_server_t.EnvironmentConfig] | None = None
301
+ services: list[IntegrationService] | None = None
288
302
 
289
303
 
290
304
  # DO NOT MODIFY -- This file is generated by type_spec
@@ -299,6 +313,7 @@ class ProfileMetadata:
299
313
  auth_retrieval: auth_retrieval_t.AuthRetrieval
300
314
  client_options: client_config_t.ClientConfigOptions | None
301
315
  jobs: list[JobDefinition]
316
+ services: list[IntegrationService]
302
317
  integration_server_base_url: str | None = None
303
318
 
304
319
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: UncountablePythonSDK
3
- Version: 0.0.186
3
+ Version: 0.0.187
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,8 +42,9 @@ examples/integration-server/jobs/materials_auto/example_instrument.py,sha256=XWy
42
42
  examples/integration-server/jobs/materials_auto/example_parse.py,sha256=Z3iqAUdI5ya07VS3XayjK8brKuAyaqXlImJMJIUHz_4,8660
43
43
  examples/integration-server/jobs/materials_auto/example_predictions.py,sha256=5fO4rqRa80_968A1uVZn2TlMOUib54A8rumGW02sIMM,2112
44
44
  examples/integration-server/jobs/materials_auto/example_runsheet_wh.py,sha256=7FWDz3QpueVcf83fPrwGSqxs9pX06iKcQFte_vz3dC8,2944
45
+ examples/integration-server/jobs/materials_auto/example_scale_connection.py,sha256=mz_FPxHPjIawIzJM2vvD_r78p_ZCYg8Mn8OpbphcwMU,5349
45
46
  examples/integration-server/jobs/materials_auto/example_wh.py,sha256=PN-skP27yJwDZboWk5g5EZEc3AKfVayQLfnopjsDKJc,659
46
- examples/integration-server/jobs/materials_auto/profile.yaml,sha256=qEgEunAcmuIIVsGCvNZ1sFJPfrefonN5r9BLTt5DIFg,2750
47
+ examples/integration-server/jobs/materials_auto/profile.yaml,sha256=Oga4GypqV4cBXwnOg7vGqDiu0Ha9KvRi555gBowzOI0,2888
47
48
  pkgs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
48
49
  pkgs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
50
  pkgs/argument_parser/__init__.py,sha256=cEEOAQS8UJ4Kbuo0oCVAHHhkbYCfrH6cUTTt2Mq3PmI,1445
@@ -54,7 +55,7 @@ pkgs/argument_parser/parser_builder.py,sha256=phtGw0dYTu1_9RrysZ4oNrkuHyodCbL9x6
54
55
  pkgs/argument_parser/parser_cache.py,sha256=VEJVJsMLgDsVNsRledh78VNxHAshJ1nDWvsgpDocFGs,863
55
56
  pkgs/argument_parser/parser_error.py,sha256=2DuYW-vQL4F1V_NEr-WW_ZdBPmH7L_uJiOuv7KhMLXw,2732
56
57
  pkgs/argument_parser/parser_function_type.py,sha256=xmsg0YVseYbUx52qC9vP-_m9F_HehtDHo84kFjI1gV4,125
57
- pkgs/argument_parser/parser_inner.py,sha256=0zepgTqQm6yFp9WMlHJmWV3S9WsxzRiN_nBwNfun23A,24937
58
+ pkgs/argument_parser/parser_inner.py,sha256=zO7Q-AfdbxLrwt0rRqytXtDVvij9sPmh-6_T6Te5elk,25499
58
59
  pkgs/argument_parser/parser_options.py,sha256=NAwNZSIH8EjyfJXJSxbn4kYmF3K7D3HuOedlSjjsF28,1332
59
60
  pkgs/argument_parser/type_predicates.py,sha256=CkQexiseN05XDz4e34K3gIOlER5LG5Ke8u0UHK4gC04,1157
60
61
  pkgs/filesystem_utils/__init__.py,sha256=b8NblpFX4rwyRSY3d8GGJRk9x3ET5jpsxDz0M2VnGlw,2071
@@ -99,7 +100,7 @@ pkgs/type_spec/emit_scss.py,sha256=6psmBAJyu7cvfQAIvIIMk2U6VFG_xeWy1Ea4afv0XyM,1
99
100
  pkgs/type_spec/emit_typescript.py,sha256=Qu9f1Yv_wc449KHcBGO58hatbzvPvgMbZ11jferi3oY,11567
100
101
  pkgs/type_spec/emit_typescript_util.py,sha256=dRiOm38M4DR5NtvJyaEoGXE8Zzrfnae6yuDC76OzCeU,14915
101
102
  pkgs/type_spec/load_types.py,sha256=k5hWxFvhj8aT_9vAwtkjn9OdudQFK9IIUzZI7-Gpx2Y,5277
102
- pkgs/type_spec/non_discriminated_union_exceptions.py,sha256=zfYHKhmq3Q7g6vB4tkno8pZj_F8enbSn_CjCJgJAu80,676
103
+ pkgs/type_spec/non_discriminated_union_exceptions.py,sha256=jgQ4Naz8A6FbhgfR3c9SI4IMvkkft0MT6CyQXWu9RB0,559
103
104
  pkgs/type_spec/open_api_util.py,sha256=LX6ny4xNOWXQO3qWVM8elrSDTqWsj_7jFplax2POShE,7992
104
105
  pkgs/type_spec/test.py,sha256=uXzUwMaHCShfTeilfAYZDV03mgF2z-ZHA_W5STp_0Ts,501
105
106
  pkgs/type_spec/util.py,sha256=Yc7QTZDjlu4ndOu3BwxLy5gRwG2mhnXN_s-iY9SYnIo,4834
@@ -138,9 +139,11 @@ uncountable/integration/cron.py,sha256=6eH-kIs3sdYPCyb62_L2M7U_uQTdMTdwY5hreEJb0
138
139
  uncountable/integration/entrypoint.py,sha256=seXMXxUPjBLzpP6UAB6fRVodkgGea6vnl2oy4FWvCyE,591
139
140
  uncountable/integration/job.py,sha256=Jeiiwvk-T3TxNYpesGbyTuQirw8gX1qQlmCIZ7r7Wuc,9187
140
141
  uncountable/integration/request_context.py,sha256=N_FJJxqvfUJ0yV9h3I3vFTGNJiDfyLYObczcYa44pw8,999
141
- uncountable/integration/scan_profiles.py,sha256=86CQgrQ-zXl3p41ERazzbzhTIIjIh_BprWVfYTSHHE4,3259
142
- uncountable/integration/scheduler.py,sha256=Z_CL2a-kOAJFpMWzFKtuvRz4AlCIRYbxWdN8fpM0RSQ,9150
142
+ uncountable/integration/scan_profiles.py,sha256=Fl8vr2jhW9BmnatLHZSvMnruUUXYgnXVVlbftYetY9g,3647
143
+ uncountable/integration/scheduler.py,sha256=YonK5CCA-eEIP5jjO-osIGPVwgTtVg9m_GNDp-B6-es,10706
143
144
  uncountable/integration/server.py,sha256=wnqpowr3xdAXfi4gCTdfFBvmQVyTro439kqrXewBauM,6002
145
+ uncountable/integration/service.py,sha256=i3GSrNu7QzMfK1jOIKLdJQCM5Tzp9eF5nfWldj6Cx-M,2144
146
+ uncountable/integration/service_runner.py,sha256=u9J90fh4CA9nluvfuTUaF6MhCJc2VKMD4Ahmk2x6Hzc,1435
144
147
  uncountable/integration/telemetry.py,sha256=W7z2h47Rrn7F3gF1aazbBdthNbSVi4dkQVbZFIs99-c,16155
145
148
  uncountable/integration/webhook_bootstrap.py,sha256=RlMsdfoWhsOX8eQCVz9XhyQLqfr0hPPwW6pdGhhmfeI,2332
146
149
  uncountable/integration/webhook_entity.py,sha256=As4x_W_zXuJ4uCAoVZwUKHPJILxP12ow0psPEv5sv9M,5220
@@ -208,7 +211,7 @@ uncountable/types/data_t.py,sha256=U_dIh5GcyaJlkrHEI4Qf4TOo1uJX07PYO70sYzzKtTo,2
208
211
  uncountable/types/dataset_measurements.py,sha256=CVwWePUC02B_R_UB86yfNBwnclR-5xpZaiCs2jWtu2A,512
209
212
  uncountable/types/dataset_measurements_t.py,sha256=iVwh-3Z23aXmPJcUJ3s9axPeOs627DElE_dhfj_PRlQ,1711
210
213
  uncountable/types/entity.py,sha256=Zclk1LYcRaYrMDhqyCjMSLEg0fE6_q8LHvV22Qvscgs,566
211
- uncountable/types/entity_t.py,sha256=sccVJH4AlvKklczgWaO3oE91Sam1ILgJpbtkOkhxVmM,30672
214
+ uncountable/types/entity_t.py,sha256=c-3qBHq9-bWoo1g0ghcg8v6q5OXPwwEmE7iEH5oDif8,30934
212
215
  uncountable/types/experiment_groups.py,sha256=qUpFOx1AKgzaT_4khCOv5Xs6jwiQGbvHH-GUh3v1nv4,288
213
216
  uncountable/types/experiment_groups_t.py,sha256=QjZBdjnbsD1D-VpPSSV6hBg-Q7mujjKvYtdMewtXNVU,712
214
217
  uncountable/types/exports.py,sha256=VMmxUO2PpV1Y63hZ2AnVor4H-B6aswJ7YpSru_u89lU,334
@@ -233,8 +236,8 @@ uncountable/types/integration_session.py,sha256=MVTtZa04INF4L8PxPjqz3l1Lse6Hze3I
233
236
  uncountable/types/integration_session_t.py,sha256=Nnes-u2RzvZYsybQsNhtBfbA3d2W_nuDjAS_mU6L6yM,1801
234
237
  uncountable/types/integrations.py,sha256=0fOhtbLIOl9w1GP9J3PTagRU8mjOKV48JNLLH3SJQP0,472
235
238
  uncountable/types/integrations_t.py,sha256=ZWwEP13mIkSiljSHN2p05XCwRgT2bdADs-8hiVQTs3I,1752
236
- uncountable/types/job_definition.py,sha256=s6hjlxlJgAyD9aDjV0EdAMc0zW91XCD8h7VBX9I9bIE,1956
237
- uncountable/types/job_definition_t.py,sha256=bGBvhDi0CIRmeKC5jGw5bb-LDBG53czZaZlYbgo5tuQ,9832
239
+ uncountable/types/job_definition.py,sha256=trVrCb3CHCl5zO-brA9sJonv41EITDBwK2Cy-274FUM,2027
240
+ uncountable/types/job_definition_t.py,sha256=zsQMsNjp_l6JN0zuRJ-hz54gHbQpc56kMsr1p-6xySg,10232
238
241
  uncountable/types/listing.py,sha256=U-0-opI0Wgxzmwp4y0qhyT26aK_BjaqdlFVpjud-ryk,2838
239
242
  uncountable/types/listing_t.py,sha256=z0hpUlburpIrVA5KcO1TRjkyAGGuAHvpHt5Fo6cAqIc,17521
240
243
  uncountable/types/ml_reports.py,sha256=qhZ36pbFLOcoET3D_K2DiNx59bjp8WyurbCrWPsZTrM,757
@@ -435,7 +438,7 @@ uncountable/types/api/uploader/complete_async_parse.py,sha256=Os1HAubxTlbut6Gylj
435
438
  uncountable/types/api/uploader/invoke_uploader.py,sha256=cmy7zl3AMF_AH5PCAB67rBeTE6JIcaUqPzm2NS4k_AE,1907
436
439
  uncountable/types/api/user/__init__.py,sha256=gCgbynxG3jA8FQHzercKtrHKHkiIKr8APdZYUniAor8,55
437
440
  uncountable/types/api/user/get_current_user_info.py,sha256=BT7bGp5SOHz3XdKWhhdZ6acnInafT2PgdJ-H4YH3W7c,1181
438
- uncountablepythonsdk-0.0.186.dist-info/METADATA,sha256=0oNUdR1xQijFWUrNokwwcoC6Zh4S-tqL2US-qv23lrM,2211
439
- uncountablepythonsdk-0.0.186.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
440
- uncountablepythonsdk-0.0.186.dist-info/top_level.txt,sha256=1UVGjAU-6hJY9qw2iJ7nCBeEwZ793AEN5ZfKX9A1uj4,31
441
- uncountablepythonsdk-0.0.186.dist-info/RECORD,,
441
+ uncountablepythonsdk-0.0.187.dist-info/METADATA,sha256=9o2-KrSVgmr2N07XLPaYD7ucwbKJOCcp3iWowNMiIqk,2211
442
+ uncountablepythonsdk-0.0.187.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
443
+ uncountablepythonsdk-0.0.187.dist-info/top_level.txt,sha256=1UVGjAU-6hJY9qw2iJ7nCBeEwZ793AEN5ZfKX9A1uj4,31
444
+ uncountablepythonsdk-0.0.187.dist-info/RECORD,,