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
@@ -1,487 +1,319 @@
1
- """
2
- Enhanced IRIS container wrapper.
3
-
4
- Extends testcontainers-iris-python with automatic connection management,
5
- password reset, and better wait strategies.
6
- """
7
-
8
- import hashlib
9
1
  import logging
10
2
  import os
11
- import platform as platform_module
12
3
  import subprocess
13
4
  import time
14
- from typing import Any, Optional, TYPE_CHECKING, Union
15
-
16
- from iris_devtester.config.models import IRISConfig
17
- from iris_devtester.connections.manager import get_connection
18
- from iris_devtester.utils.password_reset import reset_password_if_needed
19
- from iris_devtester.containers.wait_strategies import IRISReadyWaitStrategy
20
- from iris_devtester.containers.monitoring import (
21
- MonitoringPolicy,
22
- configure_monitoring,
23
- disable_monitoring,
24
- )
25
- from iris_devtester.containers.performance import get_resource_metrics
26
-
27
- if TYPE_CHECKING:
28
- from iris_devtester.ports.registry import PortRegistry
29
- from iris_devtester.containers.models import HealthCheckLevel, ValidationResult
30
- from iris_devtester.config.container_config import ContainerConfig
5
+ from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union
6
+
7
+ from iris_devtester.config import IRISConfig, discover_config
8
+ from iris_devtester.connections import get_connection
31
9
 
32
10
  logger = logging.getLogger(__name__)
33
11
 
34
- try:
35
- from testcontainers.iris import IRISContainer as BaseIRISContainer
36
- HAS_TESTCONTAINERS_IRIS = True
37
- except ImportError:
38
- logger.warning("testcontainers-iris-python not installed.")
39
- HAS_TESTCONTAINERS_IRIS = False
40
12
 
41
- class BaseIRISContainer:
42
- def __init__(self, image: str = "", **kwargs):
43
- self.image = image
44
- self.port = 1972
45
- def __enter__(self): return self
46
- def __exit__(self, *args): pass
47
- def start(self):
48
- logger.info("Mock IRIS container started (test mode)")
49
- return self
50
- def stop(self, *args, **kwargs): pass
13
+ # Single base class definition to satisfy LSP
14
+ class _IRISMockContainer:
15
+ def __init__(self, image: str = "", **kwargs):
16
+ self.image = image
17
+ self._container = None
18
+
19
+ def start(self):
20
+ return self
21
+
22
+ def stop(self, *args, **kwargs):
23
+ pass
24
+
25
+ def get_container_host_ip(self) -> str:
26
+ return "localhost"
27
+
28
+ def get_exposed_port(self, port: int) -> int:
29
+ return port
30
+
31
+ def with_env(self, key: str, value: str):
32
+ return self
33
+
34
+ def __enter__(self):
35
+ return self
36
+
37
+ def __exit__(self, *args):
38
+ pass
39
+
40
+ def get_container_name(self) -> str:
41
+ return "iris_db"
42
+
51
43
 
44
+ # Select the base class. We use Any type to bypass strict type check on the class itself.
45
+ IRISBase: Any = _IRISMockContainer
52
46
 
47
+ # Check for testcontainers
48
+ HAS_TESTCONTAINERS = False
49
+ try:
50
+ from testcontainers.iris import IRISContainer as _ActualBase
53
51
 
54
- def get_wrapped_container(self): return None
55
- def with_env(self, key: str, value: str): return self
56
- def with_volume_mapping(self, host: str, container: str, mode: str = "rw"): return self
57
- def with_bind_ports(self, container: int, host: int): return self
58
- def with_name(self, name: str): return self
59
- def get_container_host_ip(self): return "localhost"
60
- def get_exposed_port(self, port: int): return port
52
+ IRISBase = _ActualBase
53
+ HAS_TESTCONTAINERS = True
54
+ except ImportError:
55
+ pass
61
56
 
62
57
 
