mccode-plumber 0.14.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.
Files changed (47) hide show
  1. mccode_plumber-0.14.0/.github/workflows/pip.yml +38 -0
  2. mccode_plumber-0.14.0/.github/workflows/wheels.yml +52 -0
  3. mccode_plumber-0.14.0/.gitignore +5 -0
  4. mccode_plumber-0.14.0/PKG-INFO +17 -0
  5. mccode_plumber-0.14.0/README.md +2 -0
  6. mccode_plumber-0.14.0/pyproject.toml +46 -0
  7. mccode_plumber-0.14.0/setup.cfg +4 -0
  8. mccode_plumber-0.14.0/src/mccode_plumber/__init__.py +6 -0
  9. mccode_plumber-0.14.0/src/mccode_plumber/conductor.py +0 -0
  10. mccode_plumber-0.14.0/src/mccode_plumber/epics.py +186 -0
  11. mccode_plumber-0.14.0/src/mccode_plumber/epics_watcher.py +125 -0
  12. mccode_plumber-0.14.0/src/mccode_plumber/file_writer_control/CommandChannel.py +236 -0
  13. mccode_plumber-0.14.0/src/mccode_plumber/file_writer_control/CommandHandler.py +58 -0
  14. mccode_plumber-0.14.0/src/mccode_plumber/file_writer_control/CommandStatus.py +151 -0
  15. mccode_plumber-0.14.0/src/mccode_plumber/file_writer_control/InThreadStatusTracker.py +228 -0
  16. mccode_plumber-0.14.0/src/mccode_plumber/file_writer_control/JobHandler.py +102 -0
  17. mccode_plumber-0.14.0/src/mccode_plumber/file_writer_control/JobStatus.py +147 -0
  18. mccode_plumber-0.14.0/src/mccode_plumber/file_writer_control/KafkaTopicUrl.py +22 -0
  19. mccode_plumber-0.14.0/src/mccode_plumber/file_writer_control/StateExtractor.py +58 -0
  20. mccode_plumber-0.14.0/src/mccode_plumber/file_writer_control/WorkerFinder.py +139 -0
  21. mccode_plumber-0.14.0/src/mccode_plumber/file_writer_control/WorkerJobPool.py +70 -0
  22. mccode_plumber-0.14.0/src/mccode_plumber/file_writer_control/WorkerStatus.py +88 -0
  23. mccode_plumber-0.14.0/src/mccode_plumber/file_writer_control/WriteJob.py +83 -0
  24. mccode_plumber-0.14.0/src/mccode_plumber/file_writer_control/__init__.py +13 -0
  25. mccode_plumber-0.14.0/src/mccode_plumber/forwarder.py +95 -0
  26. mccode_plumber-0.14.0/src/mccode_plumber/kafka.py +68 -0
  27. mccode_plumber-0.14.0/src/mccode_plumber/manage/__init__.py +26 -0
  28. mccode_plumber-0.14.0/src/mccode_plumber/manage/efu.py +142 -0
  29. mccode_plumber-0.14.0/src/mccode_plumber/manage/ensure.py +73 -0
  30. mccode_plumber-0.14.0/src/mccode_plumber/manage/epics.py +33 -0
  31. mccode_plumber-0.14.0/src/mccode_plumber/manage/forwarder.py +79 -0
  32. mccode_plumber-0.14.0/src/mccode_plumber/manage/manager.py +113 -0
  33. mccode_plumber-0.14.0/src/mccode_plumber/manage/orchestrate.py +435 -0
  34. mccode_plumber-0.14.0/src/mccode_plumber/manage/writer.py +60 -0
  35. mccode_plumber-0.14.0/src/mccode_plumber/mccode.py +59 -0
  36. mccode_plumber-0.14.0/src/mccode_plumber/splitrun.py +32 -0
  37. mccode_plumber-0.14.0/src/mccode_plumber/utils.py +68 -0
  38. mccode_plumber-0.14.0/src/mccode_plumber/writer.py +498 -0
  39. mccode_plumber-0.14.0/src/mccode_plumber.egg-info/PKG-INFO +17 -0
  40. mccode_plumber-0.14.0/src/mccode_plumber.egg-info/SOURCES.txt +45 -0
  41. mccode_plumber-0.14.0/src/mccode_plumber.egg-info/dependency_links.txt +1 -0
  42. mccode_plumber-0.14.0/src/mccode_plumber.egg-info/entry_points.txt +18 -0
  43. mccode_plumber-0.14.0/src/mccode_plumber.egg-info/requires.txt +8 -0
  44. mccode_plumber-0.14.0/src/mccode_plumber.egg-info/top_level.txt +1 -0
  45. mccode_plumber-0.14.0/tests/test_epics.py +50 -0
  46. mccode_plumber-0.14.0/tests/test_splitrun.py +69 -0
  47. mccode_plumber-0.14.0/tests/test_writer.py +71 -0
