iris-pex-embedded-python 4.0.0b15__py3-none-any.whl → 4.0.1__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.
iop/__init__.py CHANGED
@@ -125,8 +125,8 @@ class BusinessService(_BusinessService):
125
125
  production graph.
126
126
 
127
127
  Lifecycle:
128
- IRIS calls on_process_input(); the default implementation delegates to
129
- on_process_input(request).
128
+ IRIS calls on_process_input(); the default implementation delegates the
129
+ incoming request to on_message(request).
130
130
 
131
131
  Best practices:
132
132
  Declare outbound routes with target() and wire them in a Production
@@ -20,16 +20,13 @@ def _call_process_input(method, request):
20
20
 
21
21
 
22
22
  class _BusinessService(_BusinessHost):
23
- """This class is responsible for receiving the data from external system and sending it to business processes or business operations in the production.
24
- The business service can use an adapter to access the external system, which is specified in the InboundAdapter property.
25
- There are three ways of implementing a business service:
26
- 1) Polling business service with an adapter - The production framework at regular intervals calls the adapter's task method,
27
- which sends incoming data to the business service process input method.
28
- 2) Polling business service that uses the default adapter - In this case, the framework calls the default adapter's OnTask method with no data.
29
- The on_process_input() method then performs the role of the adapter and is responsible for accessing the external system and receiving the data.
30
- 3) Nonpolling business service - The production framework does not initiate the business service. Instead custom code in either a long-running process
31
- or one that is started at regular intervals initiates the business service through the Director API.
32
- """
23
+ """Runtime base for inbound production entry points.
24
+
25
+ IRIS invokes on_process_input(...). By default, that hook delegates to
26
+ on_message(...). Application code should normally subclass iop.BusinessService
27
+ or iop.PollingBusinessService and wire outbound routes with target() in a
28
+ Production graph.
29
+ """
33
30
 
34
31
  Adapter = adapter = None
35
32
  _wait_for_next_call_interval = False
@@ -79,13 +76,12 @@ class _BusinessService(_BusinessHost):
79
76
  return None
80
77
 
81
78
  def on_process_input(self, message_input=None):
82
- """Receives the message from the inbond adapter via the PRocessInput method and is responsible for forwarding it to target business processes or operations.
83
- If the business service does not specify an adapter, then the default adapter calls this method with no message
84
- and the business service is responsible for receiving the data from the external system and validating it.
79
+ """Handle IRIS ProcessInput and delegate to on_message(message_input).
85
80
 
86
- Parameters:
87
- message_input: an instance of IRISObject or subclass of Message containing the data that the inbound adapter passes in.
88
- The message can have any structure agreed upon by the inbound adapter and the business service.
81
+ Override this low-level hook only when an inbound adapter or Director
82
+ call needs custom ProcessInput handling. For simple services, override
83
+ on_message(...); for scheduled polling, use PollingBusinessService and
84
+ override on_poll().
89
85
  """
90
86
  return self.on_message(message_input)
91
87
 
iop/messages/dispatch.py CHANGED
@@ -1,3 +1,4 @@
1
+ import ast
1
2
  import logging
2
3
  from collections.abc import Callable
3
4
  from dataclasses import dataclass
@@ -36,6 +37,8 @@ _PICKLE_MESSAGE_CLASSES = {
36
37
  "IOP.Generator.Message.StartPickle",
37
38
  }
38
39
  _HANDLER_ATTRIBUTE = "__iop_handler_message__"
40
+ _IRIS_MODULE_PREFIX = "iris."
41
+ _UNRESOLVED = object()
39
42
 
40
43
 
41
44
  @dataclass(frozen=True)
@@ -60,7 +63,7 @@ def handler(message_type: Any) -> Callable[[Callable], Callable]:
60
63
  message_type: The message class, fully qualified class name string, or
61
64
  IRIS object instance this method handles.
62
65
  """
63
- message = _message_class_name(message_type)
66
+ message = _message_key_from_declared(message_type)
64
67
  if message is None:
65
68
  raise TypeError("handler() requires a message class or class name")
66
69
 
