mccode-plumber 0.14.4__tar.gz → 0.15.0__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.
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/.github/workflows/pip.yml +6 -11
- {mccode_plumber-0.14.4/src/mccode_plumber.egg-info → mccode_plumber-0.15.0}/PKG-INFO +11 -3
- mccode_plumber-0.15.0/mypy.ini +2 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/pyproject.toml +32 -2
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/epics.py +4 -2
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/CommandChannel.py +2 -2
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/CommandHandler.py +9 -3
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/CommandStatus.py +6 -4
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/JobStatus.py +16 -13
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/WorkerFinder.py +5 -2
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/WorkerJobPool.py +2 -3
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/WorkerStatus.py +10 -11
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/WriteJob.py +4 -5
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/kafka.py +1 -1
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/manage/efu.py +4 -3
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/manage/ensure.py +17 -1
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/manage/forwarder.py +13 -7
- mccode_plumber-0.15.0/src/mccode_plumber/manage/manager.py +123 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/manage/orchestrate.py +7 -5
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/splitrun.py +6 -1
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/writer.py +7 -7
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0/src/mccode_plumber.egg-info}/PKG-INFO +11 -3
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber.egg-info/SOURCES.txt +4 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber.egg-info/requires.txt +6 -2
- mccode_plumber-0.15.0/tests/fake_efu.py +70 -0
- mccode_plumber-0.15.0/tests/fake_manager.py +28 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/tests/test_epics.py +16 -5
- mccode_plumber-0.15.0/tests/test_management.py +69 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/tests/test_splitrun.py +8 -2
- mccode_plumber-0.14.4/src/mccode_plumber/manage/manager.py +0 -113
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/.github/dependabot.yml +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/.github/workflows/wheels.yml +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/.gitignore +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/README.md +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/setup.cfg +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/__init__.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/conductor.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/epics_watcher.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/InThreadStatusTracker.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/JobHandler.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/KafkaTopicUrl.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/StateExtractor.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/__init__.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/forwarder.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/manage/__init__.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/manage/epics.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/manage/writer.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/mccode.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/utils.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber.egg-info/dependency_links.txt +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber.egg-info/entry_points.txt +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber.egg-info/top_level.txt +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/tests/test_orchestration_utils.py +0 -0
- {mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/tests/test_writer.py +0 -0
|
@@ -2,10 +2,10 @@ name: Pip
|
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
workflow_dispatch:
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
pull_request:
|
|
6
|
+
push:
|
|
7
|
+
branches:
|
|
8
|
+
- main
|
|
9
9
|
|
|
10
10
|
jobs:
|
|
11
11
|
build:
|
|
@@ -15,7 +15,7 @@ jobs:
|
|
|
15
15
|
fail-fast: false
|
|
16
16
|
matrix:
|
|
17
17
|
platform: [windows-latest, macos-latest, ubuntu-latest]
|
|
18
|
-
python-version: ["3.
|
|
18
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
19
19
|
|
|
20
20
|
steps:
|
|
21
21
|
- uses: actions/checkout@v6
|
|
@@ -24,15 +24,10 @@ jobs:
|
|
|
24
24
|
with:
|
|
25
25
|
python-version: ${{ matrix.python-version }}
|
|
26
26
|
|
|
27
|
-
- name: Set min macOS version
|
|
28
|
-
if: runner.os == 'macOS'
|
|
29
|
-
run: |
|
|
30
|
-
echo "MACOS_DEPLOYMENT_TARGET=10.14" >> $GITHUB_ENV
|
|
31
|
-
|
|
32
27
|
- name: Build and install
|
|
33
28
|
run: pip install --verbose .
|
|
34
29
|
|
|
35
30
|
- name: Test
|
|
36
31
|
run: |
|
|
37
|
-
python -m pip install pytest
|
|
32
|
+
python -m pip install pytest "niess>=0.1.4"
|
|
38
33
|
python -m pytest
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mccode-plumber
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.0
|
|
4
4
|
Author-email: Gregory Tucker <gregory.tucker@ess.eu>
|
|
5
5
|
Classifier: License :: OSI Approved :: BSD License
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
6
11
|
Description-Content-Type: text/markdown
|
|
7
12
|
Requires-Dist: p4p
|
|
8
13
|
Requires-Dist: kafka-python>=2.2.11
|
|
9
14
|
Requires-Dist: ess-streaming-data-types>=0.14.0
|
|
10
|
-
Requires-Dist: restage>=0.
|
|
15
|
+
Requires-Dist: restage>=0.10.0
|
|
11
16
|
Requires-Dist: mccode-to-kafka>=0.3.1
|
|
12
|
-
Requires-Dist: moreniius>=0.6.
|
|
17
|
+
Requires-Dist: moreniius>=0.6.2
|
|
13
18
|
Requires-Dist: icecream
|
|
14
19
|
Requires-Dist: ephemeral-port-reserve
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest; extra == "test"
|
|
22
|
+
Requires-Dist: niess>=0.1.4; extra == "test"
|
|
15
23
|
|
|
16
24
|
# McCode Plumber
|
|
17
25
|
Setup, run, and teardown the infrastructure for splitrun McCode scans sending data through Kafka into NeXus
|
|
@@ -8,9 +8,9 @@ dependencies = [
|
|
|
8
8
|
'p4p',
|
|
9
9
|
'kafka-python>=2.2.11',
|
|
10
10
|
'ess-streaming-data-types>=0.14.0',
|
|
11
|
-
'restage>=0.
|
|
11
|
+
'restage>=0.10.0',
|
|
12
12
|
'mccode-to-kafka>=0.3.1',
|
|
13
|
-
'moreniius>=0.6.
|
|
13
|
+
'moreniius>=0.6.2',
|
|
14
14
|
'icecream',
|
|
15
15
|
'ephemeral-port-reserve',
|
|
16
16
|
]
|
|
@@ -20,9 +20,17 @@ authors = [
|
|
|
20
20
|
]
|
|
21
21
|
classifiers = [
|
|
22
22
|
"License :: OSI Approved :: BSD License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
23
28
|
]
|
|
24
29
|
dynamic = ['version']
|
|
25
30
|
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
test = ["pytest", "niess>=0.1.4"]
|
|
33
|
+
|
|
26
34
|
[project.scripts]
|
|
27
35
|
mp-splitrun = 'mccode_plumber.splitrun:main'
|
|
28
36
|
mp-epics = 'mccode_plumber.epics:run'
|
|
@@ -44,3 +52,25 @@ mp-nexus-services = 'mccode_plumber.manage.orchestrate:services'
|
|
|
44
52
|
|
|
45
53
|
[tool.setuptools_scm]
|
|
46
54
|
|
|
55
|
+
[tool.tox]
|
|
56
|
+
legacy_tox_ini = """
|
|
57
|
+
[tox]
|
|
58
|
+
min_version = 4.0
|
|
59
|
+
env_list =
|
|
60
|
+
py313
|
|
61
|
+
py312
|
|
62
|
+
py311
|
|
63
|
+
type
|
|
64
|
+
|
|
65
|
+
[testenv]
|
|
66
|
+
deps =
|
|
67
|
+
pytest
|
|
68
|
+
niess>=0.1.4
|
|
69
|
+
commands = pytest tests
|
|
70
|
+
|
|
71
|
+
[testenv:type]
|
|
72
|
+
deps =
|
|
73
|
+
mypy
|
|
74
|
+
types-PyYAML
|
|
75
|
+
commands = mypy src
|
|
76
|
+
"""
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
2
4
|
from p4p.nt import NTScalar
|
|
3
5
|
from p4p.server import Server, StaticProvider
|
|
4
6
|
from p4p.server.thread import SharedPV
|
|
@@ -96,7 +98,7 @@ def parse_args():
|
|
|
96
98
|
return parameters, args
|
|
97
99
|
|
|
98
100
|
|
|
99
|
-
def main(names: dict[str, NTScalar], prefix: str = None, filename_required: bool = True):
|
|
101
|
+
def main(names: dict[str, NTScalar], prefix: str | None = None, filename_required: bool = True):
|
|
100
102
|
provider = StaticProvider('mailbox') # 'mailbox' is an arbitrary name
|
|
101
103
|
|
|
102
104
|
if filename_required and 'mcpl_filename' not in names:
|
|
@@ -118,7 +120,7 @@ def run():
|
|
|
118
120
|
main(parameters, prefix=args.prefix)
|
|
119
121
|
|
|
120
122
|
|
|
121
|
-
def start(parameters, prefix: str = None):
|
|
123
|
+
def start(parameters, prefix: str | None = None):
|
|
122
124
|
from multiprocessing import Process
|
|
123
125
|
proc = Process(target=main, args=(parameters, prefix))
|
|
124
126
|
proc.start()
|
|
@@ -74,8 +74,8 @@ class CommandChannel(object):
|
|
|
74
74
|
:param command_topic_url: The url of the Kafka topic to where the file-writer status/command messages are published.
|
|
75
75
|
"""
|
|
76
76
|
kafka_address = KafkaTopicUrl(command_topic_url)
|
|
77
|
-
self.status_queue = Queue()
|
|
78
|
-
self.to_thread_queue = Queue()
|
|
77
|
+
self.status_queue: Queue = Queue()
|
|
78
|
+
self.to_thread_queue: Queue = Queue()
|
|
79
79
|
thread_kwargs = {
|
|
80
80
|
"host_port": kafka_address.host_port,
|
|
81
81
|
"topic": kafka_address.topic,
|
|
@@ -32,7 +32,10 @@ class CommandHandler:
|
|
|
32
32
|
"""
|
|
33
33
|
:return: True if the command completed successfully. False otherwise.
|
|
34
34
|
"""
|
|
35
|
-
|
|
35
|
+
command = self.command_channel.get_command(self.command_id)
|
|
36
|
+
if command is None:
|
|
37
|
+
return False
|
|
38
|
+
current_state = command.state
|
|
36
39
|
if current_state == CommandState.ERROR:
|
|
37
40
|
raise RuntimeError(
|
|
38
41
|
f'Command failed with error message "{self.get_message()}".'
|
|
@@ -52,7 +55,10 @@ class CommandHandler:
|
|
|
52
55
|
return command.message
|
|
53
56
|
|
|
54
57
|
def set_timeout(self, new_timeout: timedelta):
|
|
55
|
-
self.command_channel.get_command(self.command_id)
|
|
58
|
+
if command := self.command_channel.get_command(self.command_id):
|
|
59
|
+
command.timeout = new_timeout
|
|
56
60
|
|
|
57
61
|
def get_timeout(self):
|
|
58
|
-
|
|
62
|
+
if command := self.command_channel.get_command(self.command_id):
|
|
63
|
+
return command.timeout
|
|
64
|
+
return None
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from datetime import datetime, timedelta
|
|
2
4
|
from enum import Enum, auto
|
|
3
5
|
from typing import Optional
|
|
@@ -18,7 +20,7 @@ class CommandState(Enum):
|
|
|
18
20
|
SUCCESS = auto()
|
|
19
21
|
|
|
20
22
|
|
|
21
|
-
class CommandStatus
|
|
23
|
+
class CommandStatus:
|
|
22
24
|
"""
|
|
23
25
|
The status of a command.
|
|
24
26
|
"""
|
|
@@ -35,11 +37,11 @@ class CommandStatus(object):
|
|
|
35
37
|
self._last_update = datetime.now()
|
|
36
38
|
self._state = CommandState.NO_COMMAND
|
|
37
39
|
self._message = ""
|
|
38
|
-
self._response_code = None
|
|
40
|
+
self._response_code: int | None = None
|
|
39
41
|
|
|
40
|
-
def __eq__(self, other_status
|
|
42
|
+
def __eq__(self, other_status):
|
|
41
43
|
if not isinstance(other_status, CommandStatus):
|
|
42
|
-
|
|
44
|
+
return NotImplemented
|
|
43
45
|
return (
|
|
44
46
|
other_status.command_id == self.command_id
|
|
45
47
|
and other_status.job_id == self.job_id
|
{mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/JobStatus.py
RENAMED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from datetime import datetime, timedelta
|
|
2
4
|
from enum import Enum, auto
|
|
3
5
|
from typing import Dict, Optional
|
|
@@ -48,7 +50,7 @@ class JobStatus:
|
|
|
48
50
|
if new_status.message:
|
|
49
51
|
self._message = new_status.message
|
|
50
52
|
self._service_id = new_status.service_id
|
|
51
|
-
self._file_name = new_status.file_name
|
|
53
|
+
self._file_name = new_status.file_name or ""
|
|
52
54
|
self._last_update = new_status.last_update
|
|
53
55
|
self._metadata = new_status.metadata
|
|
54
56
|
|
|
@@ -61,7 +63,7 @@ class JobStatus:
|
|
|
61
63
|
self.state != JobState.DONE
|
|
62
64
|
and self.state != JobState.ERROR
|
|
63
65
|
and self.state != JobState.TIMEOUT
|
|
64
|
-
and current_time - self.last_update > self._timeout
|
|
66
|
+
and self._timeout and current_time - self.last_update > self._timeout
|
|
65
67
|
):
|
|
66
68
|
self._state = JobState.TIMEOUT
|
|
67
69
|
self._last_update = current_time
|
|
@@ -106,8 +108,13 @@ class JobStatus:
|
|
|
106
108
|
"""
|
|
107
109
|
return self._state
|
|
108
110
|
|
|
111
|
+
@state.setter
|
|
112
|
+
def state(self, new_state: JobState) -> None:
|
|
113
|
+
self._state = new_state
|
|
114
|
+
self._last_update = datetime.now()
|
|
115
|
+
|
|
109
116
|
@property
|
|
110
|
-
def file_name(self) -> str:
|
|
117
|
+
def file_name(self) -> str | None:
|
|
111
118
|
"""
|
|
112
119
|
The file name of the job. None if the file name is not known.
|
|
113
120
|
"""
|
|
@@ -120,11 +127,6 @@ class JobStatus:
|
|
|
120
127
|
self._file_name = new_file_name
|
|
121
128
|
self._last_update = datetime.now()
|
|
122
129
|
|
|
123
|
-
@state.setter
|
|
124
|
-
def state(self, new_state: JobState) -> None:
|
|
125
|
-
self._state = new_state
|
|
126
|
-
self._last_update = datetime.now()
|
|
127
|
-
|
|
128
130
|
@property
|
|
129
131
|
def message(self) -> str:
|
|
130
132
|
"""
|
|
@@ -132,6 +134,12 @@ class JobStatus:
|
|
|
132
134
|
"""
|
|
133
135
|
return self._message
|
|
134
136
|
|
|
137
|
+
@message.setter
|
|
138
|
+
def message(self, new_message: str) -> None:
|
|
139
|
+
if new_message:
|
|
140
|
+
self._message = new_message
|
|
141
|
+
self._last_update = datetime.now()
|
|
142
|
+
|
|
135
143
|
@property
|
|
136
144
|
def metadata(self) -> Optional[Dict]:
|
|
137
145
|
return self._metadata
|
|
@@ -140,8 +148,3 @@ class JobStatus:
|
|
|
140
148
|
def metadata(self, metadata: Dict) -> None:
|
|
141
149
|
self._metadata = metadata
|
|
142
150
|
|
|
143
|
-
@message.setter
|
|
144
|
-
def message(self, new_message: str) -> None:
|
|
145
|
-
if new_message:
|
|
146
|
-
self._message = new_message
|
|
147
|
-
self._last_update = datetime.now()
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import uuid
|
|
2
4
|
from datetime import datetime
|
|
3
5
|
from typing import Dict, List, Optional
|
|
@@ -84,7 +86,8 @@ class WorkerFinderBase:
|
|
|
84
86
|
:param job_id: The job identifier of the currently running file-writer job.
|
|
85
87
|
:return: A CommandHandler instance for (more) easily checking the outcome of the "abort" command.
|
|
86
88
|
"""
|
|
87
|
-
|
|
89
|
+
from datetime import datetime, UTC
|
|
90
|
+
return self.try_send_stop_time(service_id, job_id, datetime.fromtimestamp(0, UTC))
|
|
88
91
|
|
|
89
92
|
def list_known_workers(self) -> List[WorkerStatus]:
|
|
90
93
|
"""
|
|
@@ -115,7 +118,7 @@ class WorkerFinderBase:
|
|
|
115
118
|
return JobState.UNAVAILABLE
|
|
116
119
|
return current_job.state
|
|
117
120
|
|
|
118
|
-
def get_job_status(self, job_id: str) -> JobStatus:
|
|
121
|
+
def get_job_status(self, job_id: str) -> JobStatus | None:
|
|
119
122
|
"""
|
|
120
123
|
Get the full (known) status of a specific job.
|
|
121
124
|
:param job_id: The (unique) identifier of the job that we are trying to find the status of.
|
|
@@ -63,8 +63,7 @@ class WorkerJobPool(WorkerFinder):
|
|
|
63
63
|
"""
|
|
64
64
|
self.command_channel.add_job_id(job.job_id)
|
|
65
65
|
self.command_channel.add_command_id(job.job_id, job.job_id)
|
|
66
|
-
self.command_channel.get_command(
|
|
67
|
-
|
|
68
|
-
).state = CommandState.WAITING_RESPONSE
|
|
66
|
+
if command := self.command_channel.get_command(job.job_id):
|
|
67
|
+
command.state = CommandState.WAITING_RESPONSE
|
|
69
68
|
self._send_pool_message(job.get_start_message())
|
|
70
69
|
return CommandHandler(self.command_channel, job.job_id)
|
|
@@ -16,7 +16,7 @@ class WorkerState(Enum):
|
|
|
16
16
|
UNAVAILABLE = auto()
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
class WorkerStatus
|
|
19
|
+
class WorkerStatus:
|
|
20
20
|
"""
|
|
21
21
|
Contains general status information about a worker.
|
|
22
22
|
"""
|
|
@@ -27,9 +27,9 @@ class WorkerStatus(object):
|
|
|
27
27
|
self._timeout = timeout
|
|
28
28
|
self._state = WorkerState.UNAVAILABLE
|
|
29
29
|
|
|
30
|
-
def __eq__(self, other_status
|
|
30
|
+
def __eq__(self, other_status) -> bool:
|
|
31
31
|
if not isinstance(other_status, WorkerStatus):
|
|
32
|
-
|
|
32
|
+
return NotImplemented
|
|
33
33
|
return (
|
|
34
34
|
self.service_id == other_status.service_id
|
|
35
35
|
and self.state == other_status.state
|
|
@@ -55,18 +55,11 @@ class WorkerStatus(object):
|
|
|
55
55
|
"""
|
|
56
56
|
if (
|
|
57
57
|
self.state != WorkerState.UNAVAILABLE
|
|
58
|
-
and current_time - self.last_update > self._timeout
|
|
58
|
+
and self._timeout and current_time - self.last_update > self._timeout
|
|
59
59
|
):
|
|
60
60
|
self._state = WorkerState.UNAVAILABLE
|
|
61
61
|
self._last_update = current_time
|
|
62
62
|
|
|
63
|
-
@property
|
|
64
|
-
def state(self) -> WorkerState:
|
|
65
|
-
"""
|
|
66
|
-
The current state of the worker.
|
|
67
|
-
"""
|
|
68
|
-
return self._state
|
|
69
|
-
|
|
70
63
|
@property
|
|
71
64
|
def service_id(self) -> str:
|
|
72
65
|
"""
|
|
@@ -82,7 +75,13 @@ class WorkerStatus(object):
|
|
|
82
75
|
"""
|
|
83
76
|
return self._last_update
|
|
84
77
|
|
|
78
|
+
@property
|
|
79
|
+
def state(self) -> WorkerState:
|
|
80
|
+
return self._state
|
|
81
|
+
|
|
85
82
|
@state.setter
|
|
86
83
|
def state(self, new_state: WorkerState):
|
|
87
84
|
self._last_update = datetime.now()
|
|
88
85
|
self._state = new_state
|
|
86
|
+
|
|
87
|
+
|
{mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/WriteJob.py
RENAMED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import uuid
|
|
2
4
|
from datetime import datetime, timedelta
|
|
3
5
|
|
|
@@ -15,7 +17,7 @@ class WriteJob:
|
|
|
15
17
|
file_name: str,
|
|
16
18
|
broker: str,
|
|
17
19
|
start_time: datetime,
|
|
18
|
-
stop_time: datetime = None,
|
|
20
|
+
stop_time: datetime | None = None,
|
|
19
21
|
job_id="",
|
|
20
22
|
instrument_name: str = "",
|
|
21
23
|
run_name: str = "",
|
|
@@ -33,10 +35,7 @@ class WriteJob:
|
|
|
33
35
|
else:
|
|
34
36
|
self.job_id = str(uuid.uuid1())
|
|
35
37
|
self.start = start_time
|
|
36
|
-
|
|
37
|
-
self.stop = self.start + timedelta(days=365.25 * 10)
|
|
38
|
-
else:
|
|
39
|
-
self.stop = stop_time
|
|
38
|
+
self.stop = stop_time or self.start + timedelta(days=365.25 * 10)
|
|
40
39
|
self._service_id = ""
|
|
41
40
|
self.broker = broker
|
|
42
41
|
self.instrument_name = instrument_name
|
|
@@ -33,7 +33,7 @@ def register_kafka_topics(broker: str, topics: list[str]):
|
|
|
33
33
|
config = {
|
|
34
34
|
# 'cleanup.policy': 'delete',
|
|
35
35
|
# 'delete.retention.ms': 60000,
|
|
36
|
-
'max.message.bytes': 104857600,
|
|
36
|
+
'max.message.bytes': '104857600',
|
|
37
37
|
# 'retention.bytes': 10737418240,
|
|
38
38
|
# 'retention.ms': 30000,
|
|
39
39
|
# 'segment.bytes': 104857600,
|
|
@@ -21,9 +21,10 @@ class EventFormationUnitConfig:
|
|
|
21
21
|
def from_dict(cls, data: dict):
|
|
22
22
|
required = ('binary', 'config', 'calibration', 'topic', 'port')
|
|
23
23
|
if any(req not in data for req in required):
|
|
24
|
-
|
|
25
|
-
msg = ', '.join(
|
|
26
|
-
|
|
24
|
+
missing = [req for req in required if req not in data]
|
|
25
|
+
msg = ', '.join(missing)
|
|
26
|
+
val = f"value{'' if len(missing) == 1 else 's'}"
|
|
27
|
+
raise ValueError(f"Missing required {val}: {msg}")
|
|
27
28
|
binary = ensure_readable_file(data['binary'])
|
|
28
29
|
config = ensure_readable_file(data['config'])
|
|
29
30
|
calibration = ensure_readable_file(data['calibration'])
|
|
@@ -7,7 +7,23 @@ def message(mode) -> str:
|
|
|
7
7
|
|
|
8
8
|
def ensure_executable(path: str| Path) -> Path:
|
|
9
9
|
from shutil import which
|
|
10
|
-
|
|
10
|
+
import os
|
|
11
|
+
p = Path(path)
|
|
12
|
+
# If the path exists as given, accept it (handles absolute and relative files)
|
|
13
|
+
if p.exists():
|
|
14
|
+
return p
|
|
15
|
+
|
|
16
|
+
# On Windows try PATHEXT extensions for provided path (handles .py etc.)
|
|
17
|
+
if os.name == "nt":
|
|
18
|
+
pathext = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD;.PY;.PYW").split(
|
|
19
|
+
os.pathsep)
|
|
20
|
+
for ext in pathext:
|
|
21
|
+
candidate = Path(str(p) + ext)
|
|
22
|
+
if candidate.exists():
|
|
23
|
+
return candidate
|
|
24
|
+
|
|
25
|
+
# Fallback to shutil.which (searches PATH and PATHEXT)
|
|
26
|
+
found = which(str(path))
|
|
11
27
|
if found is None:
|
|
12
28
|
raise FileNotFoundError(path)
|
|
13
29
|
return Path(found)
|
|
@@ -35,20 +35,26 @@ class Forwarder(Manager):
|
|
|
35
35
|
retrieve: bool = False
|
|
36
36
|
verbosity: str | None = None
|
|
37
37
|
_command: Path = field(default_factory=lambda: Path('forwarder-launch'))
|
|
38
|
+
_broker: str = field(default='localhost:9092')
|
|
39
|
+
_config: str = field(default='ForwardConfig')
|
|
40
|
+
_status: str = field(default='ForwardStatus')
|
|
38
41
|
|
|
39
42
|
def __post_init__(self):
|
|
40
43
|
from mccode_plumber.kafka import register_kafka_topics, all_exist
|
|
41
44
|
self._command =ensure_executable(self._command)
|
|
42
45
|
if self.broker is None:
|
|
43
|
-
self.broker =
|
|
46
|
+
self.broker = self._broker
|
|
44
47
|
if self.config is None:
|
|
45
|
-
self.config =
|
|
48
|
+
self.config = self._config
|
|
46
49
|
if self.status is None:
|
|
47
|
-
self.status =
|
|
50
|
+
self.status = self._status
|
|
48
51
|
if '/' not in self.config:
|
|
49
52
|
self.config = f'{self.broker}/{self.config}'
|
|
50
53
|
if '/' not in self.status:
|
|
51
54
|
self.status = f'{self.broker}/{self.status}'
|
|
55
|
+
self._broker = self.broker
|
|
56
|
+
self._config = self.config
|
|
57
|
+
self._status = self.status
|
|
52
58
|
|
|
53
59
|
for broker_topic in (self.config, self.status):
|
|
54
60
|
b, t = broker_topic.split('/')
|
|
@@ -58,11 +64,11 @@ class Forwarder(Manager):
|
|
|
58
64
|
|
|
59
65
|
|
|
60
66
|
def __run_command__(self) -> list[str]:
|
|
61
|
-
args = [
|
|
67
|
+
args: list[str] = [
|
|
62
68
|
self._command.as_posix(),
|
|
63
|
-
'--config-topic', self.
|
|
64
|
-
'--status-topic', self.
|
|
65
|
-
'--output-broker', self.
|
|
69
|
+
'--config-topic', self._config,
|
|
70
|
+
'--status-topic', self._status,
|
|
71
|
+
'--output-broker', self._broker,
|
|
66
72
|
]
|
|
67
73
|
if not self.retrieve:
|
|
68
74
|
args.append('--skip-retrieval')
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from subprocess import Popen, PIPE
|
|
4
|
+
from threading import Thread
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from colorama import Fore, Back, Style
|
|
7
|
+
from colorama.ansi import AnsiStyle
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IOType(Enum):
|
|
11
|
+
stdout = 1
|
|
12
|
+
stderr = 2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Manager:
|
|
17
|
+
"""
|
|
18
|
+
Command and control of a process
|
|
19
|
+
|
|
20
|
+
Properties
|
|
21
|
+
----------
|
|
22
|
+
_process: a subprocess.Popen instance
|
|
23
|
+
"""
|
|
24
|
+
name: str
|
|
25
|
+
style: AnsiStyle
|
|
26
|
+
_process: Popen | None
|
|
27
|
+
_stdout_thread: Thread | None
|
|
28
|
+
_stderr_thread: Thread | None
|
|
29
|
+
|
|
30
|
+
def __run_command__(self) -> list[str]:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
def finalize(self):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def fieldnames(cls) -> list[str]:
|
|
38
|
+
from dataclasses import fields
|
|
39
|
+
return [field.name for field in fields(cls)]
|
|
40
|
+
|
|
41
|
+
def _read_stream(self, stream, io_type: IOType):
|
|
42
|
+
"""Read lines from stream and print them until EOF.
|
|
43
|
+
|
|
44
|
+
This replaces the previous behaviour of sending lines over a
|
|
45
|
+
multiprocessing Connection. Printing directly from the reader
|
|
46
|
+
threads is sufficient because the manager previously only used
|
|
47
|
+
the connection to relay subprocess stdout/stderr back to the
|
|
48
|
+
parent process for display.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
for line in iter(stream.readline, ''):
|
|
52
|
+
if not line:
|
|
53
|
+
break
|
|
54
|
+
# format and print the line, preserving original behaviour
|
|
55
|
+
formatted = f'{self.style}{self.name}:{Style.RESET_ALL} {line}'
|
|
56
|
+
if io_type == IOType.stdout:
|
|
57
|
+
print(formatted, end='')
|
|
58
|
+
else:
|
|
59
|
+
from sys import stderr
|
|
60
|
+
print(formatted, file=stderr, end='')
|
|
61
|
+
except ValueError:
|
|
62
|
+
pass # stream closed
|
|
63
|
+
finally:
|
|
64
|
+
try:
|
|
65
|
+
stream.close()
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def start(cls, **config):
|
|
71
|
+
names = cls.fieldnames()
|
|
72
|
+
kwargs = {k: config[k] for k in names if k in config}
|
|
73
|
+
if any(k not in names for k in config):
|
|
74
|
+
raise ValueError(f'{config} expected to contain only {names}')
|
|
75
|
+
for p in ('_process', '_stdout_thread', '_stderr_thread'):
|
|
76
|
+
if p not in kwargs:
|
|
77
|
+
kwargs[p] = None
|
|
78
|
+
if 'name' not in kwargs:
|
|
79
|
+
kwargs['name'] = 'Managed process'
|
|
80
|
+
if 'style' not in kwargs:
|
|
81
|
+
kwargs['style'] = Fore.WHITE + Back.BLACK
|
|
82
|
+
|
|
83
|
+
manager = cls(**kwargs)
|
|
84
|
+
|
|
85
|
+
argv = manager.__run_command__()
|
|
86
|
+
shell = isinstance(argv, str)
|
|
87
|
+
# announce start directly instead of sending via a Connection
|
|
88
|
+
print(f'Starting {argv if shell else " ".join(argv)}')
|
|
89
|
+
|
|
90
|
+
manager._process = Popen(
|
|
91
|
+
argv, shell=shell, stdout=PIPE, stderr=PIPE, bufsize=1,
|
|
92
|
+
universal_newlines=True,
|
|
93
|
+
)
|
|
94
|
+
manager._stdout_thread = Thread(
|
|
95
|
+
target=manager._read_stream,
|
|
96
|
+
args=(manager._process.stdout, IOType.stdout),
|
|
97
|
+
daemon=True,
|
|
98
|
+
)
|
|
99
|
+
manager._stderr_thread = Thread(
|
|
100
|
+
target=manager._read_stream,
|
|
101
|
+
args=(manager._process.stderr, IOType.stderr),
|
|
102
|
+
daemon=True,
|
|
103
|
+
)
|
|
104
|
+
manager._stdout_thread.start()
|
|
105
|
+
manager._stderr_thread.start()
|
|
106
|
+
return manager
|
|
107
|
+
|
|
108
|
+
def stop(self):
|
|
109
|
+
self.finalize()
|
|
110
|
+
if self._process:
|
|
111
|
+
self._process.terminate()
|
|
112
|
+
self._process.wait()
|
|
113
|
+
|
|
114
|
+
def poll(self):
|
|
115
|
+
"""Check whether the managed process is still running.
|
|
116
|
+
|
|
117
|
+
Previously this drained and printed any messages received over a
|
|
118
|
+
multiprocessing Connection. Reader threads now handle printing,
|
|
119
|
+
so poll only needs to report process liveness.
|
|
120
|
+
"""
|
|
121
|
+
if not self._process:
|
|
122
|
+
return False
|
|
123
|
+
return self._process.poll() is None
|
|
@@ -17,17 +17,17 @@ TOPICS = {
|
|
|
17
17
|
}
|
|
18
18
|
PREFIX = 'mcstas:'
|
|
19
19
|
|
|
20
|
-
def guess_instr_config(name: str):
|
|
20
|
+
def guess_instr_config(name: str) -> Path:
|
|
21
21
|
guess = f'/event-formation-unit/configs/{name}/configs/{name}.json'
|
|
22
22
|
return ensure_readable_file(Path(guess))
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def guess_instr_calibration(name: str):
|
|
25
|
+
def guess_instr_calibration(name: str) -> Path:
|
|
26
26
|
guess = f'/event-formation-unit/configs/{name}/configs/{name}nullcalib.json'
|
|
27
27
|
return ensure_readable_file(Path(guess))
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def guess_instr_efu(name: str):
|
|
30
|
+
def guess_instr_efu(name: str) -> Path:
|
|
31
31
|
guess = name.split('_')[0].split('.')[0].split('-')[0].lower()
|
|
32
32
|
return ensure_executable(Path(guess))
|
|
33
33
|
|
|
@@ -193,8 +193,10 @@ def efu_parameter(s: str):
|
|
|
193
193
|
# likely to be needed. Finally, the config file can also be supplied to change, e.g.,
|
|
194
194
|
# number of pixels or rings, etc.
|
|
195
195
|
parts = s.split(',')
|
|
196
|
-
|
|
197
|
-
data[
|
|
196
|
+
binary: Path = ensure_executable(parts[0])
|
|
197
|
+
data : dict[str, int | str | Path] = {
|
|
198
|
+
'topic': TOPICS['event'], 'port': 9000, 'binary': binary, 'name': binary.stem
|
|
199
|
+
}
|
|
198
200
|
|
|
199
201
|
if len(parts) > 1 and (len(parts) > 2 or not parts[1].isnumeric()):
|
|
200
202
|
data['calibration'] = parts[1]
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
|
|
1
6
|
def make_parser():
|
|
2
7
|
from mccode_plumber import __version__
|
|
3
8
|
from restage.splitrun import make_splitrun_parser
|
|
@@ -16,7 +21,7 @@ def monitors_to_kafka_callback_with_arguments(
|
|
|
16
21
|
):
|
|
17
22
|
from mccode_to_kafka.sender import send_histograms
|
|
18
23
|
|
|
19
|
-
partial_kwargs = {'broker': broker}
|
|
24
|
+
partial_kwargs: dict[str, Union[str,list[str]]] = {'broker': broker}
|
|
20
25
|
if topic is not None and source is not None and names is not None and len(names) > 1:
|
|
21
26
|
raise ValueError("Cannot specify both topic/source and multiple names simultaneously.")
|
|
22
27
|
|
|
@@ -137,7 +137,7 @@ def insert_events_in_nexus_structure(ns: dict, config: dict):
|
|
|
137
137
|
return ns
|
|
138
138
|
|
|
139
139
|
|
|
140
|
-
def get_writer_pool(broker: str = None, job: str = None, command: str = None):
|
|
140
|
+
def get_writer_pool(broker: str | None = None, job: str | None = None, command: str | None = None):
|
|
141
141
|
from .file_writer_control import WorkerJobPool
|
|
142
142
|
print(f'Create a Writer pool for {broker=} {job=} {command=}')
|
|
143
143
|
pool = WorkerJobPool(f"{broker}/{job}", f"{broker}/{command}")
|
|
@@ -151,17 +151,17 @@ def make_define_nexus_structure():
|
|
|
151
151
|
def define_nexus_structure(
|
|
152
152
|
instr: Path | str,
|
|
153
153
|
pvs: list[dict],
|
|
154
|
-
title: str = None,
|
|
155
|
-
event_stream: dict[str, str] = None,
|
|
154
|
+
title: str | None = None,
|
|
155
|
+
event_stream: dict[str, str] | None = None,
|
|
156
156
|
file: Path | None = None,
|
|
157
157
|
func: Callable[[Instr], dict] | None = None,
|
|
158
158
|
binary: Path | None = None,
|
|
159
|
-
origin: str = None):
|
|
159
|
+
origin: str | None = None):
|
|
160
160
|
import json
|
|
161
161
|
from .mccode import get_mcstas_instr
|
|
162
162
|
if file is not None and file.exists():
|
|
163
|
-
with open(file, 'r') as
|
|
164
|
-
nexus_structure = json.load(
|
|
163
|
+
with open(file, 'r') as f:
|
|
164
|
+
nexus_structure = json.load(f)
|
|
165
165
|
elif func is not None:
|
|
166
166
|
nexus_structure = func(get_mcstas_instr(instr))
|
|
167
167
|
elif binary is not None and binary.exists():
|
|
@@ -173,7 +173,7 @@ def make_define_nexus_structure():
|
|
|
173
173
|
else:
|
|
174
174
|
nexus_structure = default_nexus_structure(get_mcstas_instr(instr), origin=origin)
|
|
175
175
|
nexus_structure = add_pvs_to_nexus_structure(nexus_structure, pvs)
|
|
176
|
-
nexus_structure = add_title_to_nexus_structure(nexus_structure, title)
|
|
176
|
+
nexus_structure = add_title_to_nexus_structure(nexus_structure, title or 'Unknown title')
|
|
177
177
|
# nexus_structure = insert_events_in_nexus_structure(nexus_structure, event_stream)
|
|
178
178
|
return nexus_structure
|
|
179
179
|
return define_nexus_structure
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mccode-plumber
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.0
|
|
4
4
|
Author-email: Gregory Tucker <gregory.tucker@ess.eu>
|
|
5
5
|
Classifier: License :: OSI Approved :: BSD License
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
6
11
|
Description-Content-Type: text/markdown
|
|
7
12
|
Requires-Dist: p4p
|
|
8
13
|
Requires-Dist: kafka-python>=2.2.11
|
|
9
14
|
Requires-Dist: ess-streaming-data-types>=0.14.0
|
|
10
|
-
Requires-Dist: restage>=0.
|
|
15
|
+
Requires-Dist: restage>=0.10.0
|
|
11
16
|
Requires-Dist: mccode-to-kafka>=0.3.1
|
|
12
|
-
Requires-Dist: moreniius>=0.6.
|
|
17
|
+
Requires-Dist: moreniius>=0.6.2
|
|
13
18
|
Requires-Dist: icecream
|
|
14
19
|
Requires-Dist: ephemeral-port-reserve
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest; extra == "test"
|
|
22
|
+
Requires-Dist: niess>=0.1.4; extra == "test"
|
|
15
23
|
|
|
16
24
|
# McCode Plumber
|
|
17
25
|
Setup, run, and teardown the infrastructure for splitrun McCode scans sending data through Kafka into NeXus
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
.gitignore
|
|
2
2
|
README.md
|
|
3
|
+
mypy.ini
|
|
3
4
|
pyproject.toml
|
|
4
5
|
.github/dependabot.yml
|
|
5
6
|
.github/workflows/pip.yml
|
|
@@ -41,7 +42,10 @@ src/mccode_plumber/manage/forwarder.py
|
|
|
41
42
|
src/mccode_plumber/manage/manager.py
|
|
42
43
|
src/mccode_plumber/manage/orchestrate.py
|
|
43
44
|
src/mccode_plumber/manage/writer.py
|
|
45
|
+
tests/fake_efu.py
|
|
46
|
+
tests/fake_manager.py
|
|
44
47
|
tests/test_epics.py
|
|
48
|
+
tests/test_management.py
|
|
45
49
|
tests/test_orchestration_utils.py
|
|
46
50
|
tests/test_splitrun.py
|
|
47
51
|
tests/test_writer.py
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
A fake EFU server for testing purposes. Arguments needed for a real EFU are accepted,
|
|
5
|
+
but only the command port is utilized.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
------
|
|
9
|
+
Extra carriage returns inserted to keep client and server timelines in sync vertically
|
|
10
|
+
|
|
11
|
+
(client) | (server)
|
|
12
|
+
$ | $ python fake_efu.py --cmdport 10123
|
|
13
|
+
$ echo hello | netcat localhost 10123 | 1. connection from ('127.0.0.1', xxxxx)
|
|
14
|
+
hello | ('127.0.0.1', xxxxx) says: hello
|
|
15
|
+
$ |
|
|
16
|
+
$ echo EXIT | netcat localhost 10123 | 1. connection from ('127.0.0.1', YYYYY)
|
|
17
|
+
<OK>$ | ('127.0.0.1', YYYYY) says: EXIT
|
|
18
|
+
$ |
|
|
19
|
+
$ | $
|
|
20
|
+
$ | $ python fake_efu.py --cmdport 10456
|
|
21
|
+
$ netcat localhost 10456 | 1. connection from ('127.0.0.1', ZZZZZ)
|
|
22
|
+
Hello? | ('127.0.0.1', ZZZZZ) says: Hello
|
|
23
|
+
Hello? |
|
|
24
|
+
Goodbye | ('127.0.0.1', ZZZZZ) says: Goodbye
|
|
25
|
+
Goodbye |
|
|
26
|
+
EXIT |
|
|
27
|
+
$ echo EXIT | netcat localhost 10123 | ('127.0.0.1', ZZZZZ) says: EXIT
|
|
28
|
+
<OK>$ |
|
|
29
|
+
$ | $
|
|
30
|
+
|
|
31
|
+
(Note that 'EXIT' within interactive netcat is a command and is not sent.)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def main():
|
|
35
|
+
import sys
|
|
36
|
+
from argparse import ArgumentParser
|
|
37
|
+
parser = ArgumentParser(prog='fake_efu', description='Fake Efu commands')
|
|
38
|
+
parser.add_argument('--cmdport', type=int)
|
|
39
|
+
args, unknown = parser.parse_known_args()
|
|
40
|
+
if unknown:
|
|
41
|
+
print(f"Ignoring unknown args: {unknown}", file=sys.stderr)
|
|
42
|
+
serve(args.cmdport)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def serve(p):
|
|
46
|
+
import socket
|
|
47
|
+
l = []
|
|
48
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
49
|
+
# Since we're undoubtedly calling this after an ephemeral_port_reserve.reserve() call
|
|
50
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
51
|
+
s.bind(('', p))
|
|
52
|
+
s.listen(1)
|
|
53
|
+
while 1:
|
|
54
|
+
(c, a) = s.accept()
|
|
55
|
+
l.append(c)
|
|
56
|
+
print(f'{len(l)}: connection from {a}')
|
|
57
|
+
received = str(c.recv(1024), 'utf-8')
|
|
58
|
+
print(f'{a} says: {received}')
|
|
59
|
+
if received == 'EXIT\n':
|
|
60
|
+
c.sendall(bytes('<OK>', 'utf-8'))
|
|
61
|
+
exit(0)
|
|
62
|
+
else:
|
|
63
|
+
c.sendall(bytes(received, 'utf-8'))
|
|
64
|
+
c.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == '__main__':
|
|
68
|
+
main()
|
|
69
|
+
else:
|
|
70
|
+
print(f'Imported? {__name__}')
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
def sender(port, message):
|
|
5
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
6
|
+
try:
|
|
7
|
+
sock.settimeout(1.0)
|
|
8
|
+
sock.connect(('localhost', port))
|
|
9
|
+
sock.sendall(bytes(message, 'utf-8'))
|
|
10
|
+
recv = str(sock.recv(1024), "utf-8")
|
|
11
|
+
except TimeoutError:
|
|
12
|
+
print("Timeout!")
|
|
13
|
+
exit(1)
|
|
14
|
+
except ConnectionRefusedError:
|
|
15
|
+
print('Connection Refused')
|
|
16
|
+
exit(1)
|
|
17
|
+
print(recv)
|
|
18
|
+
|
|
19
|
+
if __name__ == '__main__':
|
|
20
|
+
from argparse import ArgumentParser
|
|
21
|
+
parser = ArgumentParser()
|
|
22
|
+
parser.add_argument('p', type=int)
|
|
23
|
+
parser.add_argument('message', type=str, default='message')
|
|
24
|
+
args = parser.parse_args()
|
|
25
|
+
sender(args.p, args.message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
@@ -1,17 +1,28 @@
|
|
|
1
|
+
import multiprocessing
|
|
1
2
|
import unittest
|
|
2
3
|
|
|
3
4
|
|
|
5
|
+
def main_for_tests(instr: str, prefix: str):
|
|
6
|
+
"""Avoid needing to pickle p4p.nt.scalar.NTScalar values"""
|
|
7
|
+
from mccode_plumber.epics import main, convert_instr_parameters_to_nt
|
|
8
|
+
from mccode_antlr.loader.loader import parse_mcstas_instr
|
|
9
|
+
pvs = convert_instr_parameters_to_nt(parse_mcstas_instr(instr).parameters)
|
|
10
|
+
return main(pvs, prefix)
|
|
11
|
+
|
|
12
|
+
|
|
4
13
|
class EPICSTestCase(unittest.TestCase):
|
|
5
14
|
def setUp(self):
|
|
6
15
|
from uuid import uuid4
|
|
7
|
-
from
|
|
8
|
-
|
|
9
|
-
|
|
16
|
+
from mccode_antlr.loader.loader import parse_mccode_instr_parameters
|
|
17
|
+
|
|
18
|
+
# Ensure we use the same multiprocessing method on all systems, Python versions
|
|
19
|
+
# Thus ensuring only pickle-able types are provided to multiprocessing.Process
|
|
20
|
+
ctx = multiprocessing.get_context('spawn')
|
|
21
|
+
|
|
10
22
|
instr = 'define instrument blah(par1, double par2, int par3=1, string par4="string", double par5=5.5) trace end'
|
|
11
23
|
self.pars = parse_mccode_instr_parameters(instr)
|
|
12
|
-
self.pvs = convert_instr_parameters_to_nt(parse_mcstas_instr(instr).parameters)
|
|
13
24
|
self.prefix = f"test{str(uuid4()).replace('-', '')}:"
|
|
14
|
-
self.proc = Process(target=
|
|
25
|
+
self.proc = ctx.Process(target=main_for_tests, args=(instr, self.prefix))
|
|
15
26
|
self.proc.start()
|
|
16
27
|
|
|
17
28
|
def tearDown(self):
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import pytest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from ephemeral_port_reserve import reserve
|
|
6
|
+
from mccode_plumber.manage import EventFormationUnit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def command_args(*args):
|
|
10
|
+
largs = [a if isinstance(a, str) else str(a) for a in args]
|
|
11
|
+
if os.name == 'nt':
|
|
12
|
+
return ["python"] + largs
|
|
13
|
+
return largs
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def assert_faker_working(port, message, retries=5, timeout=0.1):
|
|
17
|
+
from subprocess import run
|
|
18
|
+
binary = Path(__file__).resolve().parent / 'fake_manager.py'
|
|
19
|
+
res = 'Not-run yet'
|
|
20
|
+
for _ in range(retries):
|
|
21
|
+
res = run(command_args(binary, port, message), capture_output=True, text=True)
|
|
22
|
+
if res.returncode == 0:
|
|
23
|
+
return res.stdout.strip() == message
|
|
24
|
+
time.sleep(timeout)
|
|
25
|
+
print(f'Failed after {retries} attempts: {res}')
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_all_fake():
|
|
30
|
+
from subprocess import Popen
|
|
31
|
+
port = reserve()
|
|
32
|
+
binary = Path(__file__).resolve().parent / 'fake_efu.py'
|
|
33
|
+
proc = Popen(command_args(binary, '--cmdport', port))
|
|
34
|
+
time.sleep(0.1) # startup
|
|
35
|
+
assert proc.poll() is None # ensure it's still running
|
|
36
|
+
assert assert_faker_working(port, "fake it till you make it")
|
|
37
|
+
proc.terminate()
|
|
38
|
+
proc.wait()
|
|
39
|
+
assert proc.poll() is not None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.skipif(os.name == 'nt', reason="fake_efu.py needs the python")
|
|
43
|
+
def test_efu_management():
|
|
44
|
+
service = EventFormationUnit.start(
|
|
45
|
+
binary=Path(__file__).parent / "fake_efu.py",
|
|
46
|
+
config=Path(__file__),
|
|
47
|
+
calibration=Path(__file__),
|
|
48
|
+
broker='localhost:9092',
|
|
49
|
+
topic='TestEvents',
|
|
50
|
+
samples_topic='TestEvents_samples',
|
|
51
|
+
port=-1,
|
|
52
|
+
command=reserve(),
|
|
53
|
+
name='efu'
|
|
54
|
+
)
|
|
55
|
+
assert service.poll()
|
|
56
|
+
assert assert_faker_working(service.command, "test_management")
|
|
57
|
+
service.stop()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.skipif(os.name == 'nt', reason="fake_efu.py needs the python")
|
|
61
|
+
def test_minimal_efu_management():
|
|
62
|
+
service = EventFormationUnit.start(
|
|
63
|
+
binary=Path(__file__).parent / "fake_efu.py",
|
|
64
|
+
config=Path(__file__),
|
|
65
|
+
calibration=Path(__file__),
|
|
66
|
+
)
|
|
67
|
+
assert service.poll()
|
|
68
|
+
assert assert_faker_working(service.command, "test_management")
|
|
69
|
+
service.stop()
|
|
@@ -16,8 +16,9 @@ class SplitrunTestCase(unittest.TestCase):
|
|
|
16
16
|
|
|
17
17
|
def test_mixed_order_throws(self):
|
|
18
18
|
parser = make_parser()
|
|
19
|
-
parser.prog = "{{This failed before
|
|
20
|
-
|
|
19
|
+
parser.prog = "{{This failed before Python 3.12}}"
|
|
20
|
+
# Pre Python 3.12 was more-strict about argument position mixing
|
|
21
|
+
pa = getattr(parser, "parse_intermixed_args", parser.parse_args)
|
|
21
22
|
# These also output usage information to stdout -- don't be surprised by the 'extra' test output.
|
|
22
23
|
pa(['inst.h5', '--broker', 'l:9092', '--source', 'm', '-n', '10000',
|
|
23
24
|
'a=1:4', 'b=2:5'
|
|
@@ -64,6 +65,11 @@ class SplitrunTestCase(unittest.TestCase):
|
|
|
64
65
|
self.assertEqual(args.nmin, 1)
|
|
65
66
|
self.assertEqual(args.nmax, 2**20)
|
|
66
67
|
|
|
68
|
+
def test_parsing_with_explicit_list(self):
|
|
69
|
+
parser = make_parser()
|
|
70
|
+
args = args_fixup(parser.parse_args(['--broker', 'l:9092', '--source', 'm', '-n', '10000', 'inst.h5', '--', 'a=1:4', 'b=2:5', 'c=1,2,3,4,5']))
|
|
71
|
+
self.assertEqual(args.parameters, ['a=1:4', 'b=2:5', 'c=1,2,3,4,5'])
|
|
72
|
+
|
|
67
73
|
|
|
68
74
|
if __name__ == '__main__':
|
|
69
75
|
unittest.main()
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
from dataclasses import dataclass
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from multiprocessing import Process, Pipe
|
|
5
|
-
from multiprocessing.connection import Connection
|
|
6
|
-
from enum import Enum
|
|
7
|
-
from colorama import Fore, Back, Style
|
|
8
|
-
|
|
9
|
-
class IOType(Enum):
|
|
10
|
-
stdout = 1
|
|
11
|
-
stderr = 2
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclass
|
|
15
|
-
class Manager:
|
|
16
|
-
"""
|
|
17
|
-
Command and control of a process
|
|
18
|
-
|
|
19
|
-
Properties
|
|
20
|
-
----------
|
|
21
|
-
_process: a multiprocessing.Process instance, which is undefined for a short
|
|
22
|
-
period during instance creation inside the `start` class method
|
|
23
|
-
"""
|
|
24
|
-
name: str
|
|
25
|
-
style: Style
|
|
26
|
-
_process: Process | None
|
|
27
|
-
_connection: Connection | None
|
|
28
|
-
|
|
29
|
-
def __run_command__(self) -> list[str]:
|
|
30
|
-
pass
|
|
31
|
-
|
|
32
|
-
def finalize(self):
|
|
33
|
-
pass
|
|
34
|
-
|
|
35
|
-
@classmethod
|
|
36
|
-
def fieldnames(cls) -> list[str]:
|
|
37
|
-
from dataclasses import fields
|
|
38
|
-
return [field.name for field in fields(cls)]
|
|
39
|
-
|
|
40
|
-
@classmethod
|
|
41
|
-
def start(cls, **config):
|
|
42
|
-
names = cls.fieldnames()
|
|
43
|
-
kwargs = {k: config[k] for k in names if k in config}
|
|
44
|
-
if any(k not in names for k in config):
|
|
45
|
-
raise ValueError(f'{config} expected to contain only {names}')
|
|
46
|
-
if '_process' not in kwargs:
|
|
47
|
-
kwargs['_process'] = None
|
|
48
|
-
if '_connection' not in kwargs:
|
|
49
|
-
kwargs['_connection'] = None
|
|
50
|
-
if 'name' not in kwargs:
|
|
51
|
-
kwargs['name'] = 'Managed process'
|
|
52
|
-
if 'style' not in kwargs:
|
|
53
|
-
kwargs['style'] = Fore.WHITE + Back.BLACK
|
|
54
|
-
manager = cls(**kwargs)
|
|
55
|
-
manager._connection, child_conn = Pipe()
|
|
56
|
-
manager._process = Process(target=manager.run, args=(child_conn,))
|
|
57
|
-
manager._process.start()
|
|
58
|
-
return manager
|
|
59
|
-
|
|
60
|
-
def stop(self):
|
|
61
|
-
self.finalize()
|
|
62
|
-
self._process.terminate()
|
|
63
|
-
|
|
64
|
-
def poll(self):
|
|
65
|
-
from sys import stderr
|
|
66
|
-
attn = Fore.BLACK + Back.RED + Style.BRIGHT
|
|
67
|
-
# check for anything received on our end of the connection
|
|
68
|
-
while self._connection.poll():
|
|
69
|
-
# examine what was returned:
|
|
70
|
-
try:
|
|
71
|
-
ret = self._connection.recv()
|
|
72
|
-
except EOFError:
|
|
73
|
-
print(f'{attn}{self.name}: [unexpected halt]{Style.RESET_ALL}')
|
|
74
|
-
return False
|
|
75
|
-
if len(ret) == 2:
|
|
76
|
-
t, line = ret
|
|
77
|
-
line = f'{self.style}{self.name}:{Style.RESET_ALL} {line}'
|
|
78
|
-
if t == IOType.stdout:
|
|
79
|
-
print(line, end='')
|
|
80
|
-
else:
|
|
81
|
-
print(line, file=stderr, end='')
|
|
82
|
-
else:
|
|
83
|
-
print(f'{attn}{self.name}: [unknown received data on connection]{Style.RESET_ALL}')
|
|
84
|
-
return self._process.is_alive()
|
|
85
|
-
|
|
86
|
-
def run(self, conn):
|
|
87
|
-
from subprocess import Popen, PIPE
|
|
88
|
-
from select import select
|
|
89
|
-
argv = self.__run_command__()
|
|
90
|
-
|
|
91
|
-
shell = isinstance(argv, str)
|
|
92
|
-
conn.send((IOType.stdout, f'Starting {argv if shell else " ".join(argv)}\n'))
|
|
93
|
-
process = Popen(argv, shell=shell, stdout=PIPE, stderr=PIPE, bufsize=1, universal_newlines=True, )
|
|
94
|
-
out, err = process.stdout.fileno(), process.stderr.fileno()
|
|
95
|
-
check = [process.stdout, process.stderr]
|
|
96
|
-
while process.poll() is None:
|
|
97
|
-
r, w, x = select(check, [], check, 0.5,)
|
|
98
|
-
for stream in r:
|
|
99
|
-
if stream.fileno() == out:
|
|
100
|
-
conn.send((IOType.stdout, process.stdout.readline()))
|
|
101
|
-
elif stream.fileno() == err:
|
|
102
|
-
conn.send((IOType.stderr, process.stderr.readline()))
|
|
103
|
-
for stream in x:
|
|
104
|
-
if stream.fileno() == out:
|
|
105
|
-
conn.send((IOType.stdout, "EXCEPTION ON STDOUT"))
|
|
106
|
-
elif stream.fileno() == err:
|
|
107
|
-
conn.send((IOType.stderr, "EXCEPTION ON STDERR"))
|
|
108
|
-
# Process finished, but the buffers may still contain data:
|
|
109
|
-
for stream in check:
|
|
110
|
-
if stream.fileno() == out:
|
|
111
|
-
map(lambda line: conn.send(IOType.stdout, line), stream.readlines())
|
|
112
|
-
elif stream.fileno() == err:
|
|
113
|
-
map(lambda line: conn.send(IOType.stderr, line), stream.readlines())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/JobHandler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber/file_writer_control/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{mccode_plumber-0.14.4 → mccode_plumber-0.15.0}/src/mccode_plumber.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|