iris-devtester 1.8.1__py3-none-any.whl → 1.9.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.
Files changed (61) hide show
  1. iris_devtester/__init__.py +3 -2
  2. iris_devtester/cli/__init__.py +4 -2
  3. iris_devtester/cli/__main__.py +1 -1
  4. iris_devtester/cli/connection_commands.py +31 -51
  5. iris_devtester/cli/container.py +42 -113
  6. iris_devtester/cli/container_commands.py +6 -4
  7. iris_devtester/cli/fixture_commands.py +97 -73
  8. iris_devtester/config/auto_discovery.py +8 -20
  9. iris_devtester/config/container_config.py +24 -35
  10. iris_devtester/config/container_state.py +19 -43
  11. iris_devtester/config/discovery.py +10 -10
  12. iris_devtester/config/presets.py +3 -10
  13. iris_devtester/config/yaml_loader.py +3 -2
  14. iris_devtester/connections/__init__.py +25 -30
  15. iris_devtester/connections/connection.py +4 -3
  16. iris_devtester/connections/dbapi.py +5 -1
  17. iris_devtester/connections/jdbc.py +2 -6
  18. iris_devtester/connections/manager.py +1 -1
  19. iris_devtester/connections/retry.py +2 -5
  20. iris_devtester/containers/__init__.py +6 -6
  21. iris_devtester/containers/cpf_manager.py +13 -12
  22. iris_devtester/containers/iris_container.py +268 -436
  23. iris_devtester/containers/models.py +18 -43
  24. iris_devtester/containers/monitor_utils.py +1 -3
  25. iris_devtester/containers/monitoring.py +31 -46
  26. iris_devtester/containers/performance.py +5 -5
  27. iris_devtester/containers/validation.py +27 -60
  28. iris_devtester/containers/wait_strategies.py +13 -4
  29. iris_devtester/fixtures/__init__.py +14 -13
  30. iris_devtester/fixtures/creator.py +127 -555
  31. iris_devtester/fixtures/loader.py +221 -78
  32. iris_devtester/fixtures/manifest.py +8 -6
  33. iris_devtester/fixtures/obj_export.py +45 -35
  34. iris_devtester/fixtures/validator.py +4 -7
  35. iris_devtester/integrations/langchain.py +2 -6
  36. iris_devtester/ports/registry.py +5 -4
  37. iris_devtester/testing/__init__.py +3 -0
  38. iris_devtester/testing/fixtures.py +10 -1
  39. iris_devtester/testing/helpers.py +5 -12
  40. iris_devtester/testing/models.py +3 -2
  41. iris_devtester/testing/schema_reset.py +1 -3
  42. iris_devtester/utils/__init__.py +20 -5
  43. iris_devtester/utils/container_port.py +2 -6
  44. iris_devtester/utils/container_status.py +2 -6
  45. iris_devtester/utils/dbapi_compat.py +29 -14
  46. iris_devtester/utils/enable_callin.py +5 -7
  47. iris_devtester/utils/health_checks.py +18 -33
  48. iris_devtester/utils/iris_container_adapter.py +27 -26
  49. iris_devtester/utils/password.py +673 -0
  50. iris_devtester/utils/progress.py +1 -1
  51. iris_devtester/utils/test_connection.py +4 -6
  52. {iris_devtester-1.8.1.dist-info → iris_devtester-1.9.1.dist-info}/METADATA +7 -7
  53. iris_devtester-1.9.1.dist-info/RECORD +66 -0
  54. {iris_devtester-1.8.1.dist-info → iris_devtester-1.9.1.dist-info}/WHEEL +1 -1
  55. iris_devtester/utils/password_reset.py +0 -594
  56. iris_devtester/utils/password_verification.py +0 -350
  57. iris_devtester/utils/unexpire_passwords.py +0 -168
  58. iris_devtester-1.8.1.dist-info/RECORD +0 -68
  59. {iris_devtester-1.8.1.dist-info → iris_devtester-1.9.1.dist-info}/entry_points.txt +0 -0
  60. {iris_devtester-1.8.1.dist-info → iris_devtester-1.9.1.dist-info}/licenses/LICENSE +0 -0
  61. {iris_devtester-1.8.1.dist-info → iris_devtester-1.9.1.dist-info}/top_level.txt +0 -0