@@ -154,11 +157,10 @@ def dispatch_message(host: Any, request: Any) -> Any:
154
157
  """
155
158
  call = "on_message"
156
159
 
157
- module = request.__class__.__module__
158
- classname = request.__class__.__name__
160
+ message_names = _message_keys_from_request(request)
159
161
 
160
162
  for msg, method in host.DISPATCH:
161
- if msg == module + "." + classname:
163
+ if msg in message_names:
162
164
  return getattr(host, method)(request)
163
165
 
164
166
  return getattr(host, call)(request)
@@ -202,15 +204,24 @@ def create_dispatch(host: Any) -> None:
202
204
 
203
205
  def _declared_dispatch(host: Any) -> list[tuple[str, str]]:
204
206
  if "DISPATCH" in getattr(host, "__dict__", {}):
205
- return list(host.__dict__["DISPATCH"])
207
+ return _normalized_dispatch(host.__dict__["DISPATCH"])
206
208
 
207
209
  class_dispatch = host.__class__.__dict__.get("DISPATCH")
208
210
  if class_dispatch is not None:
209
- return list(class_dispatch)
211
+ return _normalized_dispatch(class_dispatch)
210
212
 
211
213
  return []
212
214
 
213
215
 
216
+ def _normalized_dispatch(dispatch: Any) -> list[tuple[str, str]]:
217
+ entries = []
218
+ for message, method in dispatch:
219
+ key = _message_key_from_declared(message)
220
+ if key is not None:
221
+ entries.append((key, method))
222
+ return entries
223
+
224
+
214
225
  def _decorated_dispatch(host: Any) -> list[tuple[str, str]]:
215
226
  dispatch = []
216
227
  for method_name in dir(host):
@@ -306,13 +317,9 @@ def get_handler_info(host: Any, method_name: str) -> tuple[str, str] | None:
306
317
  if len(params) != 1:
307
318
  return None
308
319
 
320
+ method = getattr(host, method_name)
309
321
  param: Parameter = next(iter(params.values()))
310
- annotation = param.annotation
311
-
312
- if annotation == Parameter.empty:
313
- return None
314
-
315
- message = _message_class_name(annotation)
322
+ message = _message_key_from_annotation(host, method, param.annotation)
316
323
  if message is None:
317
324
  return None
318
325
 
@@ -322,12 +329,63 @@ def get_handler_info(host: Any, method_name: str) -> tuple[str, str] | None:
322
329
  return None
323
330
 
324
331
 
325
- def _message_class_name(message_type: Any) -> str | None:
332
+ def _message_key_from_annotation(
333
+ host: Any, method: Callable, annotation: Any
334
+ ) -> str | None:
335
+ if annotation == Parameter.empty:
336
+ return None
337
+
338
+ if not isinstance(annotation, str):
339
+ return _message_key_from_declared(annotation, allow_bare_string=False)
340
+
341
+ value: Any = annotation
342
+ for _ in range(3):
343
+ if not isinstance(value, str):
344
+ return _message_key_from_declared(value, allow_bare_string=False)
345
+
346
+ key = _message_key_from_string(value, allow_bare=False)
347
+ if key is not None:
348
+ return key
349
+
350
+ unquoted = _unquote_string(value)
351
+ if unquoted is not None and unquoted != value:
352
+ value = unquoted
353
+ continue
354
+
355
+ value = _lookup_annotation_name(host, method, value)
356
+ if value is _UNRESOLVED:
357
+ return None
358
+
359
+ return None
360
+
361
+
362
+ def _message_keys_from_request(request: Any) -> tuple[str, ...]:
363
+ module = request.__class__.__module__
364
+ classname = request.__class__.__name__
365
+ names = [f"{module}.{classname}"]
366
+
367
+ if module.startswith("iris"):
368
+ native_name = _native_iris_message_key(request)
369
+ if native_name:
370
+ names.append(native_name)
371
+ names.append(f"{_IRIS_MODULE_PREFIX}{native_name}")
372
+
373
+ return tuple(dict.fromkeys(names))
374
+
375
+
376
+ def _message_key_from_declared(
377
+ message_type: Any, *, allow_bare_string: bool = True
378
+ ) -> str | None:
326
379
  if isinstance(message_type, str):
327
- return message_type
380
+ return _message_key_from_string(
381
+ message_type,
382
+ allow_bare=allow_bare_string,
383
+ )
328
384
 
329
385
  if is_iris_object_instance(message_type):
330
- return f"{type(message_type).__module__}.{type(message_type).__name__}"
386
+ return _native_iris_message_key(message_type) or _python_class_key(
387
+ type(message_type)
388
+ )
331
389
 
332
390
  if message_type is Any or message_type is object:
333
391
  return None
@@ -338,7 +396,70 @@ def _message_class_name(message_type: Any) -> str | None:
338
396
  if not _is_dispatch_message_class(message_type):
339
397
  return None
340
398
 
341
- return f"{message_type.__module__}.{message_type.__name__}"
399
+ return _python_class_key(message_type)
400
+
401
+
402
+ def _message_key_from_string(value: str, *, allow_bare: bool) -> str | None:
403
+ if not value or _unquote_string(value) is not None:
404
+ return None
405
+
406
+ normalized = _strip_iris_prefix(value)
407
+ if "." in normalized:
408
+ return normalized if _looks_like_message_key(normalized) else None
409
+
410
+ return normalized if allow_bare else None
411
+
412
+
413
+ def _native_iris_message_key(message: Any) -> str | None:
414
+ iris_classname = get_iris_object_classname(message)
415
+ if not iris_classname:
416
+ return None
417
+ return _strip_iris_prefix(iris_classname)
418
+
419
+
420
+ def _python_class_key(klass: type) -> str:
421
+ return f"{klass.__module__}.{klass.__name__}"
422
+
423
+
424
+ def _strip_iris_prefix(value: str) -> str:
425
+ if value.startswith(_IRIS_MODULE_PREFIX):
426
+ return value[len(_IRIS_MODULE_PREFIX) :]
427
+ return value
428
+
429
+
430
+ def _looks_like_message_key(value: str) -> bool:
431
+ return not any(character in value for character in "'\"[](){} ,:")
432
+
433
+
434
+ def _unquote_string(value: str) -> str | None:
435
+ try:
436
+ literal = ast.literal_eval(value)
437
+ except (SyntaxError, ValueError):
438
+ return None
439
+ return literal if isinstance(literal, str) else None
440
+
441
+
442
+ def _lookup_annotation_name(host: Any, method: Callable, name: str) -> Any:
443
+ if not name.isidentifier():
444
+ return _UNRESOLVED
445
+
446
+ localns = _annotation_localns(host)
447
+ if name in localns:
448
+ return localns[name]
449
+
450
+ return _annotation_globalns(method).get(name, _UNRESOLVED)
451
+
452
+
453
+ def _annotation_globalns(method: Callable) -> dict[str, Any]:
454
+ function = getattr(method, "__func__", method)
455
+ return getattr(function, "__globals__", {})
456
+
457
+
458
+ def _annotation_localns(host: Any) -> dict[str, Any]:
459
+ namespace: dict[str, Any] = {}
460
+ for klass in reversed(type(host).__mro__):
461
+ namespace.update(vars(klass))
462
+ return namespace
342
463
 
343
464
 
344
465
  def _is_dispatch_message_class(klass: type) -> bool:
iop/migration/utils.py CHANGED
@@ -193,8 +193,11 @@ def register_component(
193
193
  overwrite: int = 1,
194
194
  iris_classname: str = "Python",
195
195
  ):
196
- """
197
- It registers a component in the Iris database.
196
+ """Register a Python component as an IRIS proxy class.
197
+
198
+ Prefer a Python Production graph in PRODUCTIONS for new application
199
+ components. Use register_component() directly for standalone bindings,
200
+ legacy migration helpers, or manual proxy registration only.
198
201
 
199
202
  :param module: The name of the module that contains the class
200
203
  :type module: str
@@ -239,8 +242,11 @@ def bind_component(
239
242
  overwrite: int = 1,
240
243
  iris_classname: str = "Python",
241
244
  ):
242
- """
243
- Public alias for registering a Python component as an IRIS proxy class.
245
+ """Public alias for register_component().
246
+
247
+ Prefer a Python Production graph in PRODUCTIONS for new application
248
+ components. Use bind_component() directly only for standalone bindings,
249
+ legacy migration helpers, or manual proxy registration.
244
250
  """
