db-process 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
db_process/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ """
2
+ db-process
3
+ ====================================
4
+ Python interface to DesignBuilder command line processing.
5
+
6
+ This package provides:
7
+
8
+ - **Executable discovery** — find DesignBuilder.exe across env vars,
9
+ default install paths, and the system PATH.
10
+ - **Typed command model** — dataclasses for every ``/process=`` command
11
+ documented in DesignBuilder Help, with a fluent builder API.
12
+ - **Subprocess execution** — blocking and non-blocking modes, timeout
13
+ handling, and idle-detection termination.
14
+ """
15
+
16
+ from db_process.commands import (
17
+ ChangeAttributeValue,
18
+ ExportAsXML,
19
+ ExternalCommand,
20
+ ImportLibraryData,
21
+ ImportModelData,
22
+ NoClose,
23
+ ProcessChain,
24
+ RunCalculation,
25
+ Screen,
26
+ SimEndDate,
27
+ SimStartDate,
28
+ SwitchScreen,
29
+ TabChange,
30
+ UseSimManager,
31
+ # Convenience factories
32
+ cfd_simulation,
33
+ daylighting,
34
+ eplus_simulation,
35
+ export_xml,
36
+ heating_and_cooling_design,
37
+ sbem_calculation,
38
+ )
39
+ from db_process.executable import find_designbuilder
40
+ from db_process.runner import (
41
+ RunHandle,
42
+ RunResult,
43
+ find_process,
44
+ kill_process,
45
+ kill_when_idle,
46
+ run,
47
+ run_async,
48
+ )
49
+
50
+ __all__ = [
51
+ # Executable
52
+ "find_designbuilder",
53
+ # Commands
54
+ "ChangeAttributeValue",
55
+ "ExportAsXML",
56
+ "ExternalCommand",
57
+ "ImportLibraryData",
58
+ "ImportModelData",
59
+ "NoClose",
60
+ "ProcessChain",
61
+ "RunCalculation",
62
+ "Screen",
63
+ "SimEndDate",
64
+ "SimStartDate",
65
+ "SwitchScreen",
66
+ "TabChange",
67
+ "UseSimManager",
68
+ # Convenience factories
69
+ "cfd_simulation",
70
+ "daylighting",
71
+ "eplus_simulation",
72
+ "export_xml",
73
+ "heating_and_cooling_design",
74
+ "sbem_calculation",
75
+ # Runner
76
+ "RunHandle",
77
+ "RunResult",
78
+ "find_process",
79
+ "kill_process",
80
+ "kill_when_idle",
81
+ "run",
82
+ "run_async",
83
+ ]
db_process/commands.py ADDED
@@ -0,0 +1,449 @@
1
+ """
2
+ commands.py
3
+ ====================================
4
+ Typed representation of DesignBuilder command line process commands.
5
+
6
+ DesignBuilder CLI syntax::
7
+
8
+ "DesignBuilder.exe" "MyFile.dsb" /process=Command1, Command2, ...
9
+
10
+ This module provides dataclasses for each command type and a
11
+ :class:`ProcessChain` builder to compose them into valid ``/process=`` strings.
12
+
13
+ Reference: DesignBuilder Help > Reference > Command Line Processing
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from enum import Enum
20
+ from typing import Optional, Union
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Screen / view commands
25
+ # ---------------------------------------------------------------------------
26
+
27
+ class Screen(str, Enum):
28
+ """Available DesignBuilder screen commands."""
29
+
30
+ SIMULATION = "miGSS"
31
+ HEATING_DESIGN = "miGHL"
32
+ COOLING_DESIGN = "miGHG"
33
+ DAYLIGHTING = "miGDY"
34
+ SBEM_DSM = "miGCalculate"
35
+ CFD = "miGCFD"
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Individual command dataclasses
40
+ # ---------------------------------------------------------------------------
41
+
42
+ @dataclass(frozen=True)
43
+ class SwitchScreen:
44
+ """Change the view to a specific DesignBuilder screen.
45
+
46
+ Examples: miGSS, miGHL, miGHG, miGDY, miGCalculate, miGCFD
47
+ """
48
+
49
+ screen: Screen
50
+
51
+ def render(self) -> str:
52
+ return self.screen.value
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class RunCalculation:
57
+ """Run the calculation for the current screen (miTUpdate).
58
+
59
+ Generally this command should be placed after screen switching
60
+ and before any ChangeAttributeValue commands that follow it.
61
+ """
62
+
63
+ def render(self) -> str:
64
+ return "miTUpdate"
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class TabChange:
69
+ """Change the view to tab *n* on the current screen.
70
+
71
+ Example: ``TabChange_2`` on the Daylighting screen switches to
72
+ the Annual daylighting tab.
73
+ """
74
+
75
+ tab: int
76
+
77
+ def __post_init__(self) -> None:
78
+ if self.tab < 1:
79
+ raise ValueError(f"Tab number must be >= 1, got {self.tab}")
80
+
81
+ def render(self) -> str:
82
+ return f"TabChange_{self.tab}"
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class SimStartDate:
87
+ """Set simulation start date.
88
+
89
+ Parameters are day and month as integers.
90
+ Example: ``SimStartDate 4 5`` sets start to May 4.
91
+ """
92
+
93
+ day: int
94
+ month: int
95
+
96
+ def __post_init__(self) -> None:
97
+ if not (1 <= self.month <= 12):
98
+ raise ValueError(f"Month must be 1-12, got {self.month}")
99
+ if not (1 <= self.day <= 31):
100
+ raise ValueError(f"Day must be 1-31, got {self.day}")
101
+
102
+ def render(self) -> str:
103
+ return f"SimStartDate {self.day} {self.month}"
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class SimEndDate:
108
+ """Set simulation end date.
109
+
110
+ Parameters are day and month as integers.
111
+ Example: ``SimEndDate 11 5`` sets end to May 11.
112
+ """
113
+
114
+ day: int
115
+ month: int
116
+
117
+ def __post_init__(self) -> None:
118
+ if not (1 <= self.month <= 12):
119
+ raise ValueError(f"Month must be 1-12, got {self.month}")
120
+ if not (1 <= self.day <= 31):
121
+ raise ValueError(f"Day must be 1-31, got {self.day}")
122
+
123
+ def render(self) -> str:
124
+ return f"SimEndDate {self.day} {self.month}"
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class ChangeAttributeValue:
129
+ """Change a model attribute at building level.
130
+
131
+ Attribute value types (from documentation):
132
+ - Numeric enums: integer codes (e.g. ``Layout 0``)
133
+ - Booleans: ``1`` for True, ``0`` for False
134
+ - Real numbers: SI units, ``.`` decimal separator
135
+ - Database table selections: numeric Id (obtain via API)
136
+
137
+ Tip: Enable *Show attribute names in tooltips* in
138
+ Tools > Program Options > Interface to find attribute names.
139
+ """
140
+
141
+ attribute: str
142
+ value: Union[str, int, float]
143
+
144
+ def render(self) -> str:
145
+ return f"ChangeAttributeValue {self.attribute} {self.value}"
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class ExternalCommand:
150
+ """Run an external command with optional arguments.
151
+
152
+ The external executable must be on the system PATH or in the
153
+ working directory.
154
+ """
155
+
156
+ command: str
157
+
158
+ def render(self) -> str:
159
+ return f"ExternalCommand_{self.command}"
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class ImportModelData:
164
+ """Import model data from a CSV file previously exported from the model."""
165
+
166
+ filepath: str
167
+
168
+ def render(self) -> str:
169
+ return f"ImportModelData_{self.filepath}"
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class ImportLibraryData:
174
+ """Import library data from a file."""
175
+
176
+ filepath: str
177
+
178
+ def render(self) -> str:
179
+ return f"ImportLibraryData_{self.filepath}"
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class ExportAsXML:
184
+ """Export the model as a dsbXML file (miFExportAsXML)."""
185
+
186
+ def render(self) -> str:
187
+ return "miFExportAsXML"
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class UseSimManager:
192
+ """Ensure the simulation runs using the Simulation Manager.
193
+
194
+ Uses the Server and EnergyPlus server method settings
195
+ currently configured in the model.
196
+ """
197
+
198
+ def render(self) -> str:
199
+ return "UseSimManager"
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class NoClose:
204
+ """Prevent DesignBuilder from shutting down after all commands."""
205
+
206
+ def render(self) -> str:
207
+ return "NoClose"
208
+
209
+
210
+ # Union of all command types for type hints.
211
+ Command = Union[
212
+ SwitchScreen,
213
+ RunCalculation,
214
+ TabChange,
215
+ SimStartDate,
216
+ SimEndDate,
217
+ ChangeAttributeValue,
218
+ ExternalCommand,
219
+ ImportModelData,
220
+ ImportLibraryData,
221
+ ExportAsXML,
222
+ UseSimManager,
223
+ NoClose,
224
+ ]
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # ProcessChain builder
229
+ # ---------------------------------------------------------------------------
230
+
231
+ @dataclass
232
+ class ProcessChain:
233
+ """Build a ``/process=`` command string from a sequence of commands.
234
+
235
+ Usage::
236
+
237
+ chain = (
238
+ ProcessChain()
239
+ .use_sim_manager()
240
+ .switch_screen(Screen.SIMULATION)
241
+ .sim_start_date(1, 1)
242
+ .sim_end_date(31, 12)
243
+ .change_attribute("OccupancyValue", 0.5)
244
+ .run()
245
+ )
246
+ print(chain.to_string())
247
+ # /process=UseSimManager, miGSS, SimStartDate 1 1, SimEndDate 31 12,
248
+ # ChangeAttributeValue OccupancyValue 0.5, miTUpdate
249
+ """
250
+
251
+ commands: list[Command] = field(default_factory=list)
252
+
253
+ # -- Fluent builder methods -----------------------------------------------
254
+
255
+ def add(self, command: Command) -> ProcessChain:
256
+ """Add an arbitrary command to the chain."""
257
+ self.commands.append(command)
258
+ return self
259
+
260
+ def switch_screen(self, screen: Screen) -> ProcessChain:
261
+ """Add a screen-switching command."""
262
+ return self.add(SwitchScreen(screen))
263
+
264
+ def run(self) -> ProcessChain:
265
+ """Add miTUpdate (run calculation for current screen)."""
266
+ return self.add(RunCalculation())
267
+
268
+ def tab_change(self, tab: int) -> ProcessChain:
269
+ """Switch to a specific tab on the current screen."""
270
+ return self.add(TabChange(tab))
271
+
272
+ def sim_start_date(self, day: int, month: int) -> ProcessChain:
273
+ """Set simulation start date."""
274
+ return self.add(SimStartDate(day, month))
275
+
276
+ def sim_end_date(self, day: int, month: int) -> ProcessChain:
277
+ """Set simulation end date."""
278
+ return self.add(SimEndDate(day, month))
279
+
280
+ def change_attribute(
281
+ self, attribute: str, value: Union[str, int, float]
282
+ ) -> ProcessChain:
283
+ """Change a model attribute value."""
284
+ return self.add(ChangeAttributeValue(attribute, value))
285
+
286
+ def external_command(self, command: str) -> ProcessChain:
287
+ """Run an external command."""
288
+ return self.add(ExternalCommand(command))
289
+
290
+ def import_model_data(self, filepath: str) -> ProcessChain:
291
+ """Import model data from a CSV file."""
292
+ return self.add(ImportModelData(filepath))
293
+
294
+ def import_library_data(self, filepath: str) -> ProcessChain:
295
+ """Import library data from a file."""
296
+ return self.add(ImportLibraryData(filepath))
297
+
298
+ def export_as_xml(self) -> ProcessChain:
299
+ """Export the model as dsbXML."""
300
+ return self.add(ExportAsXML())
301
+
302
+ def use_sim_manager(self) -> ProcessChain:
303
+ """Use the Simulation Manager for the run."""
304
+ return self.add(UseSimManager())
305
+
306
+ def no_close(self) -> ProcessChain:
307
+ """Prevent DesignBuilder from closing after processing."""
308
+ return self.add(NoClose())
309
+
310
+ # -- Rendering ------------------------------------------------------------
311
+
312
+ def to_string(self) -> str:
313
+ """Render the chain as a ``/process=...`` argument string.
314
+
315
+ Raises
316
+ ------
317
+ ValueError
318
+ If the chain contains no commands.
319
+ """
320
+ if not self.commands:
321
+ raise ValueError("ProcessChain is empty — add at least one command.")
322
+ parts = [cmd.render() for cmd in self.commands]
323
+ return "/process=" + ", ".join(parts)
324
+
325
+ def to_list(self) -> list[str]:
326
+ """Return the chain as a list of individual command strings."""
327
+ return [cmd.render() for cmd in self.commands]
328
+
329
+ def __str__(self) -> str:
330
+ return self.to_string()
331
+
332
+ def __repr__(self) -> str:
333
+ return f"ProcessChain({self.commands!r})"
334
+
335
+ def __len__(self) -> int:
336
+ return len(self.commands)
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Convenience factory functions
341
+ # ---------------------------------------------------------------------------
342
+
343
+ def eplus_simulation(
344
+ *,
345
+ sim_start_date: Optional[tuple[int, int]] = None,
346
+ sim_end_date: Optional[tuple[int, int]] = None,
347
+ use_sim_manager: bool = False,
348
+ attributes: Optional[list[tuple[str, Union[str, int, float]]]] = None,
349
+ no_close: bool = False,
350
+ ) -> ProcessChain:
351
+ """Build a standard EnergyPlus simulation command chain.
352
+
353
+ This produces the equivalent of what ``db-batch`` creates for
354
+ ``analysis_type="eplus"``.
355
+ """
356
+ chain = ProcessChain()
357
+
358
+ if use_sim_manager:
359
+ chain.use_sim_manager()
360
+
361
+ if sim_start_date:
362
+ chain.sim_start_date(sim_start_date[0], sim_start_date[1])
363
+
364
+ if sim_end_date:
365
+ chain.sim_end_date(sim_end_date[0], sim_end_date[1])
366
+
367
+ if attributes:
368
+ for attr, val in attributes:
369
+ chain.change_attribute(attr, val)
370
+
371
+ chain.switch_screen(Screen.SIMULATION)
372
+ chain.run()
373
+
374
+ if no_close:
375
+ chain.no_close()
376
+
377
+ return chain
378
+
379
+
380
+ def sbem_calculation(
381
+ *,
382
+ sim_start_date: Optional[tuple[int, int]] = None,
383
+ sim_end_date: Optional[tuple[int, int]] = None,
384
+ attributes: Optional[list[tuple[str, Union[str, int, float]]]] = None,
385
+ no_close: bool = False,
386
+ ) -> ProcessChain:
387
+ """Build a standard SBEM/DSM calculation command chain."""
388
+ chain = ProcessChain()
389
+
390
+ if sim_start_date:
391
+ chain.sim_start_date(sim_start_date[0], sim_start_date[1])
392
+
393
+ if sim_end_date:
394
+ chain.sim_end_date(sim_end_date[0], sim_end_date[1])
395
+
396
+ if attributes:
397
+ for attr, val in attributes:
398
+ chain.change_attribute(attr, val)
399
+
400
+ chain.switch_screen(Screen.SBEM_DSM)
401
+ chain.run()
402
+
403
+ if no_close:
404
+ chain.no_close()
405
+
406
+ return chain
407
+
408
+
409
+ def export_xml() -> ProcessChain:
410
+ """Build a command chain to export the model as dsbXML."""
411
+ return ProcessChain().export_as_xml()
412
+
413
+
414
+ def heating_and_cooling_design(*, no_close: bool = False) -> ProcessChain:
415
+ """Run both Heating and Cooling Design calculations."""
416
+ chain = (
417
+ ProcessChain()
418
+ .switch_screen(Screen.HEATING_DESIGN)
419
+ .run()
420
+ .switch_screen(Screen.COOLING_DESIGN)
421
+ .run()
422
+ )
423
+ if no_close:
424
+ chain.no_close()
425
+ return chain
426
+
427
+
428
+ def daylighting(
429
+ *, run_annual: bool = False, no_close: bool = False
430
+ ) -> ProcessChain:
431
+ """Run daylighting calculation, optionally including annual daylighting.
432
+
433
+ When *run_annual* is True, runs both Illuminance (tab 1) and
434
+ Annual daylighting (tab 2) calculations.
435
+ """
436
+ chain = ProcessChain().switch_screen(Screen.DAYLIGHTING).run()
437
+ if run_annual:
438
+ chain.tab_change(2).run()
439
+ if no_close:
440
+ chain.no_close()
441
+ return chain
442
+
443
+
444
+ def cfd_simulation(*, no_close: bool = False) -> ProcessChain:
445
+ """Run a CFD simulation."""
446
+ chain = ProcessChain().switch_screen(Screen.CFD).run()
447
+ if no_close:
448
+ chain.no_close()
449
+ return chain
@@ -0,0 +1,76 @@
1
+ """
2
+ executable.py
3
+ ====================================
4
+ Locate and validate the DesignBuilder executable.
5
+
6
+ Resolution order:
7
+ 1. Explicit path argument
8
+ 2. DESIGNBUILDER_EXE environment variable
9
+ 3. Common Windows installation directories
10
+ 4. System PATH (via shutil.which)
11
+ """
12
+
13
+ import os
14
+ import shutil
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ # Environment variable to override the DesignBuilder executable path.
19
+ ENV_VAR = "DESIGNBUILDER_EXE"
20
+
21
+ # Common DesignBuilder installation paths (Windows).
22
+ DEFAULT_INSTALL_PATHS = [
23
+ Path(r"C:\Program Files (x86)\DesignBuilder\DesignBuilder.exe"),
24
+ Path(r"C:\Program Files\DesignBuilder\DesignBuilder.exe"),
25
+ ]
26
+
27
+
28
+ def find_designbuilder(exe_path: Optional[str | Path] = None) -> Path:
29
+ """Locate the DesignBuilder executable.
30
+
31
+ Parameters
32
+ ----------
33
+ exe_path : str or Path, optional
34
+ Explicit path to DesignBuilder.exe. Takes highest priority.
35
+
36
+ Returns
37
+ -------
38
+ Path
39
+ Resolved path to the executable.
40
+
41
+ Raises
42
+ ------
43
+ FileNotFoundError
44
+ If DesignBuilder cannot be found via any method.
45
+ """
46
+ # 1. Explicit argument
47
+ if exe_path is not None:
48
+ p = Path(exe_path)
49
+ if p.is_file():
50
+ return p
51
+ raise FileNotFoundError(f"DesignBuilder not found at: {p}")
52
+
53
+ # 2. Environment variable
54
+ env = os.environ.get(ENV_VAR)
55
+ if env:
56
+ p = Path(env)
57
+ if p.is_file():
58
+ return p
59
+ raise FileNotFoundError(
60
+ f"{ENV_VAR} is set to '{env}' but the file does not exist."
61
+ )
62
+
63
+ # 3. Common install locations
64
+ for p in DEFAULT_INSTALL_PATHS:
65
+ if p.is_file():
66
+ return p
67
+
68
+ # 4. System PATH
69
+ which = shutil.which("DesignBuilder")
70
+ if which:
71
+ return Path(which)
72
+
73
+ raise FileNotFoundError(
74
+ "Could not find DesignBuilder.exe. "
75
+ f"Set the {ENV_VAR} environment variable or pass the path explicitly."
76
+ )
db_process/runner.py ADDED
@@ -0,0 +1,298 @@
1
+ """
2
+ runner.py
3
+ ====================================
4
+ Run DesignBuilder as a subprocess with support for blocking and
5
+ non-blocking execution, timeouts, and idle-detection termination.
6
+
7
+ This module unifies the execution patterns from ``designbuilder_schema``
8
+ (blocking with error capture) and ``db-batch`` (Popen + idle monitoring).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import subprocess
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Optional, Union
18
+
19
+ import psutil
20
+
21
+ from db_process.commands import ProcessChain
22
+ from db_process.executable import find_designbuilder
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Result dataclass
27
+ # ---------------------------------------------------------------------------
28
+
29
+ @dataclass
30
+ class RunResult:
31
+ """Outcome of a DesignBuilder process execution."""
32
+
33
+ success: bool
34
+ """Whether the process completed without error."""
35
+
36
+ returncode: Optional[int] = None
37
+ """Process return code (None if killed or not yet finished)."""
38
+
39
+ stdout: str = ""
40
+ """Captured stdout (blocking mode only)."""
41
+
42
+ stderr: str = ""
43
+ """Captured stderr (blocking mode only)."""
44
+
45
+ timed_out: bool = False
46
+ """True if the process was killed due to timeout."""
47
+
48
+ killed_idle: bool = False
49
+ """True if the process was killed by idle detection."""
50
+
51
+ duration_seconds: Optional[float] = None
52
+ """Wall-clock duration of the run in seconds."""
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Process management
57
+ # ---------------------------------------------------------------------------
58
+
59
+ DESIGNBUILDER_PROCESS_NAME = "DesignBuilder.exe"
60
+
61
+
62
+ def find_process(name: str = DESIGNBUILDER_PROCESS_NAME) -> Optional[psutil.Process]:
63
+ """Find a running process by name.
64
+
65
+ Returns the first matching process, or None.
66
+ """
67
+ for p in psutil.process_iter(["name"]):
68
+ try:
69
+ if p.info["name"] == name:
70
+ return p
71
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
72
+ continue
73
+ return None
74
+
75
+
76
+ def kill_process(name: str = DESIGNBUILDER_PROCESS_NAME) -> bool:
77
+ """Terminate a DesignBuilder process forcefully.
78
+
79
+ Returns True if a process was found and killed, False otherwise.
80
+ """
81
+ proc = find_process(name)
82
+ if proc is None:
83
+ return False
84
+
85
+ proc.kill()
86
+ proc.wait(timeout=5)
87
+ return True
88
+
89
+
90
+ def kill_when_idle(
91
+ name: str = DESIGNBUILDER_PROCESS_NAME,
92
+ *,
93
+ idle_threshold: float = 10,
94
+ check_interval: float = 0.5,
95
+ startup_period: float = 20,
96
+ cpu_threshold: float = 0.1,
97
+ ) -> bool:
98
+ """Monitor a process and kill it once CPU usage stays below threshold.
99
+
100
+ The function blocks until the process is killed or exits on its own.
101
+
102
+ Parameters
103
+ ----------
104
+ name : str
105
+ Process name to monitor.
106
+ idle_threshold : float
107
+ Seconds the process must be idle before termination.
108
+ check_interval : float
109
+ Polling interval in seconds.
110
+ startup_period : float
111
+ Grace period before idle detection begins.
112
+ cpu_threshold : float
113
+ CPU percentage below which the process is considered idle.
114
+
115
+ Returns
116
+ -------
117
+ bool
118
+ True if the process was killed due to idle, False if it
119
+ exited on its own or was not found.
120
+ """
121
+ proc = find_process(name)
122
+ if proc is None:
123
+ return False
124
+
125
+ idle_time = 0.0
126
+ has_been_active = False
127
+
128
+ try:
129
+ if startup_period > 0:
130
+ time.sleep(startup_period)
131
+
132
+ while proc.is_running():
133
+ cpu = proc.cpu_percent(interval=0.1)
134
+
135
+ if cpu >= cpu_threshold:
136
+ has_been_active = True
137
+ idle_time = 0.0
138
+ elif has_been_active:
139
+ idle_time += check_interval
140
+ if idle_time >= idle_threshold:
141
+ proc.kill()
142
+ proc.wait(timeout=5)
143
+ return True
144
+
145
+ time.sleep(check_interval)
146
+
147
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
148
+ pass
149
+
150
+ return False
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Blocking execution
155
+ # ---------------------------------------------------------------------------
156
+
157
+ def run(
158
+ model_path: Union[str, Path],
159
+ process_chain: Optional[ProcessChain] = None,
160
+ *,
161
+ exe_path: Optional[Union[str, Path]] = None,
162
+ timeout: Optional[int] = None,
163
+ ) -> RunResult:
164
+ """Run DesignBuilder in blocking mode and wait for completion.
165
+
166
+ Parameters
167
+ ----------
168
+ model_path : str or Path
169
+ Path to the .dsb model file.
170
+ process_chain : ProcessChain, optional
171
+ Commands to execute. When None, DesignBuilder opens the file
172
+ without processing (useful for XML import).
173
+ exe_path : str or Path, optional
174
+ Explicit path to DesignBuilder.exe.
175
+ timeout : int, optional
176
+ Maximum seconds to wait. None means wait indefinitely.
177
+
178
+ Returns
179
+ -------
180
+ RunResult
181
+ Outcome of the execution.
182
+ """
183
+ exe = find_designbuilder(exe_path)
184
+ model = Path(model_path)
185
+
186
+ cmd = [str(exe), str(model)]
187
+ if process_chain is not None:
188
+ cmd.append(process_chain.to_string())
189
+
190
+ t0 = time.monotonic()
191
+ try:
192
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
193
+ duration = time.monotonic() - t0
194
+ return RunResult(
195
+ success=result.returncode == 0,
196
+ returncode=result.returncode,
197
+ stdout=result.stdout,
198
+ stderr=result.stderr,
199
+ duration_seconds=duration,
200
+ )
201
+ except subprocess.TimeoutExpired:
202
+ duration = time.monotonic() - t0
203
+ kill_process()
204
+ return RunResult(
205
+ success=False,
206
+ timed_out=True,
207
+ duration_seconds=duration,
208
+ )
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Non-blocking execution
213
+ # ---------------------------------------------------------------------------
214
+
215
+ @dataclass
216
+ class RunHandle:
217
+ """Handle for a non-blocking DesignBuilder process.
218
+
219
+ Use :meth:`wait`, :meth:`kill`, or :meth:`kill_when_idle` to
220
+ manage the process after launch.
221
+ """
222
+
223
+ process: subprocess.Popen
224
+ """The underlying Popen object."""
225
+
226
+ start_time: float = field(default_factory=time.monotonic)
227
+ """Monotonic timestamp when the process was launched."""
228
+
229
+ def is_running(self) -> bool:
230
+ """Check if the process is still running."""
231
+ return self.process.poll() is None
232
+
233
+ def wait(self, timeout: Optional[int] = None) -> RunResult:
234
+ """Block until the process finishes or timeout expires."""
235
+ try:
236
+ self.process.wait(timeout=timeout)
237
+ duration = time.monotonic() - self.start_time
238
+ return RunResult(
239
+ success=self.process.returncode == 0,
240
+ returncode=self.process.returncode,
241
+ duration_seconds=duration,
242
+ )
243
+ except subprocess.TimeoutExpired:
244
+ duration = time.monotonic() - self.start_time
245
+ kill_process()
246
+ return RunResult(
247
+ success=False,
248
+ timed_out=True,
249
+ duration_seconds=duration,
250
+ )
251
+
252
+ def kill(self) -> None:
253
+ """Terminate the process immediately."""
254
+ kill_process()
255
+
256
+ def kill_when_idle(self, **kwargs) -> bool:
257
+ """Monitor and kill the process when it becomes idle.
258
+
259
+ Accepts the same keyword arguments as :func:`kill_when_idle`.
260
+ This call blocks until the process is killed or exits.
261
+ """
262
+ return kill_when_idle(**kwargs)
263
+
264
+
265
+ def run_async(
266
+ model_path: Union[str, Path],
267
+ process_chain: Optional[ProcessChain] = None,
268
+ *,
269
+ exe_path: Optional[Union[str, Path]] = None,
270
+ ) -> RunHandle:
271
+ """Launch DesignBuilder in non-blocking mode.
272
+
273
+ Returns a :class:`RunHandle` that can be used to wait for
274
+ completion, kill the process, or monitor for idle termination.
275
+
276
+ Parameters
277
+ ----------
278
+ model_path : str or Path
279
+ Path to the .dsb model file.
280
+ process_chain : ProcessChain, optional
281
+ Commands to execute.
282
+ exe_path : str or Path, optional
283
+ Explicit path to DesignBuilder.exe.
284
+
285
+ Returns
286
+ -------
287
+ RunHandle
288
+ Handle for the launched process.
289
+ """
290
+ exe = find_designbuilder(exe_path)
291
+ model = Path(model_path)
292
+
293
+ cmd = [str(exe), str(model)]
294
+ if process_chain is not None:
295
+ cmd.append(process_chain.to_string())
296
+
297
+ proc = subprocess.Popen(cmd)
298
+ return RunHandle(process=proc)
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: db-process
3
+ Version: 0.1.0
4
+ Summary: Python interface to DesignBuilder command line processing.
5
+ Author-email: Bartlomiej Tokarzewski <bartlomiej.tokarzewski@gmail.com>
6
+ Project-URL: Homepage, https://github.com/tokarzewski/db-process
7
+ Project-URL: Bug Tracker, https://github.com/tokarzewski/db-process/issues
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: psutil>=5.9.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest; extra == "dev"
14
+ Requires-Dist: ruff; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # db-process
18
+ DesignBuilder command line processing
@@ -0,0 +1,9 @@
1
+ db_process/__init__.py,sha256=Zf8vIjye2ejV3CZJP1HUyCA5F8dSIrm14_eMcoTgKLk,1822
2
+ db_process/commands.py,sha256=1E1EMxkx8lWLBbD5z4hZFEMRZV8XGbFU2xFzX92aXlY,12470
3
+ db_process/executable.py,sha256=0nQ38-x6OWUpd11ac3DRziwX8mHMz5xtI9xOyTXauAY,1957
4
+ db_process/runner.py,sha256=HMWbykseGAuKSgtmsIFv1xjRWI69QD0d8p5PwgmrCiQ,8633
5
+ db_process-0.1.0.dist-info/licenses/LICENSE,sha256=Dik23IwCfTKw3e2cSounRZn27EeAI421gnZ9vzgWdJw,1088
6
+ db_process-0.1.0.dist-info/METADATA,sha256=WHV8vRgg4EI5lV5v7jw3vk9flDBBaa0orFkoknHxRFA,635
7
+ db_process-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ db_process-0.1.0.dist-info/top_level.txt,sha256=yOsVAh7uYY7yuQvrtzeyg05CLpYbNjePhNn1V16XloI,11
9
+ db_process-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tokarzewski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ db_process