iris-pex-embedded-python 3.7.2b1__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 (120) hide show
  1. iop/__init__.py +280 -13
  2. iop/__main__.py +3 -3
  3. iop/cli/__init__.py +1 -0
  4. iop/cli/formatting.py +45 -0
  5. iop/cli/main.py +504 -0
  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 +835 -8
  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/components/common.py +574 -0
  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/components/settings.py +208 -0
  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.2b1.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 -66
  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/_cli.py +0 -453
  107. iop/_common.py +0 -416
  108. iop/_director.py +0 -333
  109. iop/_dispatch.py +0 -194
  110. iop/_local.py +0 -106
  111. iop/_message.py +0 -45
  112. iop/_remote.py +0 -473
  113. iop/_utils.py +0 -710
  114. iop/cls/IOP/Service/WSGI.cls +0 -310
  115. iop/wsgi/handlers.py +0 -112
  116. iris_pex_embedded_python-3.7.2b1.dist-info/RECORD +0 -94
  117. iris_pex_embedded_python-3.7.2b1.dist-info/entry_points.txt +0 -2
  118. iris_pex_embedded_python-3.7.2b1.dist-info/top_level.txt +0 -2
  119. {iris_pex_embedded_python-3.7.2b1.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/WHEEL +0 -0
  120. {iris_pex_embedded_python-3.7.2b1.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/licenses/LICENSE +0 -0
iop/cli/main.py ADDED
@@ -0,0 +1,504 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import sys
7
+ from importlib.metadata import version
8
+
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
21
+
22
+
23
+ class Command:
24
+ def __init__(self, args: CommandArgs):
25
+ self.args = args
26
+
27
+ if self.args.namespace and self.args.namespace != "not_set":
28
+ # set environment variable IRISNAMESPACE
29
+ os.environ["IRISNAMESPACE"] = self.args.namespace
30
+
31
+ # Resolve director: remote when IOP_URL / IOP_SETTINGS env vars are set
32
+ # or when the -m settings.py file contains REMOTE_SETTINGS.
33
+ # --force-local overrides everything and always uses the local director.
34
+ if self.args.force_local:
35
+ self.director: DirectorProtocol = _LocalDirector()
36
+ self._is_remote = False
37
+ else:
38
+ # Resolve absolute paths for --remote-settings and -m so
39
+ # get_remote_settings can load them regardless of cwd.
40
+ explicit_path = self.args.remote_settings
41
+ if explicit_path and not os.path.isabs(explicit_path):
42
+ explicit_path = os.path.join(os.getcwd(), explicit_path)
43
+
44
+ migrate_path = self.args.migrate
45
+ if migrate_path and not os.path.isabs(migrate_path):
46
+ migrate_path = os.path.join(os.getcwd(), migrate_path)
47
+
48
+ remote_settings = get_remote_settings(
49
+ explicit_settings_path=explicit_path,
50
+ fallback_settings_path=migrate_path,
51
+ )
52
+ if remote_settings:
53
+ if self.args.namespace and self.args.namespace != "not_set":
54
+ remote_settings["namespace"] = self.args.namespace
55
+ self.director = _RemoteDirector(remote_settings)
56
+ self._is_remote = True
57
+ else:
58
+ self.director = _LocalDirector()
59
+ self._is_remote = False
60
+
61
+ def _has_primary_command(self) -> bool:
62
+ return any(
63
+ [
64
+ self.args.default,
65
+ self.args.list,
66
+ self.args.start,
67
+ self.args.stop,
68
+ self.args.kill,
69
+ self.args.restart,
70
+ self.args.status,
71
+ self.args.test,
72
+ self.args.version,
73
+ self.args.export,
74
+ self.args.migrate,
75
+ self.args.log,
76
+ self.args.queue,
77
+ self.args.init,
78
+ self.args.bindings,
79
+ self.args.unbind is not None,
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,
86
+ ]
87
+ )
88
+
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
+
93
+ if self.args.namespace == "not_set" and not self._has_primary_command():
94
+ print(os.getenv("IRISNAMESPACE", "not set"))
95
+ return
96
+
97
+ command_type = self._determine_command_type()
98
+ command_handlers = {
99
+ CommandType.DEFAULT: self._handle_default,
100
+ CommandType.LIST: self._handle_list,
101
+ CommandType.START: self._handle_start,
102
+ CommandType.STOP: self._handle_stop,
103
+ CommandType.KILL: self._handle_kill,
104
+ CommandType.RESTART: self._handle_restart,
105
+ CommandType.STATUS: self._handle_status,
106
+ CommandType.TEST: self._handle_test,
107
+ CommandType.VERSION: self._handle_version,
108
+ CommandType.EXPORT: self._handle_export,
109
+ CommandType.MIGRATE: self._handle_migrate,
110
+ CommandType.LOG: self._handle_log,
111
+ CommandType.QUEUE: self._handle_queue,
112
+ CommandType.INIT: self._handle_init,
113
+ CommandType.BINDINGS: self._handle_bindings,
114
+ CommandType.UNBIND: self._handle_unbind,
115
+ CommandType.HELP: self._handle_help,
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,
122
+ }
123
+ handler = command_handlers.get(command_type)
124
+ if handler:
125
+ handler()
126
+
127
+ def _determine_command_type(self) -> CommandType:
128
+ if self.args.default:
129
+ return CommandType.DEFAULT
130
+ if self.args.list:
131
+ return CommandType.LIST
132
+ if self.args.start:
133
+ return CommandType.START
134
+ if self.args.stop:
135
+ return CommandType.STOP
136
+ if self.args.kill:
137
+ return CommandType.KILL
138
+ if self.args.restart:
139
+ return CommandType.RESTART
140
+ if self.args.status:
141
+ return CommandType.STATUS
142
+ if self.args.test:
143
+ return CommandType.TEST
144
+ if self.args.version:
145
+ return CommandType.VERSION
146
+ if self.args.export:
147
+ return CommandType.EXPORT
148
+ if self.args.migrate:
149
+ return CommandType.MIGRATE
150
+ if self.args.log:
151
+ return CommandType.LOG
152
+ if self.args.queue:
153
+ return CommandType.QUEUE
154
+ if self.args.init:
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
160
+ if self.args.update:
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
172
+ return CommandType.HELP
173
+
174
+ def _handle_default(self) -> None:
175
+ if self.args.default == "not_set":
176
+ print(self.director.get_default_production())
177
+ elif self.args.default is not None:
178
+ self.director.set_default_production(self.args.default)
179
+
180
+ def _handle_list(self) -> None:
181
+ dikt = self.director.list_productions()
182
+ print(json.dumps(dikt, indent=4))
183
+
184
+ def _handle_start(self) -> None:
185
+ if self.args.start != "not_set":
186
+ production_name = self.args.start
187
+ else:
188
+ production_name = self.director.get_default_production()
189
+ if not production_name or production_name == "Not defined":
190
+ print(
191
+ "Error: no production name provided and no default production is defined.",
192
+ file=sys.stderr,
193
+ )
194
+ sys.exit(1)
195
+ if self.args.detach:
196
+ self.director.start_production(production_name)
197
+ print(f"Production {production_name} started")
198
+ else:
199
+ self.director.start_production_with_log(production_name)
200
+
201
+ def _handle_stop(self) -> None:
202
+ self.director.stop_production()
203
+ print(f"Production {self.director.get_default_production()} stopped")
204
+
205
+ def _handle_kill(self) -> None:
206
+ self.director.shutdown_production()
207
+
208
+ def _handle_restart(self) -> None:
209
+ self.director.restart_production()
210
+
211
+ def _handle_status(self) -> None:
212
+ print(json.dumps(self.director.status_production(), indent=4))
213
+
214
+ def _handle_update(self) -> None:
215
+ self.director.update_production()
216
+
217
+ def _handle_test(self) -> None:
218
+ test_name = None if self.args.test == "not_set" else self.args.test
219
+ classname = self.args.classname if self.args.classname != "not_set" else None
220
+ body = self.args.body if self.args.body != "not_set" else None
221
+
222
+ # Support @filename.json body expansion
223
+ if body and body.startswith("@"):
224
+ filepath = body[1:]
225
+ if not os.path.isabs(filepath):
226
+ filepath = os.path.join(os.getcwd(), filepath)
227
+ with open(filepath, encoding="utf-8") as fh:
228
+ body = fh.read()
229
+
230
+ response = self.director.test_component(
231
+ test_name, classname=classname, body=body
232
+ )
233
+ print(format_test_response(response))
234
+
235
+ def _handle_version(self) -> None:
236
+ print(version("iris-pex-embedded-python"))
237
+
238
+ def _handle_export(self) -> None:
239
+ export_name = (
240
+ self.director.get_default_production()
241
+ if self.args.export == "not_set"
242
+ else self.args.export
243
+ )
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}")
263
+
264
+ def _handle_migrate(self) -> None:
265
+ migrate_path = self.args.migrate
266
+ if migrate_path is not None:
267
+ if not os.path.isabs(migrate_path):
268
+ migrate_path = os.path.join(os.getcwd(), migrate_path)
269
+ mode = "REMOTE" if self._is_remote else "LOCAL"
270
+ if self.args.migration_plan:
271
+ print(
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
+ ),
279
+ )
280
+ )
281
+ return
282
+ if self._is_remote:
283
+ print(
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
+ ),
291
+ )
292
+ )
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)
300
+ if self._is_remote:
301
+ print(
302
+ migration_utils.format_migration_success(
303
+ migrate_path, namespace=self.director.namespace
304
+ )
305
+ )
306
+
307
+ def _handle_log(self) -> None:
308
+ if self.args.log == "not_set":
309
+ self.director.log_production()
310
+ elif self.args.log is not None:
311
+ self.director.log_production_top(int(self.args.log))
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
+
332
+ def _handle_init(self) -> None:
333
+ path = None if self.args.init == "not_set" else self.args.init
334
+ self.director.setup(path)
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
+
399
+ def _handle_help(self) -> None:
400
+ create_parser().print_help()
401
+ if self._is_remote:
402
+ print(f"\nMode: REMOTE ({os.environ.get('IOP_URL', 'via IOP_SETTINGS')})")
403
+ try:
404
+ print(f"\nDefault production: {self.director.get_default_production()}")
405
+ print(f"\nNamespace: {self.director.namespace}")
406
+ except Exception:
407
+ logging.warning("Could not retrieve default production.")
408
+
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)
418
+ try:
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})."
474
+ )
475
+
476
+
477
+ def main(argv=None) -> None:
478
+ parser = create_parser()
479
+ args = parser.parse_args(argv)
480
+ cmd_args = CommandArgs(**vars(args))
481
+
482
+ try:
483
+ command = Command(cmd_args)
484
+ command.execute()
485
+ except requests.exceptions.ConnectionError as exc:
486
+ url = os.environ.get("IOP_URL", "")
487
+ msg = (
488
+ f"Connection error: could not reach {url!r}"
489
+ if url
490
+ else f"Connection error: {exc}"
491
+ )
492
+ print(f"Error: {msg}", file=sys.stderr)
493
+ sys.exit(1)
494
+ except requests.exceptions.HTTPError as exc:
495
+ print(f"Error: {exc}", file=sys.stderr)
496
+ sys.exit(1)
497
+ except (RuntimeError, ValueError) as exc:
498
+ print(f"Error: {exc}", file=sys.stderr)
499
+ sys.exit(1)
500
+ sys.exit(0)
501
+
502
+
503
+ if __name__ == "__main__":
504
+ main()
iop/cli/parser.py ADDED
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+
6
+ def create_parser() -> argparse.ArgumentParser:
7
+ """Create and configure the IOP CLI parser."""
8
+ main_parser = argparse.ArgumentParser(
9
+ epilog=(
10
+ "Remote mode: set IOP_URL (e.g. http://localhost:8080) to run all commands\n"
11
+ "against a remote IRIS instance via its REST API. Optional env vars:\n"
12
+ " IOP_USERNAME, IOP_PASSWORD, IOP_NAMESPACE (default: USER),\n"
13
+ " IOP_VERIFY_SSL (set to 0 to disable TLS verification).\n"
14
+ "Alternatively use -R /path/to/settings.py or set IOP_SETTINGS=\n"
15
+ "(file must contain a REMOTE_SETTINGS dict with at least 'url').\n"
16
+ "Use --force-local to suppress remote mode entirely."
17
+ ),
18
+ formatter_class=argparse.RawDescriptionHelpFormatter,
19
+ )
20
+ parser = main_parser.add_mutually_exclusive_group()
21
+
22
+ parser.add_argument(
23
+ "-d", "--default", help="set the default production", nargs="?", const="not_set"
24
+ )
25
+ parser.add_argument("-l", "--list", help="list productions", action="store_true")
26
+ parser.add_argument(
27
+ "-s", "--start", help="start a production", nargs="?", const="not_set"
28
+ )
29
+ parser.add_argument("-S", "--stop", help="stop a production", action="store_true")
30
+ parser.add_argument("-k", "--kill", help="kill a production", action="store_true")
31
+ parser.add_argument("-r", "--restart", help="restart a production", action="store_true")
32
+ parser.add_argument("-x", "--status", help="status a production", action="store_true")
33
+ parser.add_argument(
34
+ "-m",
35
+ "-M",
36
+ "--migrate",
37
+ help="migrate production and classes with a Python migration file",
38
+ )
39
+ parser.add_argument(
40
+ "-e", "--export", help="export a production", nargs="?", const="not_set"
41
+ )
42
+ parser.add_argument("-v", "--version", help="display version", action="store_true")
43
+ parser.add_argument("-L", "--log", help="display log", nargs="?", const="not_set")
44
+ parser.add_argument(
45
+ "-q",
46
+ "--queue",
47
+ help="display runtime queue information",
48
+ nargs="?",
49
+ const="not_set",
50
+ )
51
+ parser.add_argument(
52
+ "-i", "--init", help="init the iop module in iris", nargs="?", const="not_set"
53
+ )
54
+ parser.add_argument(
55
+ "--bindings",
56
+ help="list IOP-generated IRIS proxy class bindings",
57
+ action="store_true",
58
+ )
59
+ parser.add_argument(
60
+ "--unbind",
61
+ help="remove an IOP-generated IRIS proxy class binding",
62
+ )
63
+ parser.add_argument(
64
+ "-t", "--test", help="test the iop module in iris", nargs="?", const="not_set"
65
+ )
66
+ parser.add_argument("-u", "--update", help="update a production", action="store_true")
67
+ parser.add_argument(
68
+ "--plan",
69
+ help="build a conservative production change plan from a Python settings file",
70
+ )
71
+ parser.add_argument("--review-plan", help="print a saved production change plan")
72
+ parser.add_argument("--apply-plan", help="apply a saved production change plan")
73
+ parser.add_argument("--verify-plan", help="verify a saved production change plan")
74
+ parser.add_argument(
75
+ "--rollback-backup",
76
+ help="restore a production from a plan/apply backup directory",
77
+ )
78
+
79
+ start = main_parser.add_argument_group("start arguments")
80
+ start.add_argument(
81
+ "-D", "--detach", help="start a production in detach mode", action="store_true"
82
+ )
83
+
84
+ test = main_parser.add_argument_group("test arguments")
85
+ test.add_argument(
86
+ "-C", "--classname", help="test classname", nargs="?", const="not_set"
87
+ )
88
+ test.add_argument(
89
+ "-B",
90
+ "--body",
91
+ help="test body (JSON string or @path/to/file.json)",
92
+ nargs="?",
93
+ const="not_set",
94
+ )
95
+
96
+ migrate = main_parser.add_argument_group("migrate arguments")
97
+ migrate.add_argument(
98
+ "--force-local",
99
+ help="force local mode, skip remote even if REMOTE_SETTINGS or IOP_URL is present",
100
+ action="store_true",
101
+ )
102
+ migrate.add_argument(
103
+ "--dry-run",
104
+ "--explain",
105
+ dest="migration_plan",
106
+ help="show the migration plan and validation messages without writing to IRIS",
107
+ action="store_true",
108
+ )
109
+ migrate.add_argument(
110
+ "--strict-production-validation",
111
+ help="fail migration when production validation reports issues",
112
+ action="store_true",
113
+ )
114
+
115
+ export = main_parser.add_argument_group("export arguments")
116
+ export.add_argument(
117
+ "--format",
118
+ dest="export_format",
119
+ choices=("json", "python", "class", "graph", "mermaid"),
120
+ default="json",
121
+ help="export format for -e/--export",
122
+ )
123
+
124
+ bindings = main_parser.add_argument_group("bindings arguments")
125
+ bindings.add_argument(
126
+ "--unused",
127
+ help="with --bindings, show only proxy classes unused by productions",
128
+ action="store_true",
129
+ )
130
+
131
+ plan = main_parser.add_argument_group("production plan arguments")
132
+ plan.add_argument(
133
+ "--production",
134
+ help="target production name for --plan, or override the plan production",
135
+ )
136
+ plan.add_argument("--out", help="write --plan JSON to this path")
137
+ plan.add_argument(
138
+ "--settings",
139
+ help="settings.py file containing the desired Production for --apply-plan",
140
+ )
141
+ plan.add_argument(
142
+ "--backup-dir",
143
+ default=".iop/backups",
144
+ help="directory for apply backup artifacts",
145
+ )
146
+ plan.add_argument(
147
+ "--allow-destructive",
148
+ help="allow destructive plan operations or rollback",
149
+ action="store_true",
150
+ )
151
+
152
+ remote = main_parser.add_argument_group("remote arguments")
153
+ remote.add_argument(
154
+ "-R",
155
+ "--remote-settings",
156
+ help="path to a settings.py containing REMOTE_SETTINGS (overrides IOP_SETTINGS env var)",
157
+ metavar="FILE",
158
+ )
159
+
160
+ namespace = main_parser.add_argument_group("namespace arguments")
161
+ namespace.add_argument(
162
+ "-n", "--namespace", help="set namespace", nargs="?", const="not_set"
163
+ )
164
+
165
+ return main_parser