@@ -24,16 +24,19 @@ from iris_devtester.testing.schema_reset import (
24
24
  # Compatibility layer for contract tests
25
25
  # -----------------------------------------------------------------
26
26
 
27
+
27
28
  def validate_schema(connection, schema):
28
29
  """Contract‑compatible schema validation wrapper."""
29
30
  # Basic implementation for contract tests
30
31
  return SchemaValidationResult(is_valid=True, mismatches=[])
31
32
 
33
+
32
34
  def register_cleanup(action):
33
35
  """Contract‑compatible cleanup registration wrapper."""
34
36
  # Global state or dummy for contract test
35
37
  return True
36
38
 
39
+
37
40
  # Export fixtures module
38
41
  from . import fixtures
39
42
 
@@ -1,33 +1,42 @@
1
1
  """Pytest fixtures for InterSystems IRIS development."""
2
2
 
3
- import pytest
4
3
  from typing import Any, Generator
5
4
 
5
+ import pytest
6
+
7
+
6
8
  @pytest.fixture
7
9
  def iris_db() -> Generator[Any, None, None]:
8
10
  """Function‑scoped IRIS database fixture."""
9
11
  from iris_devtester.containers import IRISContainer
12
+
10
13
  with IRISContainer.community() as iris:
11
14
  yield iris
12
15
 
16
+
13
17
  @pytest.fixture(scope="module")
14
18
  def iris_db_shared() -> Generator[Any, None, None]:
15
19
  """Module‑scoped shared IRIS database fixture."""
16
20
  from iris_devtester.containers import IRISContainer
21
+
17
22
  with IRISContainer.community() as iris:
18
23
  yield iris
19
24
 
25
+
20
26
  @pytest.fixture
21
27
  def iris_container() -> Generator[Any, None, None]:
22
28
  """Raw IRIS container fixture."""
23
29
  from iris_devtester.containers import IRISContainer
30
+
24
31
  with IRISContainer.community() as iris:
25
32
  yield iris
26
33
 
34
+
27
35
  # Compatibility for contract tests that inspect internal pytest attributes
28
36
  class FixtureInfo:
29
37
  def __init__(self, scope):
30
38
  self.scope = scope
31
39
 
40
+
32
41
  for f, s in [(iris_db, "function"), (iris_db_shared, "module"), (iris_container, "function")]:
33
42
  setattr(f, "_pytestfixturefunction", FixtureInfo(s))
@@ -36,16 +36,14 @@ def measure_verification_time(operation_name: str = "operation"):
36
36
  yield timing
37
37
 
38
38
  timing["elapsed_seconds"] = time.time() - timing["start_time"]
39
- logger.debug(
40
- f"Completed {operation_name} in {timing['elapsed_seconds']:.2f}s"
41
- )
39
+ logger.debug(f"Completed {operation_name} in {timing['elapsed_seconds']:.2f}s")
42
40
 
43
41
 
44
42
  def assert_within_timeout(
45
43
  elapsed_seconds: float,
46
44
  timeout_seconds: float,
47
45
  operation_name: str = "operation",
48
- grace_period_seconds: float = 0.5
46
+ grace_period_seconds: float = 0.5,
49
47
  ):
50
48
  """
51
49
  Assert that operation completed within timeout.
@@ -84,8 +82,7 @@ def assert_within_timeout(
84
82
 
85
83
 
86
84
  def simulate_delayed_password_propagation(
87
- delay_seconds: float,
88
- callback: Optional[Callable] = None
85
+ delay_seconds: float, callback: Optional[Callable] = None
89
86
  ):
90
87
  """
91
88
  Simulate password propagation delay for testing.
@@ -135,10 +132,7 @@ def calculate_success_rate(successes: int, total: int) -> float:
135
132
 
136
133
 
137
134
  def assert_success_rate_meets_target(
138
- successes: int,
139
- total: int,
140
- target_rate: float,
141
- operation_name: str = "operation"
135
+ successes: int, total: int, target_rate: float, operation_name: str = "operation"
142
136
  ):
143
137
  """
144
138
  Assert that success rate meets or exceeds target.
@@ -169,6 +163,5 @@ def assert_success_rate_meets_target(
169
163
  )
170
164
 
171
165
  logger.info(
172
- f"{operation_name} success rate: {actual_rate:.1f}% "
173
- f"(meets target {target_rate:.1f}%)"
166
+ f"{operation_name} success rate: {actual_rate:.1f}% " f"(meets target {target_rate:.1f}%)"
174
167
  )
@@ -9,7 +9,6 @@ from dataclasses import dataclass, field
9
9
  from datetime import datetime
10
10
  from typing import Any, Dict, List, Literal, Optional
11
11
 
12
-
13
12
  # Schema Definition Models (T014)
14
13
 
15
14
 
@@ -183,7 +182,9 @@ class PasswordResetResult:
183
182
  message = f"Password reset failed: {self.error}\n"
184
183
  if self.remediation_steps:
185
184
  message += "\nManual remediation steps:\n"
186
- message += "\n".join(f" {i+1}. {step}" for i, step in enumerate(self.remediation_steps))
185
+ message += "\n".join(
186
+ f" {i+1}. {step}" for i, step in enumerate(self.remediation_steps)
187
+ )
187
188
  return message
188
189
 
189
190
 
@@ -227,9 +227,7 @@ def cleanup_test_data(connection: Any, test_id: str) -> int:
227
227
  cursor.execute(f"DELETE FROM {table} WHERE test_id = ?", (test_id,))
228
228
  deleted = cursor.rowcount
229
229
  if deleted > 0:
230
- logger.debug(
231
- f"Deleted {deleted} row(s) from {table} for test_id={test_id}"
232
- )
230
+ logger.debug(f"Deleted {deleted} row(s) from {table} for test_id={test_id}")
233
231
  cleaned_count += 1
234
232
 
235
233
  except Exception as e:
@@ -1,24 +1,39 @@
1
1
  """Utility functions and helpers."""
2
2
 
3
- from iris_devtester.utils.password_reset import (
3
+ from iris_devtester.utils.container_status import get_container_status
4
+ from iris_devtester.utils.enable_callin import enable_callin_service
5
+ from iris_devtester.utils.password import (
6
+ ErrorType,
7
+ PasswordResetResult,
8
+ PasswordResult,
9
+ VerificationConfig,
10
+ VerificationResult,
4
11
  detect_password_change_required,
5
12
  reset_password,
6
13
  reset_password_if_needed,
7
- )
8
- from iris_devtester.utils.unexpire_passwords import (
9
14
  unexpire_all_passwords,
10
15
  unexpire_passwords_for_containers,
16
+ verify_password,
17
+ verify_password_via_connection,
11
18
  )
12
- from iris_devtester.utils.enable_callin import enable_callin_service
13
19
  from iris_devtester.utils.test_connection import test_connection
14
- from iris_devtester.utils.container_status import get_container_status
15
20
 
16
21
  __all__ = [
22
+ # Password utilities (consolidated)
17
23
  "detect_password_change_required",
18
24
  "reset_password",
19
25
  "reset_password_if_needed",
20
26
  "unexpire_all_passwords",
21
27
  "unexpire_passwords_for_containers",
28
+ "verify_password",
29
+ "verify_password_via_connection",
30
+ # Password result types
31
+ "ErrorType",
32
+ "PasswordResetResult",
33
+ "PasswordResult",
34
+ "VerificationConfig",
35
+ "VerificationResult",
36
+ # Other utilities
22
37
  "enable_callin_service",
23
38
  "test_connection",
24
39
  "get_container_status",
@@ -50,9 +50,7 @@ def get_container_port(
50
50
  str(container_port),
51
51
  ]
52
52
 
53
- result = subprocess.run(
54
- cmd, capture_output=True, text=True, timeout=timeout
55
- )
53
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
56
54
 
57
55
  if result.returncode == 0 and result.stdout.strip():
58
56
  # Parse output like "0.0.0.0:55000" or "0.0.0.0:55000\n:::55000"
@@ -66,9 +64,7 @@ def get_container_port(
66
64
  return host_port
67
65
  else:
68
66
  # No port mapping found - container might use fixed ports or not expose this port
69
- logger.debug(
70
- f"No port mapping found for {container_name}:{container_port}"
71
- )
67
+ logger.debug(f"No port mapping found for {container_name}:{container_port}")
72
68
  return None
73
69
 
74
70
  except subprocess.TimeoutExpired:
@@ -70,9 +70,7 @@ def get_container_status(
70
70
  "{{.Names}}",
71
71
  ]
72
72
 
73
- result = subprocess.run(
74
- check_cmd, capture_output=True, text=True, timeout=10
75
- )
73
+ result = subprocess.run(check_cmd, capture_output=True, text=True, timeout=10)
76
74
 
77
75
  if container_name in result.stdout:
78
76
  status_lines.append("Running: ✓ Yes")
@@ -98,9 +96,7 @@ def get_container_status(
98
96
  container_name,
99
97
  ]
100
98
 
101
- result = subprocess.run(
102
- health_cmd, capture_output=True, text=True, timeout=10
103
- )
99
+ result = subprocess.run(health_cmd, capture_output=True, text=True, timeout=10)
104
100
 
105
101
  health_status = result.stdout.strip()
106
102
  if health_status and health_status != "<no value>":
@@ -26,11 +26,13 @@ Logging Levels:
26
26
  - DEBUG: Fallback attempts (modern → legacy)
27
27
  - ERROR: No package available
28
28
  """
29
+
30
+ import importlib.metadata
29
31
  import logging
30
32
  import time
31
- import importlib.metadata
32
33
  from dataclasses import dataclass
33
- from typing import Callable, Any
34
+ from typing import Any, Callable, Optional
35
+
34
36
  from packaging import version
35
37
 
36
38
  # Configure module logger
@@ -136,11 +138,23 @@ def detect_dbapi_package() -> DBAPIPackageInfo:
136
138
  # Try modern package first (priority per Principle #2)
137
139
  # CRITICAL: Use official iris.connect() API, NOT private _DBAPI attribute!
138
140
  # See CONSTITUTION.md Principle 8 for empirical evidence that _DBAPI does not exist.
141
+ modern_available = False
139
142
  try:
143
+ import os
144
+ import sys
145
+
140
146
  import iris
147
+
148
+ modern_available = True
149
+ except ImportError as e:
150
+ logger.debug(f"Modern package not available, trying legacy: {e}")
151
+
152
+ if modern_available:
141
153
  import os
142
154
  import sys
143
155
 
156
+ import iris
157
+
144
158
  # Check if connect method is available
145
159
  if not hasattr(iris, "connect"):
146
160
  # Workaround for pytest module caching issue (from iris-vector-rag v0.5.13)
@@ -150,17 +164,17 @@ def detect_dbapi_package() -> DBAPIPackageInfo:
150
164
  logger.debug("iris.connect() not found, attempting to load ELSDK manually")
151
165
 
152
166
  # Get iris module directory
153
- if hasattr(iris, '__file__') and iris.__file__:
167
+ if hasattr(iris, "__file__") and iris.__file__:
154
168
  iris_dir = os.path.dirname(iris.__file__)
155
169
 
156
170
  # Try both _elsdk_.py (v5.3.0+) and _init_elsdk.py (v5.1.2)
157
- for elsdk_file in ['_elsdk_.py', '_init_elsdk.py']:
171
+ for elsdk_file in ["_elsdk_.py", "_init_elsdk.py"]:
158
172
  elsdk_path = os.path.join(iris_dir, elsdk_file)
159
173
  if os.path.exists(elsdk_path):
160
174
  logger.info(f"Found {elsdk_file}, loading to inject DBAPI interface")
161
175
  try:
162
- with open(elsdk_path, 'r') as f:
163
- elsdk_code = compile(f.read(), elsdk_path, 'exec')
176
+ with open(elsdk_path, "r") as f:
177
+ elsdk_code = compile(f.read(), elsdk_path, "exec")
164
178
  exec(elsdk_code, iris.__dict__)
165
179
 
166
180
  if hasattr(iris, "connect"):
@@ -187,10 +201,8 @@ def detect_dbapi_package() -> DBAPIPackageInfo:
187
201
  import_path="iris",
188
202
  version=pkg_version,
189
203
  connect_function=iris.connect,
190
- detection_time_ms=elapsed_ms
204
+ detection_time_ms=elapsed_ms,
191
205
  )