63
- class IRISContainer(BaseIRISContainer):
64
- """Enhanced IRIS container with automatic connection and password reset."""
58
+ class IRISContainer(IRISBase):
59
+ """
60
+ Enhanced IRIS container with automatic connection and password management.
61
+ """
65
62
 
66
63
  def __init__(
67
64
  self,
68
65
  image: str = "intersystemsdc/iris-community:latest",
69
- port_registry: Optional["PortRegistry"] = None,
70
- project_path: Optional[str] = None,
71
- preferred_port: Optional[int] = None,
66
+ username: str = "SuperUser",
67
+ password: str = "SYS",
68
+ namespace: str = "USER",
72
69
  **kwargs,
73
70
  ):
74
- if not HAS_TESTCONTAINERS_IRIS:
75
- raise ImportError("testcontainers-iris-python not installed")
76
-
77
- # Extract known args for BaseIRISContainer if it's the mock one
78
- base_kwargs = {}
79
- if not HAS_TESTCONTAINERS_IRIS:
80
- base_kwargs = {k: v for k, v in kwargs.items() if k in ["username", "password", "namespace"]}
81
-
71
+ if not HAS_TESTCONTAINERS:
72
+ logger.warning("testcontainers not installed. Functionality will be limited.")
73
+
82
74
  super().__init__(image=image, **kwargs)
75
+ self._username = username
76
+ self._password = password
77
+ self._namespace = namespace
83
78
  self._connection = None
84
- self._config = None
85
79
  self._callin_enabled = False
80
+ self._password_preconfigured = False
86
81
  self._is_attached = False
87
- self._port_registry = port_registry
88
- self._port_assignment = None
89
- self._preferred_port = preferred_port
90
- self._cpf_manager = None
91
- self._cpf_merge_path = None
92
- self._project_path = project_path or os.getcwd()
93
- self._container_name = "iris_container"
94
- self._username = kwargs.get("username", "SuperUser")
95
- self._password = kwargs.get("password", "SYS")
96
- self._namespace = kwargs.get("namespace", "USER")
97
-
98
- def get_assigned_port(self) -> int:
99
- """Get the port assigned to this container."""
100
- if os.environ.get("IRIS_TEST_MODE"):
101
- return self._port_assignment.port if self._port_assignment else 1972
102
-
103
- if self._port_assignment:
104
- return self._port_assignment.port
105
- return int(self.get_exposed_port(1972))
106
-
107
- def get_test_namespace(self) -> str:
108
- """Generate a unique test namespace."""
109
- import uuid
110
- ns = f"TEST_{str(uuid.uuid4())[:8].upper()}"
111
- self.execute_objectscript(f'Set status = ##class(Config.Namespaces).Create("{ns}",.p)')
112
- return ns
82
+ self._container_name: Optional[str] = kwargs.get("name")
83
+ self._config: Optional[IRISConfig] = None
113
84
 
114
- def delete_namespace(self, namespace: str):
115
- """Delete a namespace."""
116
- self.execute_objectscript(f'Do ##class(Config.Namespaces).Delete("{namespace}")')
117
-
118
- def with_cpf_merge(self, path_or_content: str) -> "IRISContainer":
119
- """Configure a CPF merge file for the container."""
120
- from .cpf_manager import TempCPFManager
121
-
122
- if os.path.exists(path_or_content) and os.path.isfile(path_or_content):
123
- self._cpf_merge_path = os.path.abspath(path_or_content)
124
- else:
125
- if self._cpf_manager is None:
126
- self._cpf_manager = TempCPFManager()
127
- self._cpf_merge_path = self._cpf_manager.create_temp_cpf(path_or_content)
128
-
129
- container_path = "/usr/irissys/merge.cpf"
130
- self.with_env("ISC_CPF_MERGE_FILE", container_path)
131
- self.with_volume_mapping(self._cpf_merge_path, container_path, "ro")
132
- return self
85
+ # Standard attributes used by fixtures
86
+ # IMPORTANT: self.port must remain the INTERNAL container port (1972)
87
+ # for testcontainers' get_exposed_port() to work correctly.
88
+ # Use self._mapped_port for the host-side mapped port.
89
+ self.host = "localhost"
90
+ self.port = 1972 # Internal container port - DO NOT CHANGE
91
+ self._mapped_port: Optional[int] = None # Host-side mapped port
133
92
 
