iris-pex-embedded-python 3.5.6b1__tar.gz → 3.5.6b2__tar.gz

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 (100) hide show
  1. {iris_pex_embedded_python-3.5.6b1/src/iris_pex_embedded_python.egg-info → iris_pex_embedded_python-3.5.6b2}/PKG-INFO +1 -1
  2. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/pyproject.toml +8 -2
  3. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_cli.py +143 -28
  4. iris_pex_embedded_python-3.5.6b2/src/iop/_local.py +79 -0
  5. iris_pex_embedded_python-3.5.6b2/src/iop/_remote.py +293 -0
  6. iris_pex_embedded_python-3.5.6b2/src/iop/cls/IOP/Service/Remote/Rest/v1.cls +374 -0
  7. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2/src/iris_pex_embedded_python.egg-info}/PKG-INFO +1 -1
  8. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iris_pex_embedded_python.egg-info/SOURCES.txt +1 -0
  9. iris_pex_embedded_python-3.5.6b1/src/iop/_remote.py +0 -91
  10. iris_pex_embedded_python-3.5.6b1/src/iop/cls/IOP/Service/Remote/Rest/v1.cls +0 -97
  11. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/LICENSE +0 -0
  12. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/README.md +0 -0
  13. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/setup.cfg +0 -0
  14. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/setup.py +0 -0
  15. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/__init__.py +0 -0
  16. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/BusinessOperation.cls +0 -0
  17. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/BusinessProcess.cls +0 -0
  18. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/BusinessService.cls +0 -0
  19. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/Common.cls +0 -0
  20. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/Director.cls +0 -0
  21. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/Duplex/Operation.cls +0 -0
  22. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/Duplex/Process.cls +0 -0
  23. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/Duplex/Service.cls +0 -0
  24. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/InboundAdapter.cls +0 -0
  25. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/Message.cls +0 -0
  26. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/OutboundAdapter.cls +0 -0
  27. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/PickleMessage.cls +0 -0
  28. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/PrivateSession/Duplex.cls +0 -0
  29. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/PrivateSession/Message/Ack.cls +0 -0
  30. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/PrivateSession/Message/Poll.cls +0 -0
  31. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/PrivateSession/Message/Start.cls +0 -0
  32. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/PrivateSession/Message/Stop.cls +0 -0
  33. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/Test.cls +0 -0
  34. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/PEX/Utils.cls +0 -0
  35. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/cls/Grongier/Service/WSGI.cls +0 -0
  36. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/pex/__init__.py +0 -0
  37. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/pex/__main__.py +0 -0
  38. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/pex/_business_host.py +0 -0
  39. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/pex/_cli.py +0 -0
  40. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/pex/_common.py +0 -0
  41. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/pex/_director.py +0 -0
  42. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/pex/_utils.py +0 -0
  43. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/grongier/pex/wsgi/handlers.py +0 -0
  44. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/__init__.py +0 -0
  45. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/__main__.py +0 -0
  46. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_async_request.py +0 -0
  47. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_business_host.py +0 -0
  48. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_business_operation.py +0 -0
  49. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_business_process.py +0 -0
  50. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_business_service.py +0 -0
  51. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_common.py +0 -0
  52. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_debugpy.py +0 -0
  53. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_decorators.py +0 -0
  54. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_director.py +0 -0
  55. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_dispatch.py +0 -0
  56. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_generator_request.py +0 -0
  57. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_inbound_adapter.py +0 -0
  58. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_iris.py +0 -0
  59. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_log_manager.py +0 -0
  60. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_message.py +0 -0
  61. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_message_validator.py +0 -0
  62. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_outbound_adapter.py +0 -0
  63. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_private_session_duplex.py +0 -0
  64. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_private_session_process.py +0 -0
  65. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_serialization.py +0 -0
  66. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/_utils.py +0 -0
  67. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/BusinessOperation.cls +0 -0
  68. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/BusinessProcess.cls +0 -0
  69. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/BusinessService.cls +0 -0
  70. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Common.cls +0 -0
  71. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Director.cls +0 -0
  72. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Duplex/Operation.cls +0 -0
  73. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Duplex/Process.cls +0 -0
  74. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Duplex/Service.cls +0 -0
  75. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Generator/Message/Ack.cls +0 -0
  76. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Generator/Message/Poll.cls +0 -0
  77. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Generator/Message/Start.cls +0 -0
  78. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Generator/Message/StartPickle.cls +0 -0
  79. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Generator/Message/Stop.cls +0 -0
  80. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/InboundAdapter.cls +0 -0
  81. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Message/JSONSchema.cls +0 -0
  82. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Message.cls +0 -0
  83. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/OutboundAdapter.cls +0 -0
  84. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/PickleMessage.cls +0 -0
  85. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/PrivateSession/Duplex.cls +0 -0
  86. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/PrivateSession/Message/Ack.cls +0 -0
  87. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/PrivateSession/Message/Poll.cls +0 -0
  88. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/PrivateSession/Message/Start.cls +0 -0
  89. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/PrivateSession/Message/Stop.cls +0 -0
  90. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Projection.cls +0 -0
  91. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Service/Remote/Handler.cls +0 -0
  92. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Service/WSGI.cls +0 -0
  93. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Test.cls +0 -0
  94. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Utils.cls +0 -0
  95. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/cls/IOP/Wrapper.cls +0 -0
  96. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iop/wsgi/handlers.py +0 -0
  97. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iris_pex_embedded_python.egg-info/dependency_links.txt +0 -0
  98. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iris_pex_embedded_python.egg-info/entry_points.txt +0 -0
  99. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iris_pex_embedded_python.egg-info/requires.txt +0 -0
  100. {iris_pex_embedded_python-3.5.6b1 → iris_pex_embedded_python-3.5.6b2}/src/iris_pex_embedded_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iris_pex_embedded_python