@@ -0,0 +1,38 @@
1
+ name: Pip
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ # pull_request:
6
+ # push:
7
+ # branches:
8
+ # - main
9
+
10
+ jobs:
11
+ build:
12
+ name: Build with Pip
13
+ runs-on: ${{ matrix.platform }}
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ platform: [windows-latest, macos-latest, ubuntu-latest]
18
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
19
+
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - uses: actions/setup-python@v4
24
+ with:
25
+ python-version: ${{ matrix.python-version }}
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
+ - name: Build and install
33
+ run: pip install --verbose .
34
+
35
+ - name: Test
36
+ run: |
37
+ python -m pip install pytest
38
+ python -m pytest
@@ -0,0 +1,52 @@
1
+ name: Wheels
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ pull_request:
6
+ push:
7
+ branches:
8
+ - main
9
+ release:
10
+ types:
11
+ - published
12
+
13
+ jobs:
14
+ build_sdist_and_wheel:
15
+ name: Build SDist and Wheel
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ with:
20
+ fetch-depth: 0
21
+ submodules: true
22
+
23
+ - name: Build SDist
24
+ run: pipx run build
25
+
26
+ - name: Check metadata
27
+ run: pipx run twine check dist/*
28
+
29
+ - uses: actions/upload-artifact@v4
30
+ with:
31
+ path: dist/*
32
+
33
+ upload_all:
34
+ name: Upload if release
35
+ needs: [build_sdist_and_wheel]
36
+ runs-on: ubuntu-latest
37
+ environment:
38
+ name: pypi
39
+ url: https://pypi.org/p/mccode-plumber
40
+ permissions:
41
+ id-token: write
42
+ if: github.event_name == 'release' && github.event.action == 'published'
43
+
44
+ steps:
45
+ - uses: actions/setup-python@v5
46
+
47
+ - uses: actions/download-artifact@v4
48
+ with:
49
+ name: artifact
50
+ path: dist
51
+
52
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,5 @@
1
+ build/
2
+ venv/
3
+ __pycache__/
4
+ .idea/
5
+ *.egg-info
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: mccode-plumber
3
+ Version: 0.14.0
4
+ Author-email: Gregory Tucker <gregory.tucker@ess.eu>
5
+ Classifier: License :: OSI Approved :: BSD License
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: p4p
8
+ Requires-Dist: kafka-python>=2.2.11
9
+ Requires-Dist: ess-streaming-data-types>=0.14.0
10
+ Requires-Dist: restage>=0.9.0
11
+ Requires-Dist: mccode-to-kafka>=0.2.2
12
+ Requires-Dist: moreniius>=0.6.0
13
+ Requires-Dist: icecream
14
+ Requires-Dist: ephemeral-port-reserve
15
+
16
+ # McCode Plumber
17
+ Setup, run, and teardown the infrastructure for splitrun McCode scans sending data through Kafka into NeXus
@@ -0,0 +1,2 @@
1
+ # McCode Plumber
2
+ Setup, run, and teardown the infrastructure for splitrun McCode scans sending data through Kafka into NeXus
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ['setuptools>=60', 'setuptools_scm>=8.0']
3
+ build-backend = 'setuptools.build_meta'
4
+
5
+ [project]
6
+ name = 'mccode-plumber'
7
+ dependencies = [
8
+ 'p4p',
9
+ 'kafka-python>=2.2.11',
10
+ 'ess-streaming-data-types>=0.14.0',
11
+ 'restage>=0.9.0',
12
+ 'mccode-to-kafka>=0.2.2',
13
+ 'moreniius>=0.6.0',
14
+ 'icecream',
15
+ 'ephemeral-port-reserve',
16
+ ]
17
+ readme = "README.md"
18
+ authors = [
19
+ { name = "Gregory Tucker", email = "gregory.tucker@ess.eu" },
20
+ ]
21
+ classifiers = [
22
+ "License :: OSI Approved :: BSD License",
23
+ ]
24
+ dynamic = ['version']
25
+
26
+ [project.scripts]
27
+ mp-splitrun = 'mccode_plumber.splitrun:main'
28
+ mp-epics = 'mccode_plumber.epics:run'
29
+ mp-epics-strings = 'mccode_plumber.epics:run_strings'
30
+ mp-epics-update = 'mccode_plumber.epics:update'
31
+ mp-epics-watch = 'mccode_plumber.epics_watcher:run_instr'
32
+ mp-forwarder-setup = 'mccode_plumber.forwarder:setup'
33
+ mp-forwarder-teardown = 'mccode_plumber.forwarder:teardown'
34
+ mp-writer-from = 'mccode_plumber.writer:print_time'
35
+ mp-writer-write = 'mccode_plumber.writer:start_writer'
36
+ mp-writer-wait = 'mccode_plumber.writer:wait_on_writer'
37
+ mp-writer-list = 'mccode_plumber.writer:list_status'
38
+ mp-writer-kill = 'mccode_plumber.writer:kill_job'
39
+ mp-writer-killall = 'mccode_plumber.writer:kill_all'
40
+ mp-register-topics = 'mccode_plumber.kafka:register_topics'
41
+ mp-insert-hdf5-instr = 'mccode_plumber.mccode:insert'
42
+ mp-nexus-splitrun = 'mccode_plumber.manage.orchestrate:main'
43
+ mp-nexus-services = 'mccode_plumber.manage.orchestrate:services'
44
+
45
+ [tool.setuptools_scm]
46
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version('mccode-plumber')
5
+ except PackageNotFoundError:
6
+ pass
File without changes
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env python3
2
+ from p4p.nt import NTScalar
3
+ from p4p.server import Server, StaticProvider
4
+ from p4p.server.thread import SharedPV
5
+ from pathlib import Path
6
+ from typing import Union
7
+
8
+ def instr_par_to_nt_primitive(parameters):
9
+ from mccode_antlr.common.expression import DataType, ShapeType
10
+ out = []
11
+ for p in parameters:
12
+ expr = p.value
13
+ if expr.is_str:
14
+ t, d = 's', ''
15
+ elif expr.data_type == DataType.int:
16
+ t, d = 'i', 0
17
+ elif expr.data_type == DataType.float:
18
+ t, d = 'd', 0.0
19
+ else:
20
+ raise ValueError(f"Unknown parameter type {expr.data_type}")
21
+ if expr.shape_type == ShapeType.vector:
22
+ t, d = 'a' + t, [d]
23
+ out.append((p.name, t, d))
24
+ return out
25
+
26
+ def instr_par_nt_to_strings(parameters):
27
+ return [f'{n}:{t}:{d}'.replace(' ','') for n, t, d in instr_par_to_nt_primitive(parameters)]
28
+
29
+ def strings_to_instr_par_nt(strings):
30
+ out = []
31
+ for string in strings:
32
+ name, t, dstr = string.split(':')
33
+ trans = None
34
+ if 'i' in t:
35
+ trans = int
36
+ elif 'd' in t:
37
+ trans = float
38
+ elif 's' in t:
39
+ trans = str
40
+ else:
41
+ ValueError(f"Unknown type in {string}")
42
+ if t.startswith('a'):
43
+ d = [trans(x) for x in dstr.translate(str.maketrans(',',' ','[]')).split()]
44
+ else:
45
+ d = trans(dstr)
46
+ out.append((name, t, d))
47
+ return out
48
+
49
+ def convert_strings_to_nt(strings):
50
+ return {n: NTScalar(t).wrap(d) for n, t, d in strings_to_instr_par_nt(strings)}
51
+
52
+ def convert_instr_parameters_to_nt(parameters):
53
+ out = {n: NTScalar(t).wrap(d) for n, t, d in instr_par_to_nt_primitive(parameters)}
54
+ return out
55
+
56
+
57
+ def parse_instr_nt_values(instr: Union[Path, str]):
58
+ """Get the instrument parameters from an Instr a or a parseable Instr file and convert to NTScalar values"""
59
+ from .mccode import get_mccode_instr_parameters
60
+ nts = convert_instr_parameters_to_nt(get_mccode_instr_parameters(instr))
61
+ if 'mcpl_filename' not in nts:
62
+ nts['mcpl_filename'] = NTScalar('s').wrap('')
63
+ return nts
64
+
65
+
66
+ class MailboxHandler:
67
+ @staticmethod
68
+ def put(pv, op):
69
+ from datetime import datetime, timezone
70
+ val = op.value()
71
+
72
+ if pv.nt is None:
73
+ # Assume that this means wrap wasn't provided ...
74
+ pv.nt = NTScalar(val.type()['value'])
75
+ pv._wrap = pv.nt.wrap
76
+
77
+ # Notify any subscribers of the new value, adding the timestamp, so they know when it was set.
78
+ pv.post(val, timestamp=datetime.now(timezone.utc).timestamp())
79
+ # Notify the client making this PUT operation that it has now completed
80
+ op.done()
81
+
82
+
83
+ def get_parser():
84
+ from argparse import ArgumentParser
85
+ from mccode_plumber import __version__
86
+ p = ArgumentParser()
87
+ p.add_argument('instr', type=str, help='The instrument file to read')
88
+ p.add_argument('-p', '--prefix', type=str, help='The EPICS PV prefix to use', default='mcstas:')
89
+ p.add_argument('-v', '--version', action='version', version=__version__)
90
+ return p
91
+
92
+
93
+ def parse_args():
94
+ args = get_parser().parse_args()
95
+ parameters = parse_instr_nt_values(args.instr)
96
+ return parameters, args
97
+
98
+
99
+ def main(names: dict[str, NTScalar], prefix: str = None, filename_required: bool = True):
100
+ provider = StaticProvider('mailbox') # 'mailbox' is an arbitrary name
101
+
102
+ if filename_required and 'mcpl_filename' not in names:
103
+ names['mcpl_filename'] = NTScalar('s').wrap('')
104
+
105
+ pvs = [] # we must keep a reference in order to keep the Handler from being collected
106
+ for name, value in names.items():
107
+ pv = SharedPV(initial=value, handler=MailboxHandler())
108
+ provider.add(f'{prefix}{name}' if prefix else name, pv)
109
+ pvs.append(pv)
110
+
111
+ print(f'Start mailbox server for {len(pvs)} PVs with prefix {prefix}')
112
+ Server.forever(providers=[provider])
113
+ print('Done')
114
+
115
+
116
+ def run():
117
+ parameters, args = parse_args()
118
+ main(parameters, prefix=args.prefix)
119
+
120
+
121
+ def start(parameters, prefix: str = None):
122
+ from multiprocessing import Process
123
+ proc = Process(target=main, args=(parameters, prefix))
124
+ proc.start()
125
+ return proc
126
+
127
+
128
+ def stop(proc):
129
+ proc.terminate()
130
+ proc.join(1)
131
+ proc.close()
132
+
133
+
134
+ def update():
135
+ from argparse import ArgumentParser
136
+ from p4p.client.thread import Context
137
+ parser = ArgumentParser(description="Update the mailbox server with new values")
138
+ parser.add_argument('address value', type=str, nargs='+', help='The mailbox address and value to be updated')
139
+ args = parser.parse_args()
140
+ addresses_values = getattr(args, 'address value')
141
+
142
+ if len(addresses_values) == 0:
143
+ parser.print_help()
144
+ return
145
+
146
+ addresses = addresses_values[::2]
147
+ values = addresses_values[1::2]
148
+
149
+ if len(addresses_values) % 2:
150
+ print(f'Please provide address-value pairs. Provided {addresses=} {values=}')
151
+
152
+ ctx = Context('pva')
153
+ for address, value in zip(addresses, values):
154
+ pv = ctx.get(address, throw=False)
155
+ if isinstance(pv, float):
156
+ ctx.put(address, float(value))
157
+ elif isinstance(pv, int):
158
+ ctx.put(address, int(value))
159
+ elif isinstance(pv, str):
160
+ ctx.put(address, str(value))
161
+ elif isinstance(pv, TimeoutError):
162
+ print(f'[Timeout] Failed to update {address} with {value} (Unknown to EPICS?)')
163
+ else:
164
+ raise ValueError(f'Address {address} has unknown type {type(pv)}')
165
+
166
+ ctx.disconnect()
167
+
168
+
169
+ def get_strings_parser():
170
+ from argparse import ArgumentParser
171
+ from mccode_plumber import __version__
172
+ p = ArgumentParser()
173
+ p.add_argument('strings', type=str, nargs='+', help='The string encoded NTScalars to read, each name:type-char:default')
174
+ p.add_argument('-p', '--prefix', type=str, help='The EPICS PV prefix to use', default='mcstas:')
175
+ p.add_argument('-v', '--version', action='version', version=__version__)
176
+ return p
177
+
178
+
179
+ def run_strings():
180
+ args = get_strings_parser().parse_args()
181
+ main(convert_strings_to_nt(args.strings), prefix=args.prefix)
182
+
183
+
184
+
185
+ if __name__ == '__main__':
186
+ run()
@@ -0,0 +1,125 @@
1
+ from textual.app import App, ComposeResult
2
+ from textual.containers import VerticalScroll
3
+ from textual.widgets import Static
4
+ from textual.reactive import reactive
5
+ from textual import events
6
+
7
+ from p4p.client.thread import Context
8
+ from p4p.client.thread import Disconnected
9
+
10
+ import asyncio
11
+ import threading
12
+
13
+
14
+ class PVWidget(Static):
15
+ value: reactive[str] = reactive("Connecting...")
16
+
17
+ def __init__(self, pvname: str):
18
+ super().__init__()
19
+ self.pvname = pvname
20
+ self.sid = pvname.replace(':', '')
21
+ self.set_class(True, "pv-widget")
22
+ self.value_widget = None # Store reference directly
23
+
24
+ def compose(self) -> ComposeResult:
25
+ yield Static(f"[b]{self.pvname}[/b]", id=f"label-{self.sid}")
26
+ self.value_widget = Static(self.value, id=f"value-{self.sid}")
27
+ yield self.value_widget
28
+
29
+ def watch_value(self, value: str):
30
+ if self.value_widget:
31
+ self.value_widget.update(value)
32
+
33
+ def update_value(self, new_value: str):
34
+ self.value = new_value
35
+
36
+ class PVMonitorApp(App):
37
+ CSS = """
38
+ .pv-widget {
39
+ padding: 1 1;
40
+ border: round $primary;
41
+ margin: 1;
42
+ }
43
+ """
44
+
45
+ def __init__(self, prefix: str, names: list[str]):
46
+ super().__init__()
47
+ self.pv_widgets = {}
48
+ self.ctx = Context("pva") # Or 'ca' if using Channel Access
49
+ self.prefix = prefix
50
+ self.names = names
51
+ self.grid = None
52
+
53
+ def compose(self) -> ComposeResult:
54
+ from textual.containers import Grid
55
+ self.grid = Grid(id="pv-grid")
56
+ yield self.grid
57
+
58
+ def on_mount(self) -> None:
59
+ self.grid.styles.grid_columns = ["1fr", "1fr", "1fr"] # 3 columns
60
+ self.grid.styles.grid_gap = (1, 1)
61
+ for name in self.names:
62
+ pv = f'{self.prefix}{name}'
63
+ widget = PVWidget(pv)
64
+ self.pv_widgets[pv] = widget
65
+ self.grid.mount(widget)
66
+ threading.Thread(target=self.monitor_pv, args=(pv,), daemon=True).start()
67
+
68
+ def monitor_pv(self, pvname: str):
69
+ def callback(value):
70
+ if isinstance(value, Disconnected):
71
+ new_value = "Disconnected"
72
+ else:
73
+ new_value = str(value)
74
+ # Schedule update on the main thread
75
+ asyncio.run_coroutine_threadsafe(
76
+ self.update_widget_value(pvname, new_value),
77
+ self._loop,
78
+ )
79
+
80
+ self.ctx.monitor(pvname, callback)
81
+
82
+ async def update_widget_value(self, pvname: str, new_value: str):
83
+ widget = self.pv_widgets.get(pvname)
84
+ if widget:
85
+ widget.update_value(new_value)
86
+
87
+ async def on_shutdown(self) -> None:
88
+ self.ctx.close()
89
+
90
+
91
+
92
+ def get_names_parser():
93
+ from argparse import ArgumentParser
94
+ from mccode_plumber import __version__
95
+ p = ArgumentParser()
96
+ p.add_argument('name', type=str, nargs='+', help='The NTScalar names to watch')
97
+ p.add_argument('-p', '--prefix', type=str, help='The EPICS PV prefix to use', default='mcstas:')
98
+ p.add_argument('-v', '--version', action='version', version=__version__)
99
+ return p
100
+
101
+
102
+ def run_strings():
103
+ args = get_names_parser().parse_args()
104
+ PVMonitorApp(args.prefix, args.name).run()
105
+
106
+
107
+ def get_instr_parser():
108
+ from argparse import ArgumentParser
109
+ from mccode_plumber import __version__
110
+ p = ArgumentParser()
111
+ p.add_argument('instr', type=str, help='The instrument which defines names to watch')
112
+ p.add_argument('-p', '--prefix', type=str, help='The EPICS PV prefix to use', default='mcstas:')
113
+ p.add_argument('-v', '--version', action='version', version=__version__)
114
+ return p
115
+
116
+ def run_instr():
117
+ from mccode_plumber.manage.orchestrate import get_instr_name_and_parameters
118
+ args = get_instr_parser().parse_args()
119
+ _, parameters = get_instr_name_and_parameters(args.instr)
120
+ names = [p.name for p in parameters]
121
+ PVMonitorApp(args.prefix, names).run()
122
+
123
+
124
+ if __name__ == '__main__':
125
+ run_instr()