134
- def start(self):
135
- """Start IRIS container with port registry integration."""
136
- if self._port_registry:
137
- self._port_assignment = self._port_registry.assign_port(
138
- project_path=self._project_path, preferred_port=self._preferred_port
139
- )
140
- assigned_port = self._port_assignment.port
141
- if self._config:
142
- self._config.port = assigned_port
143
-
144
- self.with_bind_ports(1972, assigned_port)
145
- # Ensure assigned_port is set on self for base class
146
- self.port = assigned_port
147
- project_hash = hashlib.md5(self._project_path.encode()).hexdigest()[:8]
148
- container_name = f"iris_{project_hash}_{assigned_port}"
149
- self._port_assignment.container_name = container_name
150
- self.with_name(container_name)
151
- self._container_name = container_name
152
-
153
- try:
154
- if HAS_TESTCONTAINERS_IRIS and not os.environ.get("IRIS_TEST_MODE"):
155
- result = super().start()
156
- else:
157
- logger.info("Mock IRIS container started (test mode)")
158
- result = self
159
- except Exception as e:
160
- logger.error(f"Failed to start container: {e}")
161
- raise
162
-
163
- # Only wait if we actually have a running container and haven't already waited
164
- # testcontainers.iris.IRISContainer already waits for "Enabling logons"
165
- if HAS_TESTCONTAINERS_IRIS and not os.environ.get("IRIS_TEST_MODE"):
166
- # We skip wait_for_ready() here because super().start() already waited
167
- # but we still need to perform the initial password reset hardening
168
- config = self.get_config()
169
- from iris_devtester.utils.password_reset import reset_password
170
- try:
171
- reset_password(
172
- container_name=self.get_container_name(),
173
- username=config.username,
174
- new_password=config.password,
175
- hostname=None,
176
- port=config.port,
177
- namespace=config.namespace,
178
- timeout=10 # Short timeout for auto-start
179
- )
180
- except Exception as e:
181
- logger.debug(f"Initial password reset failed (non-critical): {e}")
182
-
183
- return result
184
-
185
- if self._config:
186
- self._config.host = self.get_container_host_ip()
187
- if self._port_registry:
188
- self._config.port = self._port_assignment.port
189
- else:
190
- self._config.port = int(self.get_exposed_port(self.port or 1972))
191
-
192
- return result
193
-
194
- def stop(self, *args, **kwargs):
195
- """Stop IRIS container and release resources."""
196
- try:
197
- super().stop(*args, **kwargs)
198
- finally:
199
- if self._port_registry and self._port_assignment:
200
- try:
201
- self._port_registry.release_port(self._project_path)
202
- except Exception as e:
203
- logger.warning(f"Failed to release port assignment: {e}")
204
- if self._cpf_manager:
205
- self._cpf_manager.cleanup()
93
+ # Pre-configuration fields (Feature 001)
94
+ self._preconfigure_password: Optional[str] = None
95
+ self._preconfigure_username: Optional[str] = None
206
96
 
207
97
  @classmethod
208
- def community(
209
- cls,
210
- namespace: str = "USER",
211
- username: str = "SuperUser",
212
- password: str = "SYS",
213
- **kwargs,
214
- ) -> "IRISContainer":
215
- """Create Community Edition IRIS container."""
216
- if "image" not in kwargs:
98
+ def community(cls, image: Optional[str] = None, **kwargs) -> "IRISContainer":
99
+ """Create a Community Edition container."""
100
+ if image is None:
101
+ import platform as platform_module
102
+
217
103
  if platform_module.machine() == "arm64":