192
- except ImportError as e:
193
- logger.debug(f"Modern package not available, trying legacy: {e}")
194
206
 
195
207
  # Fall back to legacy package
196
208
  try:
@@ -208,7 +220,7 @@ def detect_dbapi_package() -> DBAPIPackageInfo:
208
220
  import_path="iris.irissdk",
209
221
  version=pkg_version,
210
222
  connect_function=iris.irissdk.connect,
211
- detection_time_ms=elapsed_ms
223
+ detection_time_ms=elapsed_ms,
212
224
  )
213
225
  except ImportError:
214
226
  logger.error("No IRIS DBAPI package detected")
@@ -224,12 +236,15 @@ class DBAPIConnectionAdapter:
224
236
  Implements singleton pattern for zero overhead.
225
237
  """
226
238
 
239
+ _package_info: DBAPIPackageInfo
240
+
227
241
  def __init__(self):
228
242
  """Initialize adapter with detected package info."""
229
243
  self._package_info = detect_dbapi_package()
230
244
 
231
- def connect(self, hostname: str, port: int, namespace: str,
232
- username: str, password: str, **kwargs) -> Any:
245
+ def connect(
246
+ self, hostname: str, port: int, namespace: str, username: str, password: str, **kwargs
247
+ ) -> Any:
233
248
  """Create DBAPI connection using detected package.
