iris-pex-embedded-python 3.7.2b2__py3-none-any.whl → 4.0.0__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.
Files changed (119) hide show
  1. iop/__init__.py +276 -18
  2. iop/__main__.py +3 -3
  3. iop/cli/__init__.py +1 -0
  4. iop/cli/formatting.py +45 -0
  5. iop/{_cli.py → cli/main.py} +238 -216
  6. iop/cli/parser.py +165 -0
  7. iop/cli/types.py +73 -0
  8. iop/cls/IOP/Common.cls +11 -2
  9. iop/cls/IOP/PrivateSession/Duplex.cls +1 -1
  10. iop/cls/IOP/Service/Remote/Rest/v1.cls +152 -5
  11. iop/cls/IOP/Utils.cls +814 -7
  12. iop/cls/IOP/Wrapper.cls +1 -1
  13. iop/components/__init__.py +16 -0
  14. iop/{_async_request.py → components/async_request.py} +7 -7
  15. iop/components/business_host.py +363 -0
  16. iop/components/business_operation.py +84 -0
  17. iop/{_business_process.py → components/business_process.py} +145 -125
  18. iop/components/business_service.py +106 -0
  19. iop/{_common.py → components/common.py} +160 -164
  20. iop/{_debugpy.py → components/debugpy.py} +11 -11
  21. iop/{_generator_request.py → components/generator_request.py} +4 -4
  22. iop/{_inbound_adapter.py → components/inbound_adapter.py} +10 -18
  23. iop/{_log_manager.py → components/log_manager.py} +1 -1
  24. iop/{_outbound_adapter.py → components/outbound_adapter.py} +1 -1
  25. iop/components/polling_business_service.py +48 -0
  26. iop/{_private_session_duplex.py → components/private_session_duplex.py} +7 -9
  27. iop/{_private_session_process.py → components/private_session_process.py} +2 -2
  28. iop/{_settings.py → components/settings.py} +23 -3
  29. iop/messages/__init__.py +7 -0
  30. iop/messages/base.py +61 -0
  31. iop/{_decorators.py → messages/decorators.py} +8 -2
  32. iop/messages/dispatch.py +351 -0
  33. iop/{_persistent_message.py → messages/persistent.py} +24 -19
  34. iop/{_serialization.py → messages/serialization.py} +34 -21
  35. iop/{_message_validator.py → messages/validation.py} +15 -13
  36. iop/migration/__init__.py +1 -0
  37. iop/migration/io.py +61 -0
  38. iop/migration/manifest.py +534 -0
  39. iop/migration/plans.py +80 -0
  40. iop/migration/utils.py +1126 -0
  41. iop/production/__init__.py +55 -0
  42. iop/production/actions.py +525 -0
  43. iop/production/common.py +103 -0
  44. iop/production/component.py +234 -0
  45. iop/production/declarations.py +190 -0
  46. iop/production/declarative.py +214 -0
  47. iop/production/diff.py +327 -0
  48. iop/production/import_.py +296 -0
  49. iop/production/inspection.py +92 -0
  50. iop/production/model.py +1143 -0
  51. iop/production/planning.py +678 -0
  52. iop/production/reconstruction.py +341 -0
  53. iop/production/rendering.py +610 -0
  54. iop/production/runtime.py +91 -0
  55. iop/production/source_inference.py +729 -0
  56. iop/production/types.py +546 -0
  57. iop/production/validation.py +452 -0
  58. iop/runtime/__init__.py +1 -0
  59. iop/runtime/director.py +415 -0
  60. iop/runtime/environment.py +59 -0
  61. iop/{_iris.py → runtime/iris.py} +2 -2
  62. iop/runtime/local.py +145 -0
  63. iop/{_director_protocol.py → runtime/protocol.py} +25 -8
  64. iop/runtime/remote/__init__.py +12 -0
  65. iop/runtime/remote/client.py +90 -0
  66. iop/runtime/remote/director.py +283 -0
  67. iop/runtime/remote/migration.py +57 -0
  68. iop/runtime/remote/settings.py +69 -0
  69. iop/runtime/remote/setup.py +72 -0
  70. {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/METADATA +11 -2
  71. iris_pex_embedded_python-4.0.0.dist-info/RECORD +99 -0
  72. iris_pex_embedded_python-4.0.0.dist-info/entry_points.txt +2 -0
  73. iris_pex_embedded_python-4.0.0.dist-info/top_level.txt +1 -0
  74. grongier/__init__.py +0 -0
  75. grongier/cls/Grongier/PEX/BusinessOperation.cls +0 -8
  76. grongier/cls/Grongier/PEX/BusinessProcess.cls +0 -13
  77. grongier/cls/Grongier/PEX/BusinessService.cls +0 -8
  78. grongier/cls/Grongier/PEX/Common.cls +0 -10
  79. grongier/cls/Grongier/PEX/Director.cls +0 -10
  80. grongier/cls/Grongier/PEX/Duplex/Operation.cls +0 -4
  81. grongier/cls/Grongier/PEX/Duplex/Process.cls +0 -13
  82. grongier/cls/Grongier/PEX/Duplex/Service.cls +0 -4
  83. grongier/cls/Grongier/PEX/InboundAdapter.cls +0 -8
  84. grongier/cls/Grongier/PEX/Message.cls +0 -13
  85. grongier/cls/Grongier/PEX/OutboundAdapter.cls +0 -8
  86. grongier/cls/Grongier/PEX/PickleMessage.cls +0 -13
  87. grongier/cls/Grongier/PEX/PrivateSession/Duplex.cls +0 -8
  88. grongier/cls/Grongier/PEX/PrivateSession/Message/Ack.cls +0 -14
  89. grongier/cls/Grongier/PEX/PrivateSession/Message/Poll.cls +0 -14
  90. grongier/cls/Grongier/PEX/PrivateSession/Message/Start.cls +0 -14
  91. grongier/cls/Grongier/PEX/PrivateSession/Message/Stop.cls +0 -14
  92. grongier/cls/Grongier/PEX/Test.cls +0 -10
  93. grongier/cls/Grongier/PEX/Utils.cls +0 -10
  94. grongier/cls/Grongier/Service/WSGI.cls +0 -4
  95. grongier/pex/__init__.py +0 -75
  96. grongier/pex/__main__.py +0 -4
  97. grongier/pex/_business_host.py +0 -1
  98. grongier/pex/_cli.py +0 -4
  99. grongier/pex/_common.py +0 -1
  100. grongier/pex/_director.py +0 -1
  101. grongier/pex/_utils.py +0 -1
  102. grongier/pex/wsgi/handlers.py +0 -104
  103. iop/_business_host.py +0 -322
  104. iop/_business_operation.py +0 -77
  105. iop/_business_service.py +0 -67
  106. iop/_director.py +0 -333
  107. iop/_dispatch.py +0 -194
  108. iop/_local.py +0 -106
  109. iop/_message.py +0 -45
  110. iop/_polling_business_service.py +0 -6
  111. iop/_remote.py +0 -473
  112. iop/_utils.py +0 -912
  113. iop/cls/IOP/Service/WSGI.cls +0 -310
  114. iop/wsgi/handlers.py +0 -112
  115. iris_pex_embedded_python-3.7.2b2.dist-info/RECORD +0 -96
  116. iris_pex_embedded_python-3.7.2b2.dist-info/entry_points.txt +0 -2
  117. iris_pex_embedded_python-3.7.2b2.dist-info/top_level.txt +0 -2
  118. {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/WHEEL +0 -0
  119. {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,64 +1,23 @@
1
1
  from __future__ import annotations
2
- import argparse
2
+
3
3
  import json
4
4
  import logging
5
- import requests
6
5
  import os
7
- from dataclasses import dataclass
8
- from enum import Enum, auto
9
6
  import sys
10
- from typing import Optional
11
7
  from importlib.metadata import version
12
8
 
13
- from ._local import _LocalDirector
14
- from ._remote import _RemoteDirector, get_remote_settings
15
- from ._director_protocol import DirectorProtocol
16
- from ._utils import _Utils
17
-
18
-
19
- class CommandType(Enum):
20
- DEFAULT = auto()
21
- LIST = auto()
22
- START = auto()
23
- STOP = auto()
24
- KILL = auto()
25
- RESTART = auto()
26
- STATUS = auto()
27
- TEST = auto()
28
- VERSION = auto()
29
- EXPORT = auto()
30
- MIGRATE = auto()
31
- LOG = auto()
32
- INIT = auto()
33
- HELP = auto()
34
- UPDATE = auto()
35
-
36
-
37
- @dataclass
38
- class CommandArgs:
39
- """Container for parsed command arguments"""
40
-
41
- default: Optional[str] = None
42
- list: bool = False
43
- start: Optional[str] = None
44
- detach: bool = False
45
- stop: bool = False
46
- kill: bool = False
47
- restart: bool = False
48
- status: bool = False
49
- migrate: Optional[str] = None
50
- export: Optional[str] = None
51
- version: bool = False
52
- log: Optional[str] = None
53
- init: Optional[str] = None
54
- test: Optional[str] = None
55
- classname: Optional[str] = None
56
- body: Optional[str] = None
57
- namespace: Optional[str] = None
58
- force_local: bool = False
59
- remote_settings: Optional[str] = None
60
- update: bool = False
61
- migration_plan: bool = False
9
+ import requests
10
+
11
+ from ..migration import utils as migration_utils
12
+ from ..production import Production, ProductionChangePlan
13
+ from ..runtime.local import _LocalDirector
14
+ from ..runtime.protocol import DirectorProtocol
15
+ from ..runtime.remote import _RemoteDirector, get_remote_settings
16
+ from .formatting import format_test_response
17
+ from .parser import create_parser
18
+ from .types import CommandArgs, CommandType
19
+
20
+ _format_test_response = format_test_response
62
21
 
63
22
 
64
23
  class Command:
@@ -114,12 +73,23 @@ class Command:
114
73
  self.args.export,
115
74
  self.args.migrate,
116
75
  self.args.log,
76
+ self.args.queue,
117
77
  self.args.init,
78
+ self.args.bindings,
79
+ self.args.unbind is not None,
118
80
  self.args.update,
81
+ self.args.plan,
82
+ self.args.review_plan,
83
+ self.args.apply_plan,
84
+ self.args.verify_plan,
85
+ self.args.rollback_backup,
119
86
  ]
120
87
  )
121
88
 
122
89
  def execute(self) -> None:
90
+ if self.args.unused and not self.args.bindings:
91
+ raise ValueError("--unused can only be used with --bindings.")
92
+
123
93
  if self.args.namespace == "not_set" and not self._has_primary_command():
124
94
  print(os.getenv("IRISNAMESPACE", "not set"))
125
95
  return
@@ -138,9 +108,17 @@ class Command:
138
108
  CommandType.EXPORT: self._handle_export,
139
109
  CommandType.MIGRATE: self._handle_migrate,
140
110
  CommandType.LOG: self._handle_log,
111
+ CommandType.QUEUE: self._handle_queue,
141
112
  CommandType.INIT: self._handle_init,
113
+ CommandType.BINDINGS: self._handle_bindings,
114
+ CommandType.UNBIND: self._handle_unbind,
142
115
  CommandType.HELP: self._handle_help,
143
116
  CommandType.UPDATE: self._handle_update,
117
+ CommandType.PLAN: self._handle_plan,
118
+ CommandType.REVIEW_PLAN: self._handle_review_plan,
119
+ CommandType.APPLY_PLAN: self._handle_apply_plan,
120
+ CommandType.VERIFY_PLAN: self._handle_verify_plan,
121
+ CommandType.ROLLBACK_BACKUP: self._handle_rollback_backup,
144
122
  }