218
- kwargs["image"] = "containers.intersystems.com/intersystems/iris-community:2025.1"
104
+ image = "containers.intersystems.com/intersystems/iris-community:2025.1"
219
105
  else:
220
- kwargs["image"] = "intersystemsdc/iris-community:latest"
221
-
222
- container = cls(
223
- username=username,
224
- password=password,
225
- namespace=namespace,
226
- **kwargs
227
- )
106
+ image = "intersystemsdc/iris-community:latest"
107
+ return cls(image=image, **kwargs)
228
108
 
229
- container._config = IRISConfig(
230
- host="localhost",
231
- port=1972,
232
- namespace=namespace,
233
- username=username,
234
- password=password,
235
- container_name=container.get_container_name()
236
- )
109
+ @classmethod
110
+ def enterprise(cls, license_key: str, image: Optional[str] = None, **kwargs) -> "IRISContainer":
111
+ """Create an Enterprise Edition container."""
112
+ if image is None:
113
+ image = "containers.intersystems.com/intersystems/iris:latest"
114
+ container = cls(image=image, **kwargs)
237
115
  return container
238
116
 
239
- @classmethod
240
- def from_existing(cls, auto_discover: bool = True) -> Optional[IRISConfig]:
241
- """Detect existing IRIS instance."""
242
- if not auto_discover: return None
243
- from iris_devtester.config.auto_discovery import auto_discover_iris
244
- config_dict = auto_discover_iris()
245
- if config_dict is None: return None
246
- return IRISConfig(
247
- host=config_dict.get("host", "localhost"),
248
- port=config_dict.get("port", 1972),
249
- namespace=config_dict.get("namespace", "USER"),
250
- username=config_dict.get("username", "_SYSTEM"),
251
- password=config_dict.get("password", "SYS"),
252
- )
117
+ def with_name(self, name: str) -> "IRISContainer":
118
+ """Set the container name."""
119
+ self._container_name = name
120
+ if hasattr(self, "with_kwargs"):
121
+ self.with_kwargs(name=name)
122
+ return self
253
123
 
254
- @classmethod
255
- def enterprise(
256
- cls,
257
- license_key: Optional[str] = None,
258
- namespace: str = "USER",
259
- username: str = "SuperUser",
260
- password: str = "SYS",
261
- **kwargs,
262
- ) -> "IRISContainer":
263
- """Create Enterprise Edition IRIS container."""
264
- license_key = license_key or os.environ.get("IRIS_LICENSE_KEY")
265
- if license_key is None:
266
- raise ValueError("Enterprise Edition requires license key")
267
-
268
- if platform_module.machine() == "arm64":
269
- image = "containers.intersystems.com/intersystems/iris-arm64:2025.1"
270
- else:
271
- image = "intersystemsdc/iris:latest"
272
-
273
- container = cls(
274
- image=image,
275
- username=username,
276
- password=password,
277
- namespace=namespace,
278
- **kwargs
279
- )
280
- container.with_env("ISC_LICENSE_KEY", license_key)
281
- container._config = IRISConfig(
282
- host="localhost",
283
- port=1972,
284
- namespace=namespace,
285
- username=username,
286
- password=password,
287
- container_name=container.get_container_name()
288
- )
289
- return container
124
+ def get_container_name(self) -> str:
125
+ """Get the actual container name."""
126
+ # Priority 1: Explicit name set by with_name()
127
+ if self._container_name:
128
+ return self._container_name
290
129
 