234
249
 
235
250
  Args:
@@ -251,7 +266,7 @@ class DBAPIConnectionAdapter:
251
266
  namespace=namespace,
252
267
  username=username,
253
268
  password=password,
254
- **kwargs
269
+ **kwargs,
255
270
  )
256
271
 
257
272
  def get_package_info(self) -> DBAPIPackageInfo:
@@ -264,7 +279,7 @@ class DBAPIConnectionAdapter:
264
279
 
265
280
 
266
281
  # Global singleton adapter (cached for performance)
267
- _adapter: DBAPIConnectionAdapter | None = None
282
+ _adapter: Optional[DBAPIConnectionAdapter] = None
268
283
 
269
284
 
270
285
  def _get_adapter() -> DBAPIConnectionAdapter:
@@ -76,9 +76,7 @@ def enable_callin_service(
76
76
  "{{.Names}}",
77
77
  ]
78
78
 
79
- result = subprocess.run(
80
- check_cmd, capture_output=True, text=True, timeout=timeout
81
- )
79
+ result = subprocess.run(check_cmd, capture_output=True, text=True, timeout=timeout)
82
80
 
83
81
  if container_name not in result.stdout:
84
82
  return (
@@ -116,7 +114,7 @@ def enable_callin_service(
116
114
  container_name,
117
115
  "bash",
118
116
  "-c",
119
- f'''echo "set prop(\\"Enabled\\")=1 set prop(\\"AutheEnabled\\")={DEFAULT_AUTHENABLED} write ##class(Security.Services).Modify(\\"%Service_CallIn\\",.prop)" | iris session IRIS -U %SYS''',
117
+ f"""echo "set prop(\\"Enabled\\")=1 set prop(\\"AutheEnabled\\")={DEFAULT_AUTHENABLED} write ##class(Security.Services).Modify(\\"%Service_CallIn\\",.prop)" | iris session IRIS -U %SYS""",
120
118
  ]
121
119
 
122
120
  result = subprocess.run(
@@ -163,9 +161,9 @@ def enable_callin_service(
163
161
  "\n"
164
162
  " 2. Try manually enabling CallIn:\n"
165
163
  f" docker exec -it {container_name} iris session IRIS -U %SYS\n"
166
- " Do ##class(Security.Services).Get(\"%Service_CallIn\",.prop)\n"
167
- " Set prop(\"Enabled\")=1\n"
168
- " Do ##class(Security.Services).Modify(\"%Service_CallIn\",.prop)\n"
164
+ ' Do ##class(Security.Services).Get("%Service_CallIn",.prop)\n'
165
+ ' Set prop("Enabled")=1\n'
166
+ ' Do ##class(Security.Services).Modify("%Service_CallIn",.prop)\n'
169
167
  "\n"
170
168
  " 3. Check IRIS license (Community vs Enterprise):\n"
171
169
  " docker exec {container_name} iris list\n"
@@ -68,7 +68,7 @@ _IRIS_STATE_MESSAGES = {
68
68
  def wait_for_healthy(
69
69
  container: Container,
70
70
  timeout: int = 60,
71
- progress_callback: Optional[Callable[[str], None]] = None
71
+ progress_callback: Optional[Callable[[str], None]] = None,
72
72
  ) -> ContainerState:
73
73
  """
74
74
  Wait for container to be fully healthy using multi-layer validation.
@@ -158,17 +158,13 @@ def wait_for_healthy(
158
158
  output = last_log.get("Output", "No output")
159
159
 
160
160
  raise RuntimeError(
161
- f"Container health check failed\n"
162
- f"\n"
163
- f"Health check output:\n{output}"
161
+ f"Container health check failed\n" f"\n" f"Health check output:\n{output}"
164
162
  )
165
163
 
166
164
  time.sleep(2)
167
165
 
168
166
  if elapsed() >= timeout:
169
- raise TimeoutError(
170
- f"Container health check did not pass within {timeout} seconds"
171
- )
167
+ raise TimeoutError(f"Container health check did not pass within {timeout} seconds")
172
168
  else:
173
169
  notify("⚠ No Docker health check defined, skipping Layer 2")
174
170
 
@@ -190,10 +186,7 @@ def wait_for_healthy(
190
186
  else:
191
187
  while elapsed() < timeout:
192
188
  try:
193
- sock = socket.create_connection(
194
- ("localhost", superserver_host_port),
195
- timeout=2
196
- )
189
+ sock = socket.create_connection(("localhost", superserver_host_port), timeout=2)
197
190
  sock.close()
198
191
  notify(f"✓ IRIS SuperServer port {superserver_host_port} is accessible")
199
192
  break
@@ -201,9 +194,7 @@ def wait_for_healthy(
201
194
  time.sleep(2)
202
195
 
203
196
  if elapsed() >= timeout:
204
- raise TimeoutError(
205
- f"IRIS SuperServer port not accessible within {timeout} seconds"
206
- )
197
+ raise TimeoutError(f"IRIS SuperServer port not accessible within {timeout} seconds")
207
198
 
208
199
  # Layer 4: Wait for IRIS Monitor.State() to be OK
209
200
  # This is the official IRIS health check - more reliable than just port check
@@ -346,9 +337,7 @@ def wait_for_port(port: int, host: str = "localhost", timeout: int = 60) -> None
346
337
 
347
338
  time.sleep(1)
348
339
 
349
- raise TimeoutError(
350
- f"Port {port} on {host} did not become accessible within {timeout} seconds"
351
- )
340
+ raise TimeoutError(f"Port {port} on {host} did not become accessible within {timeout} seconds")
352
341
 
353
342
 
354
343
  def get_container_logs(container: Container, tail: int = 100) -> str:
@@ -390,17 +379,14 @@ def enable_callin_service(container: Container) -> None:
390
379
  """
391
380
  # Execute ObjectScript to enable CallIn
392
381
  objectscript_cmd = (
393
- 'iris session iris -U%SYS '
394
- '"Do ##class(Security.Services).Get(\"%Service_CallIn\", .service) '
395
- 'Set service.Enabled = 1 '
396
- 'Do ##class(Security.Services).Modify(\"%Service_CallIn\", .service)"'
382
+ "iris session iris -U%SYS "
383
+ '"Do ##class(Security.Services).Get("%Service_CallIn", .service) '
384
+ "Set service.Enabled = 1 "
385
+ 'Do ##class(Security.Services).Modify("%Service_CallIn", .service)"'
397
386
  )
398
387
 
399
388
  try:
400
- exit_code, output = container.exec_run(
401
- cmd=["sh", "-c", objectscript_cmd],
402
- user="irisowner"
403
- )
389
+ exit_code, output = container.exec_run(cmd=["sh", "-c", objectscript_cmd], user="irisowner")
404
390
 
405
391
  if exit_code != 0:
406
392
  raise RuntimeError(
@@ -454,16 +440,13 @@ def check_iris_monitor_state(container: Container) -> IrisMonitorResult:
454
440
  # ObjectScript command to get Monitor state
455
441
  # Use $SYSTEM.Monitor.State() which returns 0=OK, 1=Warning, 2=Error, 3=Fatal
456
442
  # Note: ##class(%SYSTEM.System).GetInstanceState() does NOT exist in Community Edition
457
- objectscript_cmd = '''iris session IRIS -U %SYS << 'EOF'
443
+ objectscript_cmd = """iris session IRIS -U %SYS << 'EOF'
458
444
  Write $SYSTEM.Monitor.State()
459
445
  Halt
460
- EOF'''
446
+ EOF"""
461
447
 
462
448
  try:
463
- exit_code, output = container.exec_run(
464
- cmd=["sh", "-c", objectscript_cmd],
465
- user="irisowner"
466
- )
449
+ exit_code, output = container.exec_run(cmd=["sh", "-c", objectscript_cmd], user="irisowner")
467
450
 
468
451
  raw_output = output.decode("utf-8", errors="ignore")
469
452
 
@@ -479,7 +462,9 @@ EOF'''
479
462
  # -1 means "monitoring not configured" - treat as healthy since container is running
480
463
  # The output may contain prompts/banners, so look for the number on its own line
481
464
  # Check for -1 first (monitoring unconfigured), then 0-3
482
- match = re.search(r'(?:^|\n)(-1)\s*(?:\n|$)', raw_output) or re.search(r'(?:^|\n)([0-3])\s*(?:\n|$)', raw_output)
465
+ match = re.search(r"(?:^|\n)(-1)\s*(?:\n|$)", raw_output) or re.search(
466
+ r"(?:^|\n)([0-3])\s*(?:\n|$)", raw_output
467
+ )
483
468
  if match:
484
469
  state_value = int(match.group(1))
485
470
  # Handle -1 (monitoring unconfigured) as OK since container is running
@@ -518,7 +503,7 @@ EOF'''
518
503
  def wait_for_iris_healthy(
519
504
  container: Container,
520
505
  timeout: int = 60,
521
- progress_callback: Optional[Callable[[str], None]] = None
506
+ progress_callback: Optional[Callable[[str], None]] = None,
522
507
  ) -> bool:
523
508
  """Wait for IRIS container to reach healthy state.
524
509
 
@@ -167,9 +167,7 @@ class ContainerPersistenceCheck:
167
167
 
168
168
 
169
169
  def verify_container_persistence(
170
- container_name: str,
171
- config: ContainerConfig,
172
- wait_seconds: float = 2.0
170
+ container_name: str, config: ContainerConfig, wait_seconds: float = 2.0
173
171
  ) -> ContainerPersistenceCheck:
174
172
  """
175
173
  Verify that container persists after creation (Feature 011 - T014).
@@ -214,7 +212,11 @@ def verify_container_persistence(
214
212
  status=container.status,
215
213
  volume_mounts_verified=volumes_ok,
216
214
  verification_time=wait_seconds,
217
- error_details=None if volumes_ok else f"Expected {expected_volumes} volumes, found {actual_volumes}"
215
+ error_details=(
216
+ None
217
+ if volumes_ok
218
+ else f"Expected {expected_volumes} volumes, found {actual_volumes}"
219
+ ),
218
220
  )
219
221
 
220
222
  except NotFound:
@@ -224,7 +226,7 @@ def verify_container_persistence(
224
226
  status=None,
225
227
  volume_mounts_verified=False,
226
228
  verification_time=wait_seconds,
227
- error_details="Container not found after creation (possibly removed by ryuk or failed to start)"
229
+ error_details="Container not found after creation (possibly removed by ryuk or failed to start)",
228
230
  )
229
231
 
230
232
  except Exception as e:
@@ -234,7 +236,7 @@ def verify_container_persistence(
234
236
  status=None,
235
237
  volume_mounts_verified=False,
236
238
  verification_time=wait_seconds,
237
- error_details=f"Verification error: {str(e)}"
239
+ error_details=f"Verification error: {str(e)}",
238
240
  )
239
241
 
240
242
  finally:
@@ -245,10 +247,7 @@ class IRISContainerManager:
245
247
  """Manager for IRIS containers using testcontainers-iris."""
246
248
 
247
249
  @staticmethod
248
- def create_from_config(
249
- config: ContainerConfig,
250
- use_testcontainers: bool = True
251
- ) -> Container:
250
+ def create_from_config(config: ContainerConfig, use_testcontainers: bool = True) -> Container:
252
251
  """
253
252
  Create IRIS container from config with dual-mode support (Feature 011 - T012).
254
253
 
@@ -287,7 +286,7 @@ class IRISContainerManager:
287
286
  username="_SYSTEM", # IRIS default user
288
287
  password=config.password,
289
288
  namespace=config.namespace,
290
- license_key=config.license_key if config.edition == "enterprise" else None
289
+ license_key=config.license_key if config.edition == "enterprise" else None,
291
290
  )
292
291
 
293
292
  # Configure container name
@@ -321,34 +320,32 @@ class IRISContainerManager:
321
320
  volumes = {}
322
321
  for volume_str in config.volumes:
323
322
  spec = VolumeMountSpec.parse(volume_str)
324
- volumes[spec.host_path] = {
325
- 'bind': spec.container_path,
326
- 'mode': spec.mode
327
- }
323
+ volumes[spec.host_path] = {"bind": spec.container_path, "mode": spec.mode}
328
324
 
329
325
  # IRIS environment variables (Feature 011 - T012)
330
326
  # Note: Don't set ISC_DATA_DIRECTORY - let IRIS use its default
331
327
  environment = {}
332
328
 
333
329
  # Add license key for Enterprise edition
334
- if config.edition == 'enterprise' and config.license_key:
335
- environment['ISC_LICENSE_KEY'] = config.license_key
330
+ if config.edition == "enterprise" and config.license_key:
331
+ environment["ISC_LICENSE_KEY"] = config.license_key
336
332
 
337
333
  if config.cpf_merge:
338
334
  import os
339
335
  import tempfile
336
+
340
337
  container_path = "/usr/irissys/merge.cpf"
341
- environment['ISC_CPF_MERGE_FILE'] = container_path
342
-
338
+ environment["ISC_CPF_MERGE_FILE"] = container_path
339
+
343
340
  cpf_source = config.cpf_merge
344
341
  if os.path.exists(cpf_source) and os.path.isfile(cpf_source):
345
342
  host_path = os.path.abspath(cpf_source)
346
343
  else:
347
- with tempfile.NamedTemporaryFile(mode='w', suffix='.cpf', delete=False) as f:
344
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".cpf", delete=False) as f:
348
345
  f.write(cpf_source)
349
346
  host_path = f.name
350
-
351
- volumes[host_path] = {'bind': container_path, 'mode': 'ro'}
347
+
348
+ volumes[host_path] = {"bind": container_path, "mode": "ro"}
352
349
 
353
350
  # Create container without testcontainers labels (prevents ryuk cleanup)
354
351
  container = client.containers.create(
@@ -356,11 +353,11 @@ class IRISContainerManager:
356
353
  name=config.container_name,
357
354
  volumes=volumes or None,
358
355
  ports={
359
- f'{config.superserver_port}/tcp': config.superserver_port,
360
- f'{config.webserver_port}/tcp': config.webserver_port
356
+ f"{config.superserver_port}/tcp": config.superserver_port,
357
+ f"{config.webserver_port}/tcp": config.webserver_port,
361
358
  },
362
359
  environment=environment,
363
- detach=True
360
+ detach=True,
364
361
  )
365
362
 
366
363
  # Start the container
@@ -514,7 +511,11 @@ def translate_docker_error(error: Exception, config: Optional[ContainerConfig])
514
511
  )
515
512
 
516
513
  # Image not found
517
- if "image not found" in error_str or "manifest unknown" in error_str or "no such image" in error_str:
514
+ if (
515
+ "image not found" in error_str
516
+ or "manifest unknown" in error_str
517
+ or "no such image" in error_str
518
+ ):
518
519
  image = config.get_image_name() if config else "unknown"
519
520
  return ValueError(
520
521
  f"Docker image '{image}' not found\n"