145
123
  handler = command_handlers.get(command_type)
146
124
  if handler:
@@ -171,10 +149,26 @@ class Command:
171
149
  return CommandType.MIGRATE
172
150
  if self.args.log:
173
151
  return CommandType.LOG
152
+ if self.args.queue:
153
+ return CommandType.QUEUE
174
154
  if self.args.init:
175
155
  return CommandType.INIT
156
+ if self.args.bindings:
157
+ return CommandType.BINDINGS
158
+ if self.args.unbind is not None:
159
+ return CommandType.UNBIND
176
160
  if self.args.update:
177
161
  return CommandType.UPDATE
162
+ if self.args.plan:
163
+ return CommandType.PLAN
164
+ if self.args.review_plan:
165
+ return CommandType.REVIEW_PLAN
166
+ if self.args.apply_plan:
167
+ return CommandType.APPLY_PLAN
168
+ if self.args.verify_plan:
169
+ return CommandType.VERIFY_PLAN
170
+ if self.args.rollback_backup:
171
+ return CommandType.ROLLBACK_BACKUP
178
172
  return CommandType.HELP
179
173
 
180
174
  def _handle_default(self) -> None:
@@ -230,13 +224,13 @@ class Command:
230
224
  filepath = body[1:]
231
225
  if not os.path.isabs(filepath):