245
251
  return register_component(module, classname, path, overwrite, iris_classname)
246
252
 
@@ -436,18 +442,12 @@ def migrate(
436
442
  namespace: str | None = None,
437
443
  strict_production_validation: bool = False,
438
444
  ):
439
- """
440
- Read the settings.py file and register all the components
441
- settings.py file has two dictionaries:
442
- * CLASSES
443
- * key: the name of the class
444
- * value: an instance of the class
445
- * PRODUCTIONS
446
- list of dictionaries:
447
- * key: the name of the production
448
- * value: a dictionary containing the settings for the production
449
- * SCHEMAS
450
- List of classes
445
+ """Read a migration file and apply its registrations to IRIS.
446
+
447
+ New Python-authored applications should normally export Production objects
448
+ through PRODUCTIONS. CLASSES remains available for standalone bindings,
449
+ native PersistentMessage classes, and legacy migration files. SCHEMAS
450
+ registers Message or PydanticMessage schemas for DTL support.
451
451
  """
452
452
  settings, path = _load_settings(filename)
453
453
 
@@ -546,7 +546,19 @@ def _build_migration_manifest(
546
546
 
547
547
 
548
548
  def _load_settings(filename):
549
- """Load settings module from file or default location.
549
+ """Load a migration settings module.
550
+
551
+ Purpose:
552
+ Resolve settings.py as a file-based module and keep imports local to the
553
+ settings file directory for this process.
554
+
555
+ Best practices:
556
+ Keep modules imported by settings.py in the same project/package rooted
557
+ near the settings file. Let IoP manage temporary import path setup.
558
+
559
+ Common mistakes:
560
+ Do not require users to set PYTHONPATH. Do not mutate environment
561
+ variables to force imports when module/package layout should be fixed.
550
562
 
551
563
  Returns:
552
564
  tuple: (settings_module, path_added_to_sys)
@@ -723,6 +735,20 @@ def _cleanup_sys_path(path):
723
735
 
724
736
 
725
737
  def import_module_from_path(module_name, file_path):
738
+ """Import one module from an absolute file path.
739
+
740
+ Purpose:
741
+ Execute a specific settings or component module by file location
742
+ without relying on global PYTHONPATH configuration.
743
+
744
+ Best practices:
745
+ Use absolute paths and stable module/package layout so imports resolve
746
+ from the project directory containing settings.py.
747
+
748
+ Common mistakes:
749
+ Do not patch PYTHONPATH or sys.path globally to make imports pass.
750
+ Keep import fixes in project structure and import statements.
751
+ """
726
752
  if not os.path.isabs(file_path):
727
753
  raise ValueError("The file path must be absolute")
728
754
 
iop/production/common.py CHANGED
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  import importlib
4
4
  from typing import Any
5
5
 
6
+ from ..components.settings import Setting
7
+
6
8
  PRODUCTION_SETTING_FIELDS: dict[str, tuple[str, Any]] = {
7
9
  "shutdown_timeout": ("ShutdownTimeout", 120),
8
10
  "update_timeout": ("UpdateTimeout", 10),
@@ -83,11 +85,21 @@ def _adapter_type_from_class_name(class_name: str | None) -> str:
83
85
  return _adapter_type_from_component_class(component_class)
84
86
 
85
87
 
88
+ def _setting_name(name: Any) -> str:
89
+ if isinstance(name, Setting) and name.name:
90
+ return name.name
91
+ return str(name)
92
+
93
+
94
+ def _normalize_settings_mapping(values: Any) -> dict[str, Any]:
95
+ return {_setting_name(key): value for key, value in dict(values or {}).items()}
96
+
97
+
86
98
  def _settings_to_iris(target_name: str, values: dict[str, Any]) -> list[dict[str, str]]:
87
99
  return [
88
100
  {
89
101
  "@Target": target_name,
90
- "@Name": name,
102
+ "@Name": _setting_name(name),
91
103
  "#text": _text_value(value),
92
104
  }
93
105
  for name, value in values.items()
@@ -97,6 +109,7 @@ def _settings_to_iris(target_name: str, values: dict[str, Any]) -> list[dict[str
97
109
  def _apply_settings_update(target: dict[str, Any], updates: Any) -> None:
98
110
  """Merge *updates* into *target*, treating ``None`` values as removals."""
99
111
  for key, value in (updates or {}).items():
112
+ key = _setting_name(key)
100
113
  if value is None:
101
114
  target.pop(key, None)
102
115
  else:
@@ -66,6 +66,13 @@ class ComponentRef:
66
66
  return self.target_setting(name)
67
67
  raise AttributeError(name)
68
68
 
69
+ def __dir__(self) -> list[str]:
70
+ names = set(super().__dir__())
71
+ if self.component_class is not None:
72
+ names.update(_target_setting_names(self.component_class))
73
+ names.update(self.target_setting_names)
74
+ return sorted(names)
75
+
69
76
  def target_setting(self, name: str) -> TargetSettingRef:
70
77
  self.target_setting_names.add(name)
71
78
  return TargetSettingRef(
@@ -161,7 +168,7 @@ class ComponentRef:
161
168
 
162
169
  def connect(
163
170
  self,
164
- target_setting: str | TargetSettingRef,
171
+ target_setting: str | TargetSetting | TargetSettingRef,
165
172
  target_component: ComponentRef | str | None = None,
166
173
  **kwargs: Any,
167
174
  ) -> ComponentRef:
@@ -171,7 +178,7 @@ class ComponentRef:
171
178
 
172
179
  def _coerce_target_setting_ref(
173
180
  self,
174
- target_setting: str | TargetSettingRef,
181
+ target_setting: str | TargetSetting | TargetSettingRef,
175
182
  ) -> TargetSettingRef:
176
183
  if isinstance(target_setting, TargetSettingRef):
177
184
  if target_setting.production is not self.production:
@@ -183,6 +190,28 @@ class ComponentRef:
183
190
  "source target setting belongs to a different component"
184
191
  )
185
192
  return target_setting
193
+ if isinstance(target_setting, TargetSetting):
194
+ owner = target_setting.owner
195
+ if owner is None or not target_setting.name:
196
+ raise ValueError(
197
+ "target setting must be declared on a component class"
198
+ )
199
+ if self.component_class is None or not issubclass(
200
+ self.component_class,
201
+ owner,
202
+ ):
203
+ owner_name = f"{owner.__module__}.{owner.__qualname__}"
204
+ component_name = (
205
+ f"{self.component_class.__module__}."
206
+ f"{self.component_class.__qualname__}"
207
+ if self.component_class is not None
208
+ else self.class_name
209
+ )
210
+ raise ValueError(
211
+ f"target setting {target_setting.name!r} belongs to "
212
+ f"{owner_name}, not {component_name}"
213
+ )
214
+ return self.target_setting(target_setting.name)
186
215
  return self.target_setting(str(target_setting))
187
216
 
188
217
  def inspect(self, *, refresh: bool = True) -> dict[str, Any]:
@@ -232,3 +261,12 @@ class ComponentRef:
232
261
  item["Setting"] = settings
233
262
 
234
263
  return item
264
+
265
+
266
+ def _target_setting_names(component_class: type) -> tuple[str, ...]:
267
+ names: dict[str, None] = {}
268
+ for base in reversed(component_class.__mro__):
269
+ for name, value in base.__dict__.items():
270
+ if isinstance(value, TargetSetting):
271
+ names[name] = None
272
+ return tuple(names)
@@ -2,9 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  from collections.abc import Iterable, Mapping
4
4
  from dataclasses import dataclass, field
5
- from typing import Any, ClassVar, Protocol
5
+ from typing import Any, ClassVar, Protocol, cast
6
6
 
7
- from .common import SETTING_NAME_ALIASES
7
+ from .common import SETTING_NAME_ALIASES, _normalize_settings_mapping
8
8
  from .types import TargetSetting
9
9
 
10
10
 
@@ -36,7 +36,9 @@ class Route:
36
36
  targets = (self.targets,)
37
37
  else:
38
38
  try:
39
- targets = tuple(self.targets)
39
+ targets = tuple(
40
+ cast(Iterable[str | _NamedRouteTarget], self.targets)
41
+ )
40
42
  except TypeError as exc:
41
43
  raise TypeError(
42
44
  f"Route {self.target_setting_name!r} targets must be an item name, "
@@ -174,7 +176,7 @@ def _mapping(
174
176
  item_name: str,
175
177
  ) -> dict[str, Any]:
176
178
  try:
177
- return dict(values or {})
179
+ return _normalize_settings_mapping(values)
178
180
  except (TypeError, ValueError) as exc:
179
181
  raise TypeError(
180
182
  f"Production item {item_name!r} {field_name} must be a mapping"
iop/production/model.py CHANGED
@@ -9,6 +9,7 @@ from .common import (
9
9
  _adapter_type_from_component_class,
10
10
  _apply_settings_update,
11
11
  _auto_proxy_class_name,
12
+ _normalize_settings_mapping,
12
13
  )
13
14
  from .component import ComponentRef
14
15
  from .declarative import _DeclarativeProductionMixin
@@ -34,6 +35,7 @@ from .types import (
34
35
  PersistentMessageRegistration,
35
36
  ProductionDiff,
36
37
  ProductionGraph,
38
+ TargetSetting,
37
39
  TargetSettingRef,
38
40
  _edge_identity,
39
41
  )
@@ -218,10 +220,10 @@ class Production(_DeclarativeProductionMixin):
218
220
 
219
221
  def _resolve_production_name(self, name: str | object) -> str:
220
222
  if name is not _MISSING:
221
- return name # type: ignore[return-value]
223
+ return str(name)
222
224
  class_name = getattr(type(self), "name", _MISSING)
223
225
  if class_name is not _MISSING:
224
- return class_name
226
+ return str(class_name)
225
227
  return f"{type(self).__module__}.{type(self).__name__}"
226
228
 
227
229
  def _production_default(
@@ -431,8 +433,8 @@ class Production(_DeclarativeProductionMixin):
431
433
  comment=comment,
432
434
  log_trace_events=log_trace_events,
433
435
  schedule=schedule,
434
- host_settings=dict(settings or {}),
435
- adapter_settings=dict(adapter_settings or {}),
436
+ host_settings=_normalize_settings_mapping(settings),
437
+ adapter_settings=_normalize_settings_mapping(adapter_settings),
436
438
  )
437
439
  self._add_item(ref)
438
440
  return ref
@@ -569,7 +571,12 @@ class Production(_DeclarativeProductionMixin):
569
571
  )
570
572
  ]
571
573
 
572
- def service(self, name_or_cls: str | type, cls: type | None = None, **kwargs):
574
+ def service(
575
+ self,
576
+ name_or_cls: str | type,
577
+ cls: type | None = None,
578
+ **kwargs: Any,
579
+ ) -> ComponentRef:
573
580
  """Add a BusinessService item to the production graph.