291
- @classmethod
292
- def attach(cls, container_name: str) -> "IRISContainer":
293
- """Attach to existing IRIS container."""
294
- import subprocess
295
- check_cmd = ["docker", "ps", "--filter", f"name={container_name}", "--format", "{{.Names}}"]
296
- result = subprocess.run(check_cmd, capture_output=True, text=True, timeout=10)
297
- if container_name not in result.stdout:
298
- raise ValueError(f"Container '{container_name}' not found")
299
-
300
- port_cmd = ["docker", "port", container_name, "1972"]
301
- result = subprocess.run(port_cmd, capture_output=True, text=True, timeout=10)
302
- exposed_port = int(result.stdout.strip().split(":")[-1]) if result.returncode == 0 and result.stdout.strip() else 1972
303
-
304
- instance = cls.__new__(cls)
305
- instance._connection = None
306
- instance._callin_enabled = False
307
- instance._is_attached = True
308
- instance._config = IRISConfig(
309
- host="localhost",
310
- port=exposed_port,
311
- namespace="USER",
312
- username="SuperUser",
313
- password="SYS",
314
- container_name=container_name
315
- )
316
- instance._container_name = container_name
317
- return instance
130
+ # Priority 2: Get from actual Docker container (after start)
131
+ try:
132
+ if hasattr(self, "_container") and self._container is not None:
133
+ return str(self._container.name)
134
+ except Exception:
135
+ pass
318
136
 
319
- def get_connection(self, enable_callin: bool = True) -> Any:
320
- """Get database connection."""
321
- if self._connection is not None: return self._connection
322
-
137
+ # Priority 3: Try parent class method (testcontainers might have one)
138
+ try:
139
+ parent_name = super().get_container_name()
140
+ if parent_name and parent_name != "iris_db":
141
+ return str(parent_name)
142
+ except Exception:
143
+ pass
144
+
145
+ # Fallback - but this is problematic if container isn't started yet
146
+ return "iris_db"
147
+
148
+ def execute_objectscript(self, script: str, namespace: Optional[str] = None) -> str:
149
+ """Execute ObjectScript in the container."""
323
150
  container_name = self.get_container_name()
324
-
325
- if enable_callin and not self._callin_enabled:
326
- self.enable_callin_service()
151
+ ns = namespace or self._namespace
327
152
 
328
- from iris_devtester.utils.unexpire_passwords import unexpire_all_passwords
329
- unexpire_all_passwords(container_name)
153
+ cmd = ["docker", "exec", "-i", container_name, "iris", "session", "IRIS", "-U", ns]
330
154
 