232
226
  filepath = os.path.join(os.getcwd(), filepath)
233
- with open(filepath, "r", encoding="utf-8") as fh:
227
+ with open(filepath, encoding="utf-8") as fh:
234
228
  body = fh.read()
235
229
 
236
230
  response = self.director.test_component(
237
231
  test_name, classname=classname, body=body
238
232
  )
239
- print(_format_test_response(response))
233
+ print(format_test_response(response))
240
234
 
241
235
  def _handle_version(self) -> None:
242
236
  print(version("iris-pex-embedded-python"))
@@ -247,7 +241,25 @@ class Command:
247
241
  if self.args.export == "not_set"
248
242
  else self.args.export
249
243
  )
250
- print(json.dumps(self.director.export_production(export_name), indent=4))
244
+ export_format = self.args.export_format or "json"
245
+ if export_format == "json":
246
+ print(json.dumps(self.director.export_production(export_name), indent=4))
247
+ return
248
+
249
+ production = Production.from_iris(export_name, director=self.director)
250
+ if export_format == "python":
251
+ print(production.to_python(), end="")
252
+ return
253
+ if export_format == "class":
254
+ print(production.to_class(), end="")
255
+ return
256
+ if export_format == "graph":
257
+ print(production.graph())
258
+ return
259
+ if export_format == "mermaid":
260
+ print(production.to_mermaid(), end="")
261
+ return
262
+ raise ValueError(f"Unsupported export format: {export_format}")
251
263
 