574
581
 
575
582
  See docs/cookbooks/hello-world-production.md and
@@ -577,14 +584,24 @@ class Production(_DeclarativeProductionMixin):
577
584
  """
578
585
  return self.component(name_or_cls, cls, kind="service", **kwargs)
579
586
 
580
- def process(self, name_or_cls: str | type, cls: type | None = None, **kwargs):
587
+ def process(
588
+ self,
589
+ name_or_cls: str | type,
590
+ cls: type | None = None,
591
+ **kwargs: Any,
592
+ ) -> ComponentRef:
581
593
  """Add a BusinessProcess item to the production graph.
582
594
 
583
595
  See docs/cookbooks/add-business-process.md.
584
596
  """
585
597
  return self.component(name_or_cls, cls, kind="process", **kwargs)
586
598
 
587
- def operation(self, name_or_cls: str | type, cls: type | None = None, **kwargs):
599
+ def operation(
600
+ self,
601
+ name_or_cls: str | type,
602
+ cls: type | None = None,
603
+ **kwargs: Any,
604
+ ) -> ComponentRef:
588
605
  """Add a BusinessOperation item to the production graph.
589
606
 
590
607
  See docs/cookbooks/add-business-operation.md.
@@ -649,6 +666,12 @@ class Production(_DeclarativeProductionMixin):
649
666
  mode: str = "replace",
650
667
  ) -> None:
651
668
  if not isinstance(source, TargetSettingRef):
669
+ if isinstance(source, TargetSetting):
670
+ raise TypeError(
671
+ "source must be bound to a production item. Use "
672
+ "component.connect(ComponentClass.Target, target_component) "
673
+ "or prod.connect(component.Target, target_component)."
674
+ )
652
675
  raise TypeError(
653
676
  "source must be a TargetSettingRef returned from a ComponentRef"
654
677
  )
@@ -1055,6 +1078,35 @@ class Production(_DeclarativeProductionMixin):
1055
1078
  )
1056
1079
  self._items.append(ref)
1057
1080
  self._items_by_name[ref.name] = ref
1081
+ self._apply_target_defaults()
1082
+
1083
+ def _apply_target_defaults(self) -> None:
1084
+ for ref in self._items:
1085
+ for setting_name, target_name in self._target_default_routes(ref):
1086
+ if setting_name not in ref.host_settings:
1087
+ if setting_name in ref.target_setting_names:
1088
+ continue
1089
+ ref.host_settings[setting_name] = target_name
1090
+ ref.target_setting_names.add(setting_name)
1091
+ if str(ref.host_settings[setting_name]) != target_name:
1092
+ continue
1093
+ if target_name not in self._items_by_name:
1094
+ continue
1095
+ self._register_connection(
1096
+ ref.name,
1097
+ setting_name,
1098
+ target_name,
1099
+ )
1100
+
1101
+ def _target_default_routes(self, ref: ComponentRef) -> tuple[tuple[str, str], ...]:
1102
+ if ref.component_class is None:
1103
+ return ()
1104
+ routes: list[tuple[str, str]] = []
1105
+ for setting_name, descriptor in _target_settings(ref.component_class):
1106
+ target_name = _target_default_name(descriptor.default)
1107
+ if target_name:
1108
+ routes.append((setting_name, target_name))
1109
+ return tuple(routes)
1058
1110
 
1059
1111
  def _diff_current(
1060
1112
  self,
@@ -1141,3 +1193,21 @@ def _connection_mode(mode: str) -> str:
1141
1193
  "Unsupported connection mode: "
1142
1194
  f"{mode!r}. Expected 'replace', 'add', or 'remove'."
1143
1195
  )