331
- from iris_devtester.utils.password_reset import reset_password
332
- config = self.get_config()
333
-
334
- reset_password(
335
- container_name=container_name,
336
- username=config.username,
337
- new_password=config.password,
338
- hostname=config.host,
339
- port=config.port,
340
- namespace=config.namespace,
155
+ result = subprocess.run(
156
+ cmd, input=f"{script}\nHalt\n".encode("utf-8"), capture_output=True, timeout=30
341
157
  )
342
158
 
343
- self._connection = get_connection(config)
344
- return self._connection
159
+ if result.returncode != 0:
160
+ raise RuntimeError(f"OS failed: {result.stderr.decode()}")
345
161
 
346
- def get_config(self) -> IRISConfig:
347
- """Get connection configuration."""
348
- if self._config is None: self._config = IRISConfig()
349
- try:
350
- if HAS_TESTCONTAINERS_IRIS and hasattr(self, "get_container_host_ip"):
351
- self._config = IRISConfig(
352
- host=self.get_container_host_ip(),
353
- port=int(self.get_exposed_port(1972)),
354
- namespace=self._config.namespace,
355
- username=self._config.username,
356
- password=self._config.password,
357
- container_name=self.get_container_name()
358
- )
359
- elif self._is_attached:
360
- self._config.container_name = self.get_container_name()
361
- except Exception as e:
362
- logger.debug(f"Could not update config: {e}")
363
- return self._config
162
+ return result.stdout.decode("utf-8", errors="replace")
364
163
 
365
- def wait_for_ready(self, timeout: int = 60) -> bool:
366
- """Wait for IRIS to be fully ready."""
367
- config = self.get_config()
368
- strategy = IRISReadyWaitStrategy(port=config.port, timeout=timeout)
369
- try:
370
- ready = strategy.wait_until_ready(
371
- config.host,
372
- config.port,
373
- timeout,
374
- container_name=self.get_container_name()
375
- )
376
- if not ready: return False
377
-
378
- from iris_devtester.utils.password_reset import reset_password
379
- # Convert result to bool to satisfy type checker
380
- result = reset_password(
381
- container_name=self.get_container_name(),
382
- username=config.username,
383
- new_password=config.password,
384
- hostname=None,
385
- port=config.port,
386
- namespace=config.namespace
387
- )
388
- if hasattr(result, "success"):
389
- return bool(getattr(result, "success"))
390
- return bool(result[0])
391
- except (TimeoutError, IndexError, TypeError): return False
392
-
393
- def reset_password(self, username: str = "_SYSTEM", new_password: str = "SYS") -> bool:
394
- """Reset user password."""
395
- from iris_devtester.utils.password_reset import reset_password
396
- config = self.get_config()
397
- result = reset_password(
398
- container_name=self.get_container_name(),
399
- username=username,
400
- new_password=new_password,
401
- hostname=config.host,
402
- port=config.port,
403
- namespace=config.namespace
404
- )
405
- # Handle both Tuple[bool, str] and PasswordResetResult
406
- if hasattr(result, "success"):
407
- success = bool(getattr(result, "success"))
408
- elif isinstance(result, (list, tuple)):
409
- success = bool(result[0])
410
- else:
411
- success = False
412
-
413
- if success: config.password = new_password
414
- return success
164
+ def enable_callin_service(self) -> bool:
165
+ """Enable the CallIn service (required for DBAPI)."""
166
+ if self._callin_enabled:
167
+ return True
415
168
 
416
- def get_container_name(self) -> str:
417
- """Get current container name."""
418
- if hasattr(self, "_is_attached") and self._is_attached: return self._container_name
419
- if HAS_TESTCONTAINERS_IRIS:
420
- try:
421
- wrapped = self.get_wrapped_container()
422
- if wrapped and hasattr(wrapped, "name"):
423
- return str(wrapped.name)
424
- except Exception: pass
425
- return "iris_container"
426
-
427
- def get_project_path(self) -> Optional[str]:
428
- """Get project path if port registry is used."""
429
- if self._port_registry:
430
- return self._project_path
431
- return None
169
+ from iris_devtester.utils.enable_callin import enable_callin_service
432
170
 
433
- def enable_callin_service(self) -> bool:
434
- """Enable CallIn service."""
435
- if self._callin_enabled: return True
436
- try:
437
- container_name = self.get_container_name()
438
- script = 'Do ##class(Security.Services).Get("%Service_CallIn",.p) Set p("Enabled")=1,p("AutheEnabled")=48 Do ##class(Security.Services).Modify("%Service_CallIn",.p) Write "OK" Halt'
439
- cmd = ["docker", "exec", "-u", "root", container_name, "sh", "-c", f'iris session IRIS -U %SYS << "EOF"\n{script}\nEOF']
440
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
441
- if result.returncode == 0 and "OK" in result.stdout:
442
- self._callin_enabled = True
443
- return True
171
+ success, msg = enable_callin_service(self.get_container_name())
172
+ if success:
173
+ self._callin_enabled = True
174
+ return True
175
+ else:
176
+ logger.error(f"Failed to enable CallIn: {msg}")
444
177
  return False
445
- except Exception: return False
446
178
 
447
179
  def check_callin_enabled(self) -> bool:
448
180
  """Check if CallIn is enabled."""
449
181
  try:
450
- script = 'Do ##class(Security.Services).Get("%Service_CallIn",.s) Write s.Enabled'
451
- cmd = ["docker", "exec", self.get_container_name(), "iris", "session", "IRIS", "-U", "%SYS", script]
452
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
453
- is_enabled = result.returncode == 0 and "1" in result.stdout
454
- if is_enabled: self._callin_enabled = True
182
+ script = 'Do ##class(Security.Services).Get("%Service_CallIn",.p) Write "ENABLED:",p("Enabled")'
183
+ output = self.execute_objectscript(script, namespace="%SYS")
184
+ is_enabled = "ENABLED:1" in output
185
+ if is_enabled:
186
+ self._callin_enabled = True
455
187
  return is_enabled
456
- except Exception: return False
457
-
458
- def execute_objectscript(self, code: str, namespace: Optional[str] = None) -> str:
459
- """Execute ObjectScript code."""
460
- ns = namespace or self.get_config().namespace
461
- if "Halt" not in code: code += "\nHalt"
462
- cmd = ["docker", "exec", self.get_container_name(), "sh", "-c", f'iris session IRIS -U {ns} << "EOF"\n{code}\nEOF']
463
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
464
- if result.returncode != 0: raise RuntimeError(f"OS failed: {result.stderr}")
465
- return result.stdout
466
-
467
- def validate(self, level: Any = None) -> Any:
468
- """Validate container health."""
469
- from iris_devtester.containers.models import HealthCheckLevel
470
- from iris_devtester.containers.validation import validate_container
471
- return validate_container(container_name=self.get_container_name(), level=level or HealthCheckLevel.STANDARD, docker_client=None)
472
-
473
- def assert_healthy(self, level: Any = None):
474
- """Raise if not healthy."""
475
- res = self.validate(level=level)
476
- if not res.success: raise RuntimeError(res.format_message())
188
+ except:
189
+ return False
477
190
 
478
- @classmethod
479
- def from_config(cls, config: Any) -> "IRISContainer":
480
- """Create from ContainerConfig."""
481
- image = config.get_image_name()
482
- if getattr(config, "edition", "community") == "community":
483
- container = cls.community(namespace=getattr(config, "namespace", "USER"), username=getattr(config, "username", "SuperUser"), password=getattr(config, "password", "SYS"), image=image)
484
- else:
485
- container = cls.enterprise(license_key=getattr(config, "license_key", None), namespace=getattr(config, "namespace", "USER"), username=getattr(config, "username", "SuperUser"), password=getattr(config, "password", "SYS"), image=image)
486
- if getattr(config, "cpf_merge", None): container.with_cpf_merge(config.cpf_merge)
487
- return container
191
+ def get_test_namespace(self, prefix: str = "TEST") -> str:
192
+ """Generate a unique test namespace with its own database."""
193
+ import uuid
194
+
195
+ ns = f"{prefix}_{str(uuid.uuid4())[:8].upper()}"
196
+ db_dir = f"/usr/irissys/mgr/db_{ns.lower()}"
197
+
198
+ script = f"""
199
+ Set ns="{ns}"
200
+ Set dbDir="{db_dir}"
201
+ If '##class(%File).DirectoryExists(dbDir) Do ##class(%File).CreateDirectoryChain(dbDir)
202
+ Set db=##class(SYS.Database).%New() Set db.Directory=dbDir Do db.%Save()
203
+ Do ##class(Config.Databases).Create(ns,dbDir)
204
+ Set p("Globals")=ns,p("Routines")=ns Do ##class(Config.Namespaces).Create(ns,.p)
205
+ Write "SUCCESS" Halt
206
+ """
207
+ self.execute_objectscript(script, namespace="%SYS")
208
+ return ns
209
+
210
+ def delete_namespace(self, namespace: str):
211
+ """Delete a namespace and its associated database files cleanly."""
212
+ script = f"""
213
+ Set ns="{namespace}"
214
+ Do ##class(Config.Namespaces).Delete(ns)
215
+ If ##class(Config.Databases).Get(ns,.p) {{
216
+ Set dir = p("Directory")
217
+ Do ##class(SYS.Database).DismountDatabase(dir)
218
+ Do ##class(Config.Databases).Delete(ns)
219
+ Do ##class(%File).RemoveDirectoryTree(dir)
220
+ }}
221
+ Write "SUCCESS" Halt
222
+ """
223
+ self.execute_objectscript(script, namespace="%SYS")
224
+
225
+ def get_config(self) -> IRISConfig:
226
+ """Get connection configuration."""
227
+ if self._config is None:
228
+ self._config = IRISConfig(
229
+ username=self._username,
230
+ password=self._password,
231
+ namespace=self._namespace,
232
+ container_name=self.get_container_name(),
233
+ )
234
+ config = self._config
235
+ try:
236
+ # Get host and mapped port from testcontainers
237
+ # IMPORTANT: self.port must remain 1972 (internal port) for get_exposed_port() to work
238
+ self.host = self.get_container_host_ip()
239
+ self._mapped_port = int(self.get_exposed_port(1972)) # Use internal port to get mapping
240
+ config.host = self.host
241
+ config.port = (
242
+ self._mapped_port
243
+ ) # Config uses the host-mapped port for connections
244
+ except Exception:
245
+ pass
246
+ return config
247
+
248
+ def get_mapped_port(self, internal_port: int = 1972) -> int:
249
+ """Get the host-side mapped port for a given internal container port.
250
+
251
+ This is a convenience wrapper around get_exposed_port() that ensures
252
+ we always pass the internal port (not the host port).
253
+
254
+ Args:
255
+ internal_port: The port inside the container (default: 1972 for IRIS superserver)
256
+
257
+ Returns:
258
+ The host-side port that maps to the internal port
259
+ """
260
+ if self._mapped_port is not None and internal_port == 1972:
261
+ return self._mapped_port
262
+ return int(self.get_exposed_port(internal_port))
263
+
264
+ def get_connection(self, enable_callin: bool = True) -> Any:
265
+ """Get database connection."""
266
+ if self._connection is not None:
267
+ return self._connection
268
+
269
+ if enable_callin:
270
+ self.enable_callin_service()
271
+
272
+ from iris_devtester.utils.password import unexpire_all_passwords
273
+
274
+ unexpire_all_passwords(self.get_container_name())
275
+
276
+ config = self.get_config()
277
+ from iris_devtester.connections.connection import get_connection as get_modern_connection
278
+
279
+ self._connection = get_modern_connection(config)
280
+ return self._connection
281
+
282
+ def with_preconfigured_password(self, password: str) -> "IRISContainer":
283
+ """Set password for pre-configuration."""
284
+ if not password:
285
+ raise ValueError("Password cannot be empty")
286
+ self._preconfigure_password = password
287
+ self._password = password
288
+ return self
289
+
290
+ def with_credentials(self, username: str, password: str) -> "IRISContainer":
291
+ """Set credentials for pre-configuration."""
292
+ if not username:
293
+ raise ValueError("Username cannot be empty")
294
+ if not password:
295
+ raise ValueError("Password cannot be empty")
296
+ self._preconfigure_username = username
297
+ self._preconfigure_password = password
298
+ self._username = username
299
+ self._password = password
300
+ return self
301
+
302
+ def start(self) -> "IRISContainer":
303
+ """Start container with pre-config support."""
304
+ if self._preconfigure_password:
305
+ self.with_env("IRIS_PASSWORD", self._preconfigure_password)
306
+ if self._preconfigure_username:
307
+ self.with_env("IRIS_USERNAME", self._preconfigure_username)
308
+
309
+ super().start()
310
+ # Ensure host/port are updated after start
311
+ self.get_config()
312
+ self._password_preconfigured = True
313
+ return self
314
+
315
+ def wait_for_ready(self, timeout: int = 60) -> bool:
316
+ """Wait for IRIS to be ready."""
317
+ # Simple wait for prototype
318
+ time.sleep(15)
319
+ return True