252
264
  def _handle_migrate(self) -> None:
253
265
  migrate_path = self.args.migrate
@@ -257,21 +269,37 @@ class Command:
257
269
  mode = "REMOTE" if self._is_remote else "LOCAL"
258
270
  if self.args.migration_plan:
259
271
  print(
260
- _Utils.explain_migration(
261
- migrate_path, mode=mode, namespace=self.director.namespace
272
+ migration_utils.explain_migration(
273
+ migrate_path,
274
+ mode=mode,
275
+ namespace=self.director.namespace,
276
+ strict_production_validation=(
277
+ self.args.strict_production_validation
278
+ ),
262
279
  )
263
280
  )
264
281
  return
265
282
  if self._is_remote:
266
283
  print(
267
- _Utils.explain_migration(
268
- migrate_path, mode=mode, namespace=self.director.namespace
284
+ migration_utils.explain_migration(
285
+ migrate_path,
286
+ mode=mode,
287
+ namespace=self.director.namespace,
288
+ strict_production_validation=(
289
+ self.args.strict_production_validation
290
+ ),
269
291
  )
270
292
  )
271
- self.director.migrate(migrate_path)
293
+ if self.args.strict_production_validation:
294
+ self.director.migrate(
295
+ migrate_path,
296
+ strict_production_validation=True,
297
+ )
298
+ else:
299
+ self.director.migrate(migrate_path)
272
300
  if self._is_remote:
273
301
  print(
274
- _Utils.format_migration_success(
302
+ migration_utils.format_migration_success(
275
303
  migrate_path, namespace=self.director.namespace
276
304
  )
277
305
  )
@@ -282,10 +310,92 @@ class Command:
282
310
  elif self.args.log is not None:
283
311
  self.director.log_production_top(int(self.args.log))
284
312
 
313
+ def _handle_queue(self) -> None:
314
+ production_name = (
315
+ self.director.get_default_production()
316
+ if self.args.queue == "not_set"
317
+ else self.args.queue
318
+ )
319
+ if not production_name or production_name == "Not defined":
320
+ print(
321
+ "Error: no production name provided and no default production is defined.",
322
+ file=sys.stderr,
323
+ )
324
+ sys.exit(1)
325
+ print(
326
+ json.dumps(
327
+ self.director.export_production_queue_info(production_name),
328
+ indent=4,
329
+ )
330
+ )
331
+
285
332
  def _handle_init(self) -> None:
286
333
  path = None if self.args.init == "not_set" else self.args.init
287
334
  self.director.setup(path)
288
335
 
336
+ def _handle_bindings(self) -> None:
337
+ print(
338
+ json.dumps(
339
+ self.director.list_bindings(unused_only=self.args.unused),
340
+ indent=4,
341
+ )
342
+ )
343
+
344
+ def _handle_unbind(self) -> None:
345
+ if not self.args.unbind:
346
+ raise ValueError("IRIS class name is required.")
347
+ self.director.unbind_component(self.args.unbind)
348
+ print(f"Removed binding {self.args.unbind}.")
349
+
350
+ def _handle_plan(self) -> None:
351
+ production = self._load_production_from_settings(
352
+ self.args.plan,
353
+ self.args.production,
354
+ )
355
+ plan = production.plan()
356
+ if self.args.out:
357
+ plan.save(self._absolute_path(self.args.out))
358
+ print(f"Production change plan written to {self.args.out}")
359
+ else:
360
+ print(plan)
361
+
362
+ def _handle_review_plan(self) -> None:
363
+ plan = ProductionChangePlan.load(self._absolute_path(self.args.review_plan))
364
+ print(plan)
365
+
366
+ def _handle_apply_plan(self) -> None:
367
+ if not self.args.settings:
368
+ raise ValueError("--apply-plan requires --settings.")
369
+ plan = ProductionChangePlan.load(self._absolute_path(self.args.apply_plan))
370
+ production = self._load_production_from_settings(
371
+ self.args.settings,
372
+ self.args.production or plan.production_name,
373
+ )
374
+ result = production.apply(
375
+ plan,
376
+ allow_destructive=self.args.allow_destructive,
377
+ backup_dir=self.args.backup_dir,
378
+ )
379
+ print(result)
380
+
381
+ def _handle_verify_plan(self) -> None:
382
+ plan = ProductionChangePlan.load(self._absolute_path(self.args.verify_plan))
383
+ production = Production(
384
+ self.args.production or plan.production_name,
385
+ namespace=self.director.namespace,
386
+ director=self.director,
387
+ )
388
+ print(production.verify(plan))
389
+
390
+ def _handle_rollback_backup(self) -> None:
391
+ result = Production.rollback_backup(
392
+ self._absolute_path(self.args.rollback_backup),
393
+ director=self.director,
394
+ namespace=self.director.namespace,
395
+ allow_destructive=self.args.allow_destructive,
396
+ )
397
+ print(result)
398
+
289
399
  def _handle_help(self) -> None:
290
400
  create_parser().print_help()
291
401
  if self._is_remote:
@@ -296,161 +406,73 @@ class Command:
296
406
  except Exception:
297
407
  logging.warning("Could not retrieve default production.")
298
408
 
299
-
300
- def _format_test_response(response) -> str:
301
- """Pretty-print any test_component() return value.
302
-
303
- Handles three cases:
304
- - dict : remote response with ``classname`` / ``body`` keys
305
- - str : local response in ``"ClassName : {json}"`` format
306
- - other : Python dataclass / object returned by the local director
307
- """
308
- if isinstance(response, dict):
309
- parts = []
310
- if response.get("error"):
311
- return f"Error: {response['error']}"
312
- if response.get("classname"):
313
- parts.append(f"classname: {response['classname']}")
314
- body = response.get("body", "")
315
- if body:
316
- try:
317
- parsed = json.loads(body)
318
- parts.append("body:\n" + json.dumps(parsed, indent=4))
319
- except (json.JSONDecodeError, TypeError):
320
- parts.append(f"body: {body}")
321
- if response.get("truncated"):
322
- parts.append("(response body was truncated)")
323
- return "\n".join(parts) if parts else str(response)
324
-
325
- if isinstance(response, str):
326
- # Try to detect the "ClassName : {json_body}" pattern from local mode
327
- if " : " in response:
328
- classname_part, _, body_part = response.partition(" : ")
329
- try:
330
- parsed = json.loads(body_part)
331
- return (
332
- f"classname: {classname_part.strip()}\n"
333
- f"body:\n{json.dumps(parsed, indent=4)}"
334
- )
335
- except (json.JSONDecodeError, TypeError):
336
- pass
337
- # Plain string — try JSON pretty-print
409
+ def _load_production_from_settings(
410
+ self,
411
+ settings_file: str | None,
412
+ production_name: str | None,
413
+ ) -> Production:
414
+ if not settings_file:
415
+ raise ValueError("A settings.py file is required.")
416
+ settings_path = self._absolute_path(settings_file)
417
+ settings, path_added = migration_utils._load_settings(settings_path)
338
418
  try:
339
- return json.dumps(json.loads(response), indent=4)
340
- except (json.JSONDecodeError, TypeError):
341
- return response
342
-
343
- # Python dataclass / arbitrary object
344
- try:
345
- import dataclasses
346
-
347
- if dataclasses.is_dataclass(response):
348
- return json.dumps(dataclasses.asdict(response), indent=4)
349
- except Exception:
350
- pass
351
- return str(response)
352
-
353
-
354
- def create_parser() -> argparse.ArgumentParser:
355
- """Create and configure argument parser"""
356
- main_parser = argparse.ArgumentParser(
357
- epilog=(
358
- "Remote mode: set IOP_URL (e.g. http://localhost:8080) to run all commands\n"
359
- "against a remote IRIS instance via its REST API. Optional env vars:\n"
360
- " IOP_USERNAME, IOP_PASSWORD, IOP_NAMESPACE (default: USER),\n"
361
- " IOP_VERIFY_SSL (set to 0 to disable TLS verification).\n"
362
- "Alternatively use -R /path/to/settings.py or set IOP_SETTINGS=\n"
363
- "(file must contain a REMOTE_SETTINGS dict with at least 'url').\n"
364
- "Use --force-local to suppress remote mode entirely."
365
- ),
366
- formatter_class=argparse.RawDescriptionHelpFormatter,
367
- )
368
- parser = main_parser.add_mutually_exclusive_group()
369
-
370
- # Main commands
371
- parser.add_argument(
372
- "-d", "--default", help="set the default production", nargs="?", const="not_set"
373
- )
374
- parser.add_argument("-l", "--list", help="list productions", action="store_true")
375
- parser.add_argument(
376
- "-s", "--start", help="start a production", nargs="?", const="not_set"
377
- )
378
- parser.add_argument("-S", "--stop", help="stop a production", action="store_true")
379
- parser.add_argument("-k", "--kill", help="kill a production", action="store_true")
380
- parser.add_argument(
381
- "-r", "--restart", help="restart a production", action="store_true"
382
- )
383
- parser.add_argument(
384
- "-x", "--status", help="status a production", action="store_true"
385
- )
386
- parser.add_argument(
387
- "-m",
388
- "-M",
389
- "--migrate",
390
- help="migrate production and classes with settings file",
391
- )
392
- parser.add_argument(
393
- "-e", "--export", help="export a production", nargs="?", const="not_set"
394
- )
395
- parser.add_argument("-v", "--version", help="display version", action="store_true")
396
- parser.add_argument("-L", "--log", help="display log", nargs="?", const="not_set")
397
- parser.add_argument(
398
- "-i", "--init", help="init the pex module in iris", nargs="?", const="not_set"
399
- )
400
- parser.add_argument(
401
- "-t", "--test", help="test the pex module in iris", nargs="?", const="not_set"
402
- )
403
- parser.add_argument(
404
- "-u", "--update", help="update a production", action="store_true"
405
- )
406
-
407
- # Command groups
408
- start = main_parser.add_argument_group("start arguments")
409
- start.add_argument(
410
- "-D", "--detach", help="start a production in detach mode", action="store_true"
411
- )
412
-
413
- test = main_parser.add_argument_group("test arguments")
414
- test.add_argument(
415
- "-C", "--classname", help="test classname", nargs="?", const="not_set"
416
- )
417
- test.add_argument(
418
- "-B",
419
- "--body",
420
- help="test body (JSON string or @path/to/file.json)",
421
- nargs="?",
422
- const="not_set",
423
- )
424
-
425
- migrate = main_parser.add_argument_group("migrate arguments")
426
- migrate.add_argument(
427
- "--force-local",
428
- help="force local mode, skip remote even if REMOTE_SETTINGS or IOP_URL is present",
429
- action="store_true",
430
- )
431
- migrate.add_argument(
432
- "--dry-run",
433
- "--explain",
434
- dest="migration_plan",
435
- help="show the migration plan and validation messages without writing to IRIS",
436
- action="store_true",
437
- )
438
-
439
- remote = main_parser.add_argument_group("remote arguments")
440
- remote.add_argument(
441
- "-R",
442
- "--remote-settings",
443
- help="path to a settings.py containing REMOTE_SETTINGS (overrides IOP_SETTINGS env var)",
444
- metavar="FILE",
445
- )
446
-
447
- namespace = main_parser.add_argument_group("namespace arguments")
448
- namespace.add_argument(
449
- "-n", "--namespace", help="set namespace", nargs="?", const="not_set"
419
+ production = _select_production(
420
+ getattr(settings, "PRODUCTIONS", None),
421
+ production_name,
422
+ director=self.director,
423
+ namespace=self.director.namespace,
424
+ )
425
+ production.with_director(self.director)
426
+ if not production.namespace:
427
+ production.in_namespace(self.director.namespace)
428
+ return production
429
+ finally:
430
+ migration_utils._cleanup_sys_path(path_added)
431
+
432
+ @staticmethod
433
+ def _absolute_path(path: str | None) -> str:
434
+ if not path:
435
+ raise ValueError("Path is required.")
436
+ if os.path.isabs(path):
437
+ return path
438
+ return os.path.join(os.getcwd(), path)
439
+
440
+
441
+ def _select_production(
442
+ productions,
443
+ production_name: str | None,
444
+ *,
445
+ director: DirectorProtocol,
446
+ namespace: str | None,
447
+ ) -> Production:
448
+ if not isinstance(productions, list):
449
+ raise ValueError("settings.py must define PRODUCTIONS as a list.")
450
+ candidates: list[Production] = []
451
+ for entry in productions:
452
+ if isinstance(entry, Production):
453
+ candidate = entry
454
+ elif isinstance(entry, dict):
455
+ candidate = Production.from_dict(
456
+ entry,
457
+ director=director,
458
+ namespace=namespace,
459
+ )
460
+ else:
461
+ continue
462
+ candidates.append(candidate)
463
+ if production_name:
464
+ for candidate in candidates:
465
+ if candidate.name == production_name:
466
+ return candidate
467
+ raise ValueError(f"Production {production_name!r} not found in settings.")
468
+ if len(candidates) == 1:
469
+ return candidates[0]
470
+ names = ", ".join(candidate.name for candidate in candidates) or "none"
471
+ raise ValueError(
472
+ "Production name is required when settings.py contains multiple "
473
+ f"productions ({names})."
450
474
  )
451
475
 
452
- return main_parser
453
-
454
476
 
455
477
  def main(argv=None) -> None:
456
478
  parser = create_parser()
@@ -472,7 +494,7 @@ def main(argv=None) -> None:
472
494
  except requests.exceptions.HTTPError as exc:
473
495
  print(f"Error: {exc}", file=sys.stderr)
474
496
  sys.exit(1)
475
- except RuntimeError as exc:
497
+ except (RuntimeError, ValueError) as exc:
476
498
  print(f"Error: {exc}", file=sys.stderr)
477
499
  sys.exit(1)
478
500
  sys.exit(0)