1196
+
1197
+
1198
+ def _target_settings(
1199
+ component_class: type,
1200
+ ) -> tuple[tuple[str, TargetSetting], ...]:
1201
+ descriptors: dict[str, TargetSetting] = {}
1202
+ for base in reversed(component_class.__mro__):
1203
+ for name, value in base.__dict__.items():
1204
+ if isinstance(value, TargetSetting):
1205
+ descriptors[name] = value
1206
+ return tuple(descriptors.items())
1207
+
1208
+
1209
+ def _target_default_name(value: Any) -> str:
1210
+ if value is None:
1211
+ return ""
1212
+ name = getattr(value, "name", value)
1213
+ return str(name).strip()
@@ -465,9 +465,7 @@ def _connection_lines(production, variables: dict[str, str], item_names: set[str
465
465
  for source_item, source_target_setting in sorted(grouped):
466
466
  targets = grouped[(source_item, source_target_setting)]
467
467
  source_var = variables[source_item]
468
- source_expr = (
469
- f"{source_var}.target_setting({_literal(source_target_setting)})"
470
- )
468
+ source_expr = _literal(source_target_setting)
471
469
  valid_targets = [target for target in targets if target in variables]
472
470
  invalid_targets = [target for target in targets if target not in variables]
473
471
  for target in invalid_targets:
@@ -476,15 +474,16 @@ def _connection_lines(production, variables: dict[str, str], item_names: set[str
476
474
  continue
477
475
  if len(valid_targets) == 1:
478
476
  lines.append(
479
- f"prod.connect({source_expr}, {variables[valid_targets[0]]})"
477
+ f"{source_var}.connect({source_expr}, {variables[valid_targets[0]]})"
480
478
  )
481
479
  else:
482
480
  lines.append(
483
- f"prod.connect({source_expr}, {variables[valid_targets[0]]})"
481
+ f"{source_var}.connect({source_expr}, {variables[valid_targets[0]]})"
484
482
  )
485
483
  for target in valid_targets[1:]:
486
484
  lines.append(
487
- f"prod.connect({source_expr}, {variables[target]}, mode='add')"
485
+ f"{source_var}.connect({source_expr}, {variables[target]}, "
486
+ "mode='add')"
488
487
  )
489
488
 
490
489
  for edge in production.edges:
iop/production/types.py CHANGED
@@ -12,11 +12,11 @@ from ..components.settings import Category, Setting, controls
12
12
  class TargetSetting(Setting):
13
13
  """Production target setting descriptor created by target()."""
14
14
 
15
- def __init__(self, **kwargs: Any):
15
+ def __init__(self, default: str = "", **kwargs: Any):
16
16
  kwargs.setdefault("iris_type", "Ens.DataType.ConfigName")
17
17
  kwargs.setdefault("category", Category.BASIC)
18
18
  kwargs.setdefault("control", controls.production_item())
19
- super().__init__("", **kwargs)
19
+ super().__init__(default, **kwargs)
20
20
 
21
21
  @overload
22
22
  def __get__(self, instance: None, owner: type | None = None) -> TargetSetting: ...
@@ -34,13 +34,16 @@ class TargetSetting(Setting):
34
34
  return super().__get__(instance, owner)
35
35
 
36
36
 
37
- def target(**kwargs: Any) -> TargetSetting:
37
+ def target(default: str = "", **kwargs: Any) -> TargetSetting:
38
38
  """Purpose:
39
39
  Declare a configurable outbound target setting on a component class.
40
40
 
41
41
  Use when:
42
42
  A service or process sends messages to another production component.
43
43
 
44
+ Pass a target item name as the first argument when the route should have
45
+ a default target even without an explicit Production.connect(...) call.
46
+
44
47
  Lifecycle:
45
48
  The descriptor becomes an IRIS production setting. Production.connect(...)
46
49
  binds it to a concrete target component.
@@ -59,11 +62,14 @@ def target(**kwargs: Any) -> TargetSetting:
59
62
 
60
63
  prod.connect(router.Output, operation)
61
64
 
65
+ class DefaultRouter(BusinessProcess):
66
+ Output = target("DefaultOperation")
67
+
62
68
  Related:
63
69
  docs/cookbooks/production-settings-and-targets.md,
64
70
  docs/production-graph.md
65
71
  """
66
- return TargetSetting(**kwargs)
72
+ return TargetSetting(default, **kwargs)
67
73
 
68
74
 
69
75
  @dataclass
@@ -8,8 +8,10 @@ from .common import (
8
8
  PRODUCTION_SETTING_FIELDS_BY_IRIS,
9
9
  PRODUCTION_SETTING_NAMES,
10
10
  SETTING_NAME_ALIASES,
11
+ _normalize_settings_mapping,
11
12
  )
12
13
  from .import_ import _as_list, _production_payload, _split_settings
14
+ from .types import TargetSetting
13
15
 
14
16
  _PRODUCTION_PUBLIC_FIELDS = {
15
17
  "name",
@@ -151,6 +153,8 @@ def _build_production_object_report(production: Any) -> ProductionValidationRepo
151
153
  for item in getattr(production, "items", ()):
152
154
  issues.extend(_validate_component_ref(item))
153
155
 
156
+ issues.extend(_validate_target_setting_values(production))
157
+
154
158
  return ProductionValidationReport(production_name, tuple(issues))
155
159
 
156
160
 
@@ -180,14 +184,18 @@ def _validate_component_ref(item: Any) -> list[ProductionValidationIssue]:
180
184
 
181
185
  issues.extend(
182
186
  _validate_settings(
183
- settings=dict(getattr(item, "host_settings", {}) or {}),
187
+ settings=_normalize_settings_mapping(
188
+ getattr(item, "host_settings", {}) or {}
189
+ ),
184
190
  path_prefix=f"items.{item_name}.settings.Host",
185
191
  local_class=component_class,
186
192
  iris_class_name=class_name,
187
193
  )
188
194
  )
189
195
 
190
- adapter_settings = dict(getattr(item, "adapter_settings", {}) or {})
196
+ adapter_settings = _normalize_settings_mapping(
197
+ getattr(item, "adapter_settings", {}) or {}
198
+ )
191
199
  if str(getattr(item, "kind", "")) == "process" and adapter_settings:
192
200
  issues.append(
193
201
  ProductionValidationIssue(
@@ -334,9 +342,67 @@ def _local_setting_names(cls: type) -> set[str] | None:
334
342
  if not callable(getter):
335
343
  return None
336
344
  try:
337
- return {str(prop[0]) for prop in getter() if prop}
345
+ properties = getter()
338
346
  except Exception:
339
347
  return None
348
+ if not isinstance(properties, (list, tuple)):
349
+ return None
350
+ names: set[str] = set()
351
+ for prop in properties:
352
+ if isinstance(prop, (list, tuple)) and prop:
353
+ names.add(str(prop[0]))
354
+ return names
355
+
356
+
357
+ def _validate_target_setting_values(production: Any) -> list[ProductionValidationIssue]:
358
+ items = tuple(getattr(production, "items", ()) or ())
359
+ item_names = {str(getattr(item, "name", "")) for item in items}
360
+ issues: list[ProductionValidationIssue] = []
361
+ for item in items:
362
+ item_name = str(getattr(item, "name", ""))
363
+ host_settings = _normalize_settings_mapping(
364
+ getattr(item, "host_settings", {}) or {}
365
+ )
366
+ for setting_name in _target_setting_names(item):
367
+ if setting_name not in host_settings:
368
+ continue
369
+ for target_name in _target_names(host_settings[setting_name]):
370
+ if target_name in item_names:
371
+ continue
372
+ issues.append(
373
+ ProductionValidationIssue(
374
+ kind="route",
375
+ path=f"items.{item_name}.settings.Host.{setting_name}",
376
+ message=(
377
+ f"Target setting references missing production item "
378
+ f"{target_name!r}."
379
+ ),
380
+ suggestion=(
381
+ f"Add a production item named {target_name!r}, "
382
+ "or update/remove this target setting."
383
+ ),
384
+ )
385
+ )
386
+ return issues
387
+
388
+
389
+ def _target_setting_names(item: Any) -> set[str]:
390
+ names = {str(name) for name in getattr(item, "target_setting_names", set())}
391
+ component_class = getattr(item, "component_class", None)
392
+ if isinstance(component_class, type):
393
+ for base in reversed(component_class.__mro__):
394
+ for name, value in base.__dict__.items():
395
+ if isinstance(value, TargetSetting):
396
+ names.add(str(name))
397
+ return names
398
+
399
+
400
+ def _target_names(value: Any) -> tuple[str, ...]:
401
+ return tuple(
402
+ target
403
+ for target in (part.strip() for part in str(value or "").split(","))
404
+ if target
405
+ )
340
406
 
341
407
 
342
408
  def _validate_names_with_known_set(
@@ -394,7 +460,7 @@ def _adapter_class_name_from_host(host_class_name: str) -> str:
394
460
  return ""
395
461
 
396
462
 
397
- def _iris_module():
463
+ def _iris_module() -> Any:
398
464
  try:
399
465
  import iris # type: ignore
400
466
  except Exception:
iop/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iris_pex_embedded_python
3
- Version: 4.0.0b15
3
+ Version: 4.0.1
4
4
  Summary: Iris Interoperability based on Embedded Python
5
5
  Author-email: grongier <guillaume.rongier@intersystems.com>
6
6
  License: MIT License
@@ -69,14 +69,38 @@ For application repositories, start from the [reusable AGENTS.md template](https
69
69
 
70
70
  ## Example
71
71
 
72
- Here's a simple example of how a Business Operation can be implemented in Python:
72
+ Here's a tiny Python-authored production:
73
73
 
74
74
  ```python
75
- from iop import BusinessOperation
75
+ from dataclasses import dataclass
76
76
 
77
- class MyBo(BusinessOperation):
78
- def on_message(self, request):
79
- self.log_info("Hello World")
77
+ from iop import BusinessOperation, Message, PollingBusinessService, Production, target
78
+
79
+
80
+ @dataclass
81
+ class HelloRequest(Message):
82
+ text: str = "Hello World"
83
+
84
+
85
+ class HelloService(PollingBusinessService):
86
+ Output = target()
87
+
88
+ def on_poll(self):
89
+ self.send_request_async(self.Output, HelloRequest())
90
+
91
+
92
+ class HelloOperation(BusinessOperation):
93
+ def on_message(self, request: HelloRequest):
94
+ self.log_info(request.text)
95
+ return request
96
+
97
+
98
+ prod = Production("HelloWorld.Production", testing_enabled=True)
99
+ service = prod.service("HelloService", HelloService)
100
+ operation = prod.operation("HelloOperation", HelloOperation)
101
+ service.connect(HelloService.Output, operation)
102
+
103
+ PRODUCTIONS = [prod]
80
104
  ```
81
105
 
82
106
  ## Installation
@@ -1,5 +1,6 @@
1
- iop/__init__.py,sha256=4QGYNWs4t-Zt4-WfjHXmKRosWS5jZ5A27vl2hiObCVc,10621
1
+ iop/__init__.py,sha256=bnhSiC7XcOIiS7e51uY8fr9Li7RYjhCnlCyVDzZI6-I,10636
2
2
  iop/__main__.py,sha256=2rUNM7vaYK0W57Jj72rm7GORyIFLMXKwPq3QNLwYBmY,100
3
+ iop/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
3
4
  iop/cli/__init__.py,sha256=Y78pBdA-ULzq1c_9xQZsFBlzm3x5xgxSzkvrH5DoM7I,31
4
5
  iop/cli/formatting.py,sha256=OLgHA1d8_paAuINQyKkYgihWMLoBhPn7kSXW5whRlIs,1606
5
6
  iop/cli/main.py,sha256=r3sLZniM4LCXP6u1kuQmUgMLLD0H04NC5gVXKMNY31M,18418
@@ -39,7 +40,7 @@ iop/components/async_request.py,sha256=AU36pf6NIQMM9ZYR5h6LeQry0EfXXqDaKAjsP16_h
39
40
  iop/components/business_host.py,sha256=5EXwzZzfEVYLFlzi519GXKclTz2m5FMcqtl10lLzUyw,12089
40
41
  iop/components/business_operation.py,sha256=KycRNz-zuyJs2VneTBVcppyd3rKLekrISjbubCbzHxY,2905
41
42
  iop/components/business_process.py,sha256=1xTYwqhvJaXC-BvQ5WoGOud5QwxT9EPpvDPDX4zThXg,9839
42
- iop/components/business_service.py,sha256=fwSdHGFuC55_qSjFBpW-cLZmrK3Cm8Yxgk1KvxuQv2Y,4718
43
+ iop/components/business_service.py,sha256=Bp47bJzTWHp66kp0Zo_D-blOKHzj4i6kXu-lCRMMzfg,3597
43
44
  iop/components/common.py,sha256=tsP2APRBnks1hVazS9dX0Q8KCoANbUsnaFTlzqOIkVY,19063
44
45
  iop/components/debugpy.py,sha256=sNFKaF9MtWBNAqxH4ubSIVqjsIpsHGjRK2jzBU-O59Y,6011
45
46
  iop/components/generator_request.py,sha256=NOllwJnY06kk_eC7cQXDQ8NBFKfQakyz3A_AgYJ5f5Y,1351
@@ -53,7 +54,7 @@ iop/components/settings.py,sha256=USAKAisdersKlAEIk8HDu_TuYtkO-Sc3an3PXjPcDRA,62
53
54
  iop/messages/__init__.py,sha256=k1AKplpjdqSxM5Gl7bpWAfw93xUgigovlI4SKhwKHRI,355
54
55
  iop/messages/base.py,sha256=ZrVXEz7JrYHnt5b3M7avxcJCB0_OZxm1uWm9gQ4_eAw,1906
55
56
  iop/messages/decorators.py,sha256=CWk08k8U2aTJv7Dx1n2jp8sjZZSCD8NJPoifKLB3W2g,2488
56
- iop/messages/dispatch.py,sha256=Mgz0OHTQDU02Z8qPxl_lks-nlAvW6ADcJkTS4faYE-Q,10459
57
+ iop/messages/dispatch.py,sha256=l7FX-TAx5cLgjxoxNpL8ul3Lj4w4-taxTtyeZU3W85I,14001
57
58
  iop/messages/persistent.py,sha256=ibRhRTqzwGcAqem744KZzHiouUlc8-J3ftB4zyzQYx4,16906
58
59
  iop/messages/serialization.py,sha256=BW0o-7GaceMW7RMOzOJ8g9rKEv28NYnYFwtTLmAf7Yg,9255
59
60
  iop/messages/validation.py,sha256=X6MSWsjLZDuyX1Tfx0TP3T6OI4nj1HZccwR2Z9PSubY,1606
@@ -61,24 +62,24 @@ iop/migration/__init__.py,sha256=wp-RvjLKYW_Pi2AJLfiWe1bACxENzEgjXtP9UMNiSVY,36
61
62
  iop/migration/io.py,sha256=hAehtApFZFgGzjzgwBmDMxdA1xoRhE-okOcEzvgFdQ4,1822
62
63
  iop/migration/manifest.py,sha256=Bs4eC1_i-ZnovlgSW9FKu5WBwy_6Zwy5ZAcsi5A1szU,19351
63
64
  iop/migration/plans.py,sha256=5kx3aqQiPDJ3F8hU9pp9_mOO1_N6GA0Ps7--G1Iv6z8,2549
64
- iop/migration/utils.py,sha256=A3BlFpBGsDpF5DyA2DE1Oy52Ja7qC8QbFCxLFe_Xzg0,36867
65
+ iop/migration/utils.py,sha256=pOojLPMZes05KXrGN2ap45ffppP5Z4tS4bjJ0gSD5Oc,38183
65
66
  iop/production/__init__.py,sha256=95VmJgC3lWtHpUJijLqyKKxFZa65OcRni8803kb4aHE,2148
66
67
  iop/production/actions.py,sha256=TLVAjPTPq21oLj9c-IG10qMNJ2hvPzueviMy_c1a0ZU,17199
67
- iop/production/common.py,sha256=IoUg9jkFzSQwDsHcXvkVAC4qxpWKs3kTfXFm_EP07EU,3091
68
- iop/production/component.py,sha256=tyPqo_AvmaXowUwFj6ycqS1n22IYB1NfC3aaF4T0ves,7862
69
- iop/production/declarations.py,sha256=Tb79Q6rwmFMi6xKpzajJa4Aa2_Tk3sD-e8qiuRJX77s,5821
68
+ iop/production/common.py,sha256=L2QyvE2nC1J333sbebYx_6dyoaFL-89ouMO3Kfe4-D8,3465
69
+ iop/production/component.py,sha256=XalzNEWhCC1RVSZlQZRBpZM3BdRC2TDU-ko3Zn8-Kzo,9475
70
+ iop/production/declarations.py,sha256=u1hzgbDchr55R3dEXEIi9-P5JkfMG7C3aiSWhfhifG8,5952
70
71
  iop/production/declarative.py,sha256=IpHYO46mu7k9wnq3DpFreoALPoX1Vi8DrJb-85O_-bQ,7853
71
72
  iop/production/diff.py,sha256=NYpcSVklaGpaZOyl933W8eLsDWcwjlf6Upbp4pVGSMU,9975
72
73
  iop/production/import_.py,sha256=twBra6j2peolFadv8WyDwZZOd9NznzAkPsk2RbOfZQM,10833
73
74
  iop/production/inspection.py,sha256=PPv9DiDS4OrnKROqV7JPbuY8Y0JR-m8syPhap3GgE4g,3363
74
- iop/production/model.py,sha256=CpI5OBEBBw9z40Lws1rKVL9Mx_P4fhKmwYPoqEUntKU,41705
75
+ iop/production/model.py,sha256=bEtDo-IwECuCrkKDXJXm1hB5Bmme4srB6l9isc8n3KI,44088
75
76
  iop/production/planning.py,sha256=nCYrXLKlHBlUaQZPzPfqtIERIv62F2sg4gJ0b8mtcKk,23375
76
77
  iop/production/reconstruction.py,sha256=5vVteTEM6IOiQUfpe4AUQg6Nt4oaaIDR61-1l6Vu8hk,12532
77
- iop/production/rendering.py,sha256=UVHLICY4zWEP3W7-99TpB2WeLBNREC2N8rYSnBa_iZM,20240
78
+ iop/production/rendering.py,sha256=gyJyUcWwvgwRWvlvddZbCIhkjJ1DcMZfbL2brIa_k8U,20229
78
79
  iop/production/runtime.py,sha256=6grX1u20OhHnvhzC3yPDTFEoDs_TN4mfs5MxzFzZMls,2637
79
80
  iop/production/source_inference.py,sha256=Wl3E9-1hWb4qxD1jwrrZTLSgqN-0vGhOrJtmzDWPCao,21613
80
- iop/production/types.py,sha256=L_wyXO8deOnqRFin74SX_K8UWILFu-VzIO5pKNa6VXY,16580
81
- iop/production/validation.py,sha256=14BfjrLXW1AOuP8AdBrs-CY6zynaDc8_AUD9frsYAIU,13753
81
+ iop/production/types.py,sha256=hzO8rj73z16WqFQBI7YBAznY-1fD8ENyJqzTY-ydFXI,16889
82
+ iop/production/validation.py,sha256=gzRFLCTPGiNMIot1yG1FXG5ab9inpq4rqYYQpefhBYs,16217
82
83
  iop/runtime/__init__.py,sha256=wcyWMRuPCYE_CLd1NmgmI1M-IbFs2Gz2yIj2-EW_2AA,70
83
84
  iop/runtime/director.py,sha256=KW8BvkATK8XuIUPNBD9rMww9r3Zg4K3H54868eOgvl0,12867
84
85
  iop/runtime/environment.py,sha256=0KZ4EoW6NUEbF7FW471emxQ-9FbaiX6ngUHVGihgZXU,1564
@@ -91,9 +92,9 @@ iop/runtime/remote/director.py,sha256=bkie751GrYLgGs7ApRCowHpSnJA8KupxCOrFDQmjyO
91
92
  iop/runtime/remote/migration.py,sha256=Iboz-Ye7i9xif9bn433GazCF-gtD-HJW3wiUwWS5P6w,1831
92
93
  iop/runtime/remote/settings.py,sha256=P1Hsgld5o57HpSZgPVZzobyjlUmxJj6bstiWvR0JXnc,2144
93
94
  iop/runtime/remote/setup.py,sha256=Tr80F3JJTy7oSUso2jyx0iA_KZayd4MYt9vL0BvPpMA,2600
94
- iris_pex_embedded_python-4.0.0b15.dist-info/licenses/LICENSE,sha256=rZSiBFId_sfbJ6RL0GjjPX-InNLkNS9ou7eQsikciI8,1089
95
- iris_pex_embedded_python-4.0.0b15.dist-info/METADATA,sha256=FAs1I9jhJCMQabHY14tAR02X2fnv_DS7Yu-yrAZLVso,5284
96
- iris_pex_embedded_python-4.0.0b15.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
97
- iris_pex_embedded_python-4.0.0b15.dist-info/entry_points.txt,sha256=fzS8ZFsPe8cnpEx208X60DNugCPQmuSloPeg4pyyJDY,42
98
- iris_pex_embedded_python-4.0.0b15.dist-info/top_level.txt,sha256=BM54-OXQ6l2BDtmesWNXckh033s9gcjoO7bfjbwZbxU,4
99
- iris_pex_embedded_python-4.0.0b15.dist-info/RECORD,,
95
+ iris_pex_embedded_python-4.0.1.dist-info/licenses/LICENSE,sha256=rZSiBFId_sfbJ6RL0GjjPX-InNLkNS9ou7eQsikciI8,1089
96
+ iris_pex_embedded_python-4.0.1.dist-info/METADATA,sha256=hf-w2SSbyHHLLX_SIr41V2hKx3CQ2mVjnbZmJ2Oczic,5852
97
+ iris_pex_embedded_python-4.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
98
+ iris_pex_embedded_python-4.0.1.dist-info/entry_points.txt,sha256=fzS8ZFsPe8cnpEx208X60DNugCPQmuSloPeg4pyyJDY,42
99
+ iris_pex_embedded_python-4.0.1.dist-info/top_level.txt,sha256=BM54-OXQ6l2BDtmesWNXckh033s9gcjoO7bfjbwZbxU,4
100
+ iris_pex_embedded_python-4.0.1.dist-info/RECORD,,