3
- Version: 3.5.6b1
3
+ Version: 3.5.6b2
4
4
  Summary: Iris Interoperability based on Embedded Python
5
5
  Author-email: grongier <guillaume.rongier@intersystems.com>
6
6
  License: MIT License
@@ -3,7 +3,7 @@ requires = ["setuptools", "wheel"]
3
3
 
4
4
  [project]
5
5
  name = "iris_pex_embedded_python"
6
- version = "3.5.6b1"
6
+ version = "3.5.6b2"
7
7
  description = "Iris Interoperability based on Embedded Python"
8
8
  readme = "README.md"
9
9
  authors = [
@@ -54,4 +54,10 @@ exclude = ["tests*"]
54
54
  "*" = ["*.cls"]
55
55
 
56
56
  [tool.pytest.ini_options]
57
- asyncio_default_fixture_loop_scope = "class"
57
+ asyncio_default_fixture_loop_scope = "class"
58
+ testpaths = ["src/tests"]
59
+ markers = [
60
+ "unit: pure unit test - no IRIS instance required (all IRIS calls mocked)",
61
+ "e2e_local: end-to-end test requiring a local IRIS instance",
62
+ "e2e_remote: end-to-end test requiring a remote IRIS instance via REST API",
63
+ ]
@@ -6,10 +6,11 @@ import os
6
6
  from dataclasses import dataclass
7
7
  from enum import Enum, auto
8
8
  import sys
9
- from typing import Optional, Callable
9
+ from typing import Optional
10
10
  from importlib.metadata import version
11
11
 
12
- from ._director import _Director
12
+ from ._local import _LocalDirector
13
+ from ._remote import _RemoteDirector, get_remote_settings
13
14
  from ._utils import _Utils
14
15
 
15
16
 
@@ -51,6 +52,7 @@ class CommandArgs:
51
52
  body: Optional[str] = None
52
53
  namespace: Optional[str] = None
53
54
  force_local: bool = False
55
+ remote_settings: Optional[str] = None
54
56
  update: bool = False
55
57
 
56
58
  class Command:
@@ -61,6 +63,36 @@ class Command:
61
63
  # set environment variable IRISNAMESPACE
62
64
  os.environ['IRISNAMESPACE'] = self.args.namespace
63
65
 
66
+ # Resolve director: remote when IOP_URL / IOP_SETTINGS env vars are set
67
+ # or when the -m settings.py file contains REMOTE_SETTINGS.
68
+ # --force-local overrides everything and always uses the local director.
69
+ if self.args.force_local:
70
+ self.director: _LocalDirector | _RemoteDirector = _LocalDirector()
71
+ self._is_remote = False
72
+ else:
73
+ # Resolve absolute paths for --remote-settings and -m so
74
+ # get_remote_settings can load them regardless of cwd.
75
+ explicit_path = self.args.remote_settings
76
+ if explicit_path and not os.path.isabs(explicit_path):
77
+ explicit_path = os.path.join(os.getcwd(), explicit_path)
78
+
79
+ migrate_path = self.args.migrate
80
+ if migrate_path and not os.path.isabs(migrate_path):
81
+ migrate_path = os.path.join(os.getcwd(), migrate_path)
82
+
83
+ remote_settings = get_remote_settings(
84
+ explicit_settings_path=explicit_path,
85
+ fallback_settings_path=migrate_path,
86
+ )
87
+ if remote_settings:
88
+ if self.args.namespace and self.args.namespace != 'not_set':
89
+ remote_settings['namespace'] = self.args.namespace
90
+ self.director = _RemoteDirector(remote_settings)
91
+ self._is_remote = True
92
+ else:
93
+ self.director = _LocalDirector()
94
+ self._is_remote = False
95
+
64
96
  def _has_primary_command(self) -> bool:
65
97
  return any([
66
98
  self.args.default,
@@ -125,53 +157,62 @@ class Command:
125
157
 
126
158
  def _handle_default(self) -> None:
127
159
  if self.args.default == 'not_set':
128
- print(_Director.get_default_production())
160
+ print(self.director.get_default_production())
129
161
  elif self.args.default is not None:
130
- _Director.set_default_production(self.args.default)
162
+ self.director.set_default_production(self.args.default)
131
163
 
132
164
  def _handle_list(self) -> None:
133
- dikt = _Director.list_productions()
165
+ dikt = self.director.list_productions()
134
166
  print(json.dumps(dikt, indent=4))
135
167
 
136
168
  def _handle_start(self) -> None:
137
- production_name = self.args.start if self.args.start != 'not_set' else _Director.get_default_production()
169
+ production_name = self.args.start if self.args.start != 'not_set' else self.director.get_default_production()
138
170
  if self.args.detach:
139
- _Director.start_production(production_name)
171
+ self.director.start_production(production_name)
140
172
  print(f"Production {production_name} started")
141
173
  else:
142
- _Director.start_production_with_log(production_name)
174
+ self.director.start_production_with_log(production_name)
143
175
 
144
176
  def _handle_stop(self) -> None:
145
- _Director.stop_production()
146
- print(f"Production {_Director.get_default_production()} stopped")
177
+ self.director.stop_production()
178
+ print(f"Production {self.director.get_default_production()} stopped")
147
179
 
148
180
  def _handle_kill(self) -> None:
149
- _Director.shutdown_production()
181
+ self.director.shutdown_production()
150
182
 
151
183
  def _handle_restart(self) -> None:
152
- _Director.restart_production()
184
+ self.director.restart_production()
153
185
 
154
186
  def _handle_status(self) -> None:
155
- print(json.dumps(_Director.status_production(), indent=4))
187
+ print(json.dumps(self.director.status_production(), indent=4))
156
188
 
157
189
  def _handle_update(self) -> None:
158
- _Director.update_production()
190
+ self.director.update_production()
159
191
 
160
192
  def _handle_test(self) -> None:
161
193
  test_name = None if self.args.test == 'not_set' else self.args.test
162
- response = _Director.test_component(
163
- test_name,
164
- classname=self.args.classname if self.args.classname != 'not_set' else None,
165
- body=self.args.body if self.args.body != 'not_set' else None
194
+ classname = self.args.classname if self.args.classname != 'not_set' else None
195
+ body = self.args.body if self.args.body != 'not_set' else None
196
+
197
+ # Support @filename.json body expansion
198
+ if body and body.startswith('@'):
199
+ filepath = body[1:]
200
+ if not os.path.isabs(filepath):
201
+ filepath = os.path.join(os.getcwd(), filepath)
202
+ with open(filepath, 'r', encoding='utf-8') as fh:
203
+ body = fh.read()
204
+
205
+ response = self.director.test_component(
206
+ test_name, classname=classname, body=body
166
207
  )
167
- print(response)
208
+ print(_format_test_response(response))
168
209
 
169
210
  def _handle_version(self) -> None:
170
211
  print(version('iris-pex-embedded-python'))
171
212
 
172
213
  def _handle_export(self) -> None:
173
- export_name = _Director.get_default_production() if self.args.export == 'not_set' else self.args.export
174
- print(json.dumps(_Utils.export_production(export_name), indent=4))
214
+ export_name = self.director.get_default_production() if self.args.export == 'not_set' else self.args.export
215
+ print(json.dumps(self.director.export_production(export_name), indent=4))
175
216
 
176
217
  def _handle_migrate(self) -> None:
177
218
  migrate_path = self.args.migrate
@@ -182,24 +223,95 @@ class Command:
182
223
 
183
224
  def _handle_log(self) -> None:
184
225
  if self.args.log == 'not_set':
185
- print(_Director.log_production())
226
+ self.director.log_production()
186
227
  elif self.args.log is not None:
187
- print(_Director.log_production_top(int(self.args.log)))
228
+ self.director.log_production_top(int(self.args.log))
188
229
 
189
230
  def _handle_init(self) -> None:
231
+ if self._is_remote:
232
+ logging.warning("'init' is a local-only command and cannot be run remotely.")
233
+ return
190
234
  _Utils.setup(None)
191
235
 
192
236
  def _handle_help(self) -> None:
193
237
  create_parser().print_help()
238
+ if self._is_remote:
239
+ print(f"\nMode: REMOTE ({os.environ.get('IOP_URL', 'via IOP_SETTINGS')})")
194
240
  try:
195
- print(f"\nDefault production: {_Director.get_default_production()}")
196
- print(f"\nNamespace: {os.getenv('IRISNAMESPACE','not set')}")
241
+ print(f"\nDefault production: {self.director.get_default_production()}")
242
+ ns = (self.director._namespace # type: ignore[union-attr]
243
+ if self._is_remote else os.getenv('IRISNAMESPACE', 'not set'))
244
+ print(f"\nNamespace: {ns}")
197
245
  except Exception:
198
246
  logging.warning("Could not retrieve default production.")
199
247
 
248
+ def _format_test_response(response) -> str:
249
+ """Pretty-print any test_component() return value.
250
+
251
+ Handles three cases:
252
+ - dict : remote response with ``classname`` / ``body`` keys
253
+ - str : local response in ``"ClassName : {json}"`` format
254
+ - other : Python dataclass / object returned by the local director
255
+ """
256
+ if isinstance(response, dict):
257
+ parts = []
258
+ if response.get("error"):
259
+ return f"Error: {response['error']}"
260
+ if response.get("classname"):
261
+ parts.append(f"classname: {response['classname']}")
262
+ body = response.get("body", "")
263
+ if body:
264
+ try:
265
+ parsed = json.loads(body)
266
+ parts.append("body:\n" + json.dumps(parsed, indent=4))
267
+ except (json.JSONDecodeError, TypeError):
268
+ parts.append(f"body: {body}")
269
+ if response.get("truncated"):
270
+ parts.append("(response body was truncated)")
271
+ return "\n".join(parts) if parts else str(response)
272
+
273
+ if isinstance(response, str):
274
+ # Try to detect the "ClassName : {json_body}" pattern from local mode
275
+ if " : " in response:
276
+ classname_part, _, body_part = response.partition(" : ")
277
+ try:
278
+ parsed = json.loads(body_part)
279
+ return (
280
+ f"classname: {classname_part.strip()}\n"
281
+ f"body:\n{json.dumps(parsed, indent=4)}"
282
+ )
283
+ except (json.JSONDecodeError, TypeError):
284
+ pass
285
+ # Plain string — try JSON pretty-print
286
+ try:
287
+ return json.dumps(json.loads(response), indent=4)
288
+ except (json.JSONDecodeError, TypeError):
289
+ return response
290
+
291
+ # Python dataclass / arbitrary object
292
+ try:
293
+ import dataclasses
294
+ if dataclasses.is_dataclass(response):
295
+ return json.dumps(dataclasses.asdict(response), indent=4)
296
+ except Exception:
297
+ pass
298
+ return str(response)
299
+
300
+
200
301
  def create_parser() -> argparse.ArgumentParser:
201
302
  """Create and configure argument parser"""
202
- main_parser = argparse.ArgumentParser()
303
+ main_parser = argparse.ArgumentParser(
304
+ epilog=(
305
+ "Remote mode: set IOP_URL (e.g. http://localhost:8080) to run all commands\n"
306
+ "against a remote IRIS instance via its REST API. Optional env vars:\n"
307
+ " IOP_USERNAME, IOP_PASSWORD, IOP_NAMESPACE (default: USER),\n"
308
+ " IOP_VERIFY_SSL (set to 0 to disable TLS verification).\n"
309
+ "Alternatively use -R /path/to/settings.py or set IOP_SETTINGS=\n"
310
+ "(file must contain a REMOTE_SETTINGS dict with at least 'url').\n"
311
+ "Use --force-local to suppress remote mode entirely."
312
+ ),
313
+ formatter_class=argparse.RawDescriptionHelpFormatter,
314
+ )
203
315
  parser = main_parser.add_mutually_exclusive_group()
204
316
 
205
317
  # Main commands
@@ -224,10 +336,13 @@ def create_parser() -> argparse.ArgumentParser:
224
336
 
225
337
  test = main_parser.add_argument_group('test arguments')
226
338
  test.add_argument('-C', '--classname', help='test classname', nargs='?', const='not_set')
227
- test.add_argument('-B', '--body', help='test body', nargs='?', const='not_set')
339
+ test.add_argument('-B', '--body', help='test body (JSON string or @path/to/file.json)', nargs='?', const='not_set')
228
340
 
229
341
  migrate = main_parser.add_argument_group('migrate arguments')
230
- migrate.add_argument('--force-local', help='force local migration, skip remote even if REMOTE_SETTINGS is present', action='store_true')
342
+ migrate.add_argument('--force-local', help='force local mode, skip remote even if REMOTE_SETTINGS or IOP_URL is present', action='store_true')
343
+
344
+ remote = main_parser.add_argument_group('remote arguments')
345
+ remote.add_argument('-R', '--remote-settings', help='path to a settings.py containing REMOTE_SETTINGS (overrides IOP_SETTINGS env var)', metavar='FILE')
231
346
 
232
347
  namespace = main_parser.add_argument_group('namespace arguments')
233
348
  namespace.add_argument('-n', '--namespace', help='set namespace', nargs='?', const='not_set')
@@ -0,0 +1,79 @@
1
+ """Local director: thin instance-method wrapper around static _Director calls.
2
+
3
+ This gives the CLI a uniform interface so it can swap between
4
+ _LocalDirector and _RemoteDirector without any branching.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ from ._director import _Director
12
+ from ._utils import _Utils
13
+
14
+
15
+ class _LocalDirector:
16
+
17
+ # ------------------------------------------------------------------
18
+ # Production lifecycle
19
+ # ------------------------------------------------------------------
20
+
21
+ def get_default_production(self) -> str:
22
+ return _Director.get_default_production()
23
+
24
+ def set_default_production(self, production_name: str = "") -> None:
25
+ _Director.set_default_production(production_name)
26
+
27
+ def list_productions(self) -> dict:
28
+ return _Director.list_productions()
29
+
30
+ def status_production(self) -> dict:
31
+ return _Director.status_production()
32
+
33
+ def start_production(self, production_name: Optional[str] = None) -> None:
34
+ _Director.start_production(production_name)
35
+
36
+ def start_production_with_log(self, production_name: Optional[str] = None) -> None:
37
+ _Director.start_production_with_log(production_name)
38
+
39
+ def stop_production(self) -> None:
40
+ _Director.stop_production()
41
+
42
+ def shutdown_production(self) -> None:
43
+ _Director.shutdown_production()
44
+
45
+ def restart_production(self) -> None:
46
+ _Director.restart_production()
47
+
48
+ def update_production(self) -> None:
49
+ _Director.update_production()
50
+
51
+ # ------------------------------------------------------------------
52
+ # Logging
53
+ # ------------------------------------------------------------------
54
+
55
+ def log_production_top(self, top: int = 10) -> None:
56
+ _Director.log_production_top(top)
57
+
58
+ def log_production(self) -> None:
59
+ _Director.log_production()
60
+
61
+ # ------------------------------------------------------------------
62
+ # Test
63
+ # ------------------------------------------------------------------
64
+
65
+ def test_component(
66
+ self,
67
+ target: Optional[str],
68
+ message=None,
69
+ classname: Optional[str] = None,
70
+ body: Optional[str] = None,
71
+ ):
72
+ return _Director.test_component(target, message, classname, body)
73
+
74
+ # ------------------------------------------------------------------
75
+ # Export
76
+ # ------------------------------------------------------------------
77
+
78
+ def export_production(self, production_name: str) -> dict:
79
+ return _Utils.export_production(production_name)
@@ -0,0 +1,293 @@
1
+ """Remote director: mirrors _Director's interface over the IOP REST API.
2
+
3
+ Configure via environment variables:
4
+ IOP_URL Required. e.g. http://localhost:8080
5
+ IOP_USERNAME Optional. Default: ""
6
+ IOP_PASSWORD Optional. Default: ""
7
+ IOP_NAMESPACE Optional. Default: "USER"
8
+ IOP_VERIFY_SSL Optional. "0"/"false" to disable TLS verification.
9
+
10
+ Or pass a RemoteSettings dict directly (same shape as REMOTE_SETTINGS in settings.py).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import signal
18
+ import time
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ import requests
22
+ import urllib3
23
+
24
+
25
+ class _RemoteDirector:
26
+ """Implements the same interface as _Director but dispatches over HTTP."""
27
+
28
+ def __init__(self, remote_settings: Dict[str, Any]) -> None:
29
+ self._base = remote_settings["url"].rstrip("/") + "/api/iop"
30
+ self._auth = (
31
+ remote_settings.get("username", ""),
32
+ remote_settings.get("password", ""),
33
+ )
34
+ self._namespace: str = remote_settings.get("namespace", "USER")
35
+ self._verify: bool = remote_settings.get("verify_ssl", True)
36
+ if not self._verify:
37
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
38
+
39
+ # ------------------------------------------------------------------
40
+ # Internal helpers
41
+ # ------------------------------------------------------------------
42
+
43
+ def _get(self, path: str, params: Optional[dict] = None) -> Any:
44
+ p = {"namespace": self._namespace, **(params or {})}
45
+ resp = requests.get(
46
+ f"{self._base}{path}", params=p, auth=self._auth,
47
+ verify=self._verify, timeout=30,
48
+ )
49
+ resp.raise_for_status()
50
+ return resp.json()
51
+
52
+ def _post(self, path: str, body: Optional[dict] = None) -> Any:
53
+ b = {"namespace": self._namespace, **(body or {})}
54
+ resp = requests.post(
55
+ f"{self._base}{path}", json=b, auth=self._auth,
56
+ verify=self._verify, timeout=30,
57
+ )
58
+ resp.raise_for_status()
59
+ return resp.json()
60
+
61
+ def _put(self, path: str, body: Optional[dict] = None) -> Any:
62
+ b = {"namespace": self._namespace, **(body or {})}
63
+ resp = requests.put(
64
+ f"{self._base}{path}", json=b, auth=self._auth,
65
+ verify=self._verify, timeout=30,
66
+ )
67
+ resp.raise_for_status()
68
+ return resp.json()
69
+
70
+ def _check_error(self, data: Any) -> Any:
71
+ if isinstance(data, dict) and "error" in data:
72
+ raise RuntimeError(data["error"])
73
+ return data
74
+
75
+ # ------------------------------------------------------------------
76
+ # Production lifecycle
77
+ # ------------------------------------------------------------------
78
+
79
+ def get_default_production(self) -> str:
80
+ data = self._check_error(self._get("/default"))
81
+ return data.get("production") or "Not defined"
82
+
83
+ def set_default_production(self, production_name: str = "") -> None:
84
+ self._check_error(self._put("/default", {"production": production_name}))
85
+
86
+ def list_productions(self) -> dict:
87
+ return self._check_error(self._get("/list"))
88
+
89
+ def status_production(self) -> dict:
90
+ data = self._check_error(self._get("/status"))
91
+ if not data.get("production"):
92
+ data["production"] = self.get_default_production()
93
+ return data
94
+
95
+ def start_production(self, production_name: Optional[str] = None) -> None:
96
+ body: dict = {}
97
+ if production_name:
98
+ body["production"] = production_name
99
+ self._check_error(self._post("/start", body))
100
+
101
+ def start_production_with_log(self, production_name: Optional[str] = None) -> None:
102
+ """Start remotely then stream the log until Ctrl-C (which also stops)."""
103
+ self.start_production(production_name)
104
+ prod = production_name or self.get_default_production()
105
+ print(f"Production '{prod}' started. Streaming log — Ctrl-C to stop.")
106
+ running = True
107
+
108
+ def _sigint(sig, frame): # pragma: no cover
109
+ nonlocal running
110
+ running = False
111
+
112
+ signal.signal(signal.SIGINT, _sigint)
113
+
114
+ last_id = 0
115
+ for entry in self._get_log_entries(top=10):
116
+ _print_log_entry(entry)
117
+ last_id = max(last_id, entry.get("id", 0))
118
+
119
+ while running:
120
+ time.sleep(1)
121
+ entries = self._get_log_entries(since_id=last_id)
122
+ for entry in entries:
123
+ _print_log_entry(entry)
124
+ last_id = max(last_id, entry.get("id", 0))
125
+
126
+ self.stop_production()
127
+
128
+ def stop_production(self) -> None:
129
+ self._check_error(self._post("/stop"))
130
+
131
+ def shutdown_production(self) -> None:
132
+ self._check_error(self._post("/kill"))
133
+
134
+ def restart_production(self) -> dict:
135
+ return self._check_error(self._post("/restart"))
136
+
137
+ def update_production(self) -> dict:
138
+ return self._check_error(self._post("/update"))
139
+
140
+ # ------------------------------------------------------------------
141
+ # Logging
142
+ # ------------------------------------------------------------------
143
+
144
+ def _get_log_entries(
145
+ self,
146
+ top: int = 10,
147
+ since_id: Optional[int] = None,
148
+ ) -> List[dict]:
149
+ params: dict = {}
150
+ if since_id is not None:
151
+ params["since_id"] = since_id
152
+ else:
153
+ params["top"] = top
154
+ data = self._check_error(self._get("/log", params))
155
+ return data if isinstance(data, list) else []
156
+
157
+ def log_production_top(self, top: int = 10) -> None:
158
+ entries = self._get_log_entries(top=top)
159
+ for entry in reversed(entries):
160
+ _print_log_entry(entry)
161
+
162
+ def log_production(self) -> None:
163
+ """Stream log continuously until Ctrl-C."""
164
+ running = True
165
+
166
+ def _sigint(sig, frame): # pragma: no cover
167
+ nonlocal running
168
+ running = False
169
+
170
+ signal.signal(signal.SIGINT, _sigint)
171
+
172
+ last_id = 0
173
+ for entry in self._get_log_entries(top=10):
174
+ _print_log_entry(entry)
175
+ last_id = max(last_id, entry.get("id", 0))
176
+
177
+ while running:
178
+ time.sleep(1)
179
+ entries = self._get_log_entries(since_id=last_id)
180
+ for entry in entries:
181
+ _print_log_entry(entry)
182
+ last_id = max(last_id, entry.get("id", 0))
183
+
184
+ # ------------------------------------------------------------------
185
+ # Test
186
+ # ------------------------------------------------------------------
187
+
188
+ def test_component(
189
+ self,
190
+ target: Optional[str],
191
+ message=None, # ignored remotely — not serialisable over HTTP
192
+ classname: Optional[str] = None,
193
+ body: Optional[str] = None,
194
+ ) -> dict:
195
+ """Returns a dict: {"classname": "...", "body": "...", "truncated": false}"""
196
+ payload: dict = {"target": target or ""}
197
+ if classname:
198
+ payload["classname"] = classname
199
+ if body:
200
+ payload["body"] = body
201
+ try:
202
+ return self._check_error(self._post("/test", payload))
203
+ except requests.exceptions.HTTPError as exc:
204
+ raise RuntimeError(str(exc)) from exc
205
+
206
+ # ------------------------------------------------------------------
207
+ # Export
208
+ # ------------------------------------------------------------------
209
+
210
+ def export_production(self, production_name: str) -> dict:
211
+ import xmltodict # already required by _utils
212
+
213
+ data = self._check_error(
214
+ self._get("/export", {"production": production_name})
215
+ )
216
+ xml = data.get("xml", "")
217
+ if not xml:
218
+ return {}
219
+
220
+ def _postprocessor(path, key, value):
221
+ return key, "" if value is None else value
222
+
223
+ return xmltodict.parse(xml, postprocessor=_postprocessor)
224
+
225
+
226
+ # ------------------------------------------------------------------
227
+ # Shared helpers
228
+ # ------------------------------------------------------------------
229
+
230
+ def _print_log_entry(entry: dict) -> None:
231
+ print(
232
+ entry.get("time_logged", ""),
233
+ entry.get("type", ""),
234
+ entry.get("config_name", ""),
235
+ entry.get("job", ""),
236
+ entry.get("message_id", ""),
237
+ entry.get("session_id", ""),
238
+ entry.get("source_class", ""),
239
+ entry.get("source_method", ""),
240
+ entry.get("text", ""),
241
+ )
242
+
243
+
244
+ def _load_remote_settings_from_file(settings_path: str) -> Optional[Dict[str, Any]]:
245
+ """Load a ``REMOTE_SETTINGS`` dict from an arbitrary settings.py file."""
246
+ try:
247
+ import importlib.util
248
+ spec = importlib.util.spec_from_file_location("_iop_settings_remote", settings_path)
249
+ mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
250
+ spec.loader.exec_module(mod) # type: ignore[union-attr]
251
+ remote = getattr(mod, "REMOTE_SETTINGS", None)
252
+ if isinstance(remote, dict) and "url" in remote:
253
+ return remote
254
+ except Exception:
255
+ pass
256
+ return None
257
+
258
+
259
+ def get_remote_settings(
260
+ explicit_settings_path: Optional[str] = None,
261
+ fallback_settings_path: Optional[str] = None,
262
+ ) -> Optional[Dict[str, Any]]:
263
+ """Detect remote settings from the environment or an explicit file.
264
+
265
+ Priority:
266
+ 1. ``IOP_URL`` env var (direct inline configuration).
267
+ 2. ``explicit_settings_path`` — the file supplied via ``--remote-settings``.
268
+ 3. ``IOP_SETTINGS`` env var pointing to a settings.py with ``REMOTE_SETTINGS``.
269
+ 4. ``fallback_settings_path`` — e.g. the file passed via ``-m settings.py``;
270
+ its ``REMOTE_SETTINGS`` dict is used when present.
271
+ """
272
+ url = os.environ.get("IOP_URL")
273
+ if url:
274
+ verify_raw = os.environ.get("IOP_VERIFY_SSL", "1")
275
+ return {
276
+ "url": url,
277
+ "username": os.environ.get("IOP_USERNAME", ""),
278
+ "password": os.environ.get("IOP_PASSWORD", ""),
279
+ "namespace": os.environ.get("IOP_NAMESPACE", "USER"),
280
+ "verify_ssl": verify_raw.lower() not in ("0", "false"),
281
+ }
282
+
283
+ for path in filter(None, [
284
+ explicit_settings_path,
285
+ os.environ.get("IOP_SETTINGS"),
286
+ fallback_settings_path,
287
+ ]):
288
+ result = _load_remote_settings_from_file(path)
289
+ if result:
290
+ return result
291
+
292
+ return None
293
+