pytrms 0.9.7__py3-none-any.whl → 0.9.8__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.
pytrms/__init__.py CHANGED
@@ -1,8 +1,52 @@
1
- _version = '0.9.7'
1
+ _version = '0.9.8'
2
+
3
+ import logging
4
+ from functools import wraps
5
+
6
+ _logging_getLogger = logging.getLogger
7
+
8
+ @wraps(_logging_getLogger)
9
+ def getLoggerWithAnnouncement(name=None):
10
+ # patch the (global) logger to print its own name
11
+ # (useful for turning individual loggers on/off)
12
+ # WARNING: this will patch every instance of the
13
+ # logging-module in every import after pytrms is
14
+ # imported! don't be overwhelmingly fancy with this!
15
+ rv = _logging_getLogger(name)
16
+ if name is not None:
17
+ rv.debug(f"'acquired logger for '{name}'")
18
+
19
+ return rv
20
+
21
+ logging.getLogger = getLoggerWithAnnouncement
22
+ logging.TRACE = 5 # even more verbose than logging.DEBUG
2
23
 
3
24
  __all__ = ['load', 'connect']
4
25
 
5
26
 
27
+ def enable_extended_logging(log_level=logging.DEBUG):
28
+ '''make output of http-requests more talkative.
29
+
30
+ set 'log_level=logging.TRACE' for highest verbosity!
31
+ '''
32
+ if log_level <= logging.DEBUG:
33
+ # enable logging of http request urls on the library, that is
34
+ # underlying the 'requests'-package:
35
+ logging.warning(f"enabling logging-output on 'urllib3' ({log_level = })")
36
+ requests_log = logging.getLogger("urllib3")
37
+ requests_log.setLevel(log_level)
38
+ requests_log.propagate = True
39
+
40
+ if log_level <= logging.TRACE:
41
+ # Enabling debugging at http.client level (requests->urllib3->http.client)
42
+ # you will see the REQUEST, including HEADERS and DATA, and RESPONSE with
43
+ # HEADERS but without DATA. the only thing missing will be the response.body,
44
+ # which is not logged.
45
+ logging.warning(f"enabling logging-output on 'HTTPConnection' ({log_level = })")
46
+ from http.client import HTTPConnection
47
+ HTTPConnection.debuglevel = 1
48
+
49
+
6
50
  def load(path):
7
51
  '''Open a datafile for post-analysis or batch processing.
8
52
 
@@ -1,33 +1,9 @@
1
1
  import os
2
2
 
3
+ from .. import enable_extended_logging
4
+
3
5
  _root = os.path.dirname(__file__)
4
6
  _par_id_file = os.path.abspath(os.path.join(_root, '..', 'data', 'ParaIDs.csv'))
5
7
  assert os.path.exists(_par_id_file), "par-id file not found: please re-install PyTRMS package"
6
8
 
7
9
 
8
- import logging as _logging
9
-
10
- _logging.TRACE = 5 # even more verbose than logging.DEBUG
11
-
12
- def enable_extended_logging(log_level=_logging.DEBUG):
13
- '''make output of http-requests more talkative.
14
-
15
- set 'log_level=_logging.TRACE' for highest verbosity!
16
- '''
17
- if log_level <= _logging.DEBUG:
18
- # enable logging of http request urls on the library, that is
19
- # underlying the 'requests'-package:
20
- _logging.warning(f"enabling logging-output on 'urllib3' ({log_level = })")
21
- requests_log = _logging.getLogger("urllib3")
22
- requests_log.setLevel(log_level)
23
- requests_log.propagate = True
24
-
25
- if log_level <= _logging.TRACE:
26
- # Enabling debugging at http.client level (requests->urllib3->http.client)
27
- # you will see the REQUEST, including HEADERS and DATA, and RESPONSE with
28
- # HEADERS but without DATA. the only thing missing will be the response.body,
29
- # which is not logged.
30
- _logging.warning(f"enabling logging-output on 'HTTPConnection' ({log_level = })")
31
- from http.client import HTTPConnection
32
- HTTPConnection.debuglevel = 1
33
-
pytrms/clients/db_api.py CHANGED
@@ -1,21 +1,53 @@
1
1
  import os
2
+ import time
2
3
  import json
4
+ import logging
5
+ from collections import namedtuple
6
+ import urllib3.util
3
7
 
4
8
  import requests
9
+ import requests.adapters
10
+ import requests.exceptions
5
11
 
6
- from . import _logging
7
12
  from .ssevent import SSEventListener
8
13
  from .._base import _IoniClientBase
9
14
 
10
- log = _logging.getLogger(__name__)
15
+ log = logging.getLogger(__name__)
16
+
17
+ _unsafe = namedtuple('http_response', ['status_code', 'href'])
18
+
19
+ __all__ = ['IoniConnect']
11
20
 
12
21
 
13
22
  class IoniConnect(_IoniClientBase):
14
23
 
24
+ # Note: this retry-policy is specifically designed for the
25
+ # SQLite Error 5: 'database locked', which may take potentially
26
+ # minutes to resolve itself! Therefore, it is extra generous
27
+ # and backs off up to `3.0 * 2^4 = 48 sec` between retries for
28
+ # a total of ~1 1/2 minutes (plus database timeout). But, giving
29
+ # up on retrying here, would mean *losing all data* in the queue!
30
+ # ==>> We would rather crash on a `queue.full` exception! <<==
31
+ _retry_policy = urllib3.util.Retry(
32
+ # this configures policies on each cause for errors individually...
33
+ total=None, # max. retries (takes precedence). `None`: turned off
34
+ connect=0, read=0, redirect=0, # (all turned off, see docs for details)
35
+ other=0, # "other" errors include timeout (set to 27 seconds)
36
+ # configure the retries on specific status-codes...
37
+ status=5, # how many times to retry on bad status codes
38
+ raise_on_status=True, # `True`: do not return a 429 status code
39
+ status_forcelist=[429], # integer status-codes to retry on
40
+ allowed_methods=None, # `None`: retry on all (possibly not idempotent) verbs
41
+ # this configures backoff between retries...
42
+ backoff_factor=3.0, # back off *after* first try in seconds (x 2^n_retries)
43
+ respect_retry_after_header=False, # would override `backoff_factor`, turn off!
44
+ )
45
+
15
46
  @property
16
47
  def is_connected(self):
17
48
  '''Returns `True` if connection to IoniTOF could be established.'''
18
49
  try:
50
+ assert self.session is not None, "not connected"
19
51
  self.get("/api/status")
20
52
  return True
21
53
  except:
@@ -24,88 +56,223 @@ class IoniConnect(_IoniClientBase):
24
56
  @property
25
57
  def is_running(self):
26
58
  '''Returns `True` if IoniTOF is currently acquiring data.'''
27
- # TODO :: /api/meas/curretn {isRunning ?}
28
- raise NotImplementedError("is_running")
59
+ try:
60
+ assert self.session is not None, "not connected"
61
+ self.get_location("/api/measurements/current")
62
+ return True
63
+ except (AssertionError, requests.exceptions.HTTPError):
64
+ return False
29
65
 
30
- def connect(self, timeout_s):
31
- # TODO :: create session ?! (see __init__ ...)
32
- pass
66
+ def connect(self, timeout_s=10):
67
+ self.session = requests.sessions.Session()
68
+ self.session.mount('http://', self._http_adapter)
69
+ self.session.mount('https://', self._http_adapter)
70
+ started_at = time.monotonic()
71
+ while timeout_s is None or time.monotonic() < started_at + timeout_s:
72
+ try:
73
+ self.current_meas_loc = self.get_location("/api/measurements/current")
74
+ break
75
+ except requests.exceptions.HTTPError:
76
+ # OK, no measurement running..
77
+ self.current_meas_loc = ''
78
+ break
79
+ except Exception:
80
+ pass
81
+
82
+ time.sleep(10e-1)
83
+ else:
84
+ self.session = self.current_meas_loc = None
85
+ raise TimeoutError(f"no connection to '{self.url}'");
33
86
 
34
87
  def disconnect(self):
35
- # TODO :: del session ?!
36
- pass
88
+ if self.session is not None:
89
+ del self.session
90
+ self.session = None
91
+ self.current_meas_loc = None
37
92
 
38
93
  def start_measurement(self, path=None):
39
94
  '''Start a new measurement and block until the change is confirmed.
40
95
 
41
96
  If 'path' is not None, write to the given .h5 file.
42
97
  '''
43
- # TODO :: POST /api/measurement {recipeDirectory} / path = ?
44
- pass
98
+ assert not self.is_running, "measurement already running @ " + str(self.current_meas_loc)
99
+
100
+ payload = {}
101
+ if path is not None:
102
+ assert os.path.isdir(path), "must point to a (recipe-)directory: " + str(path)
103
+ payload |= { "recipeDirectory": str(path) }
104
+
105
+ self.current_meas_loc = self.post("/api/measurements", payload)
106
+ self.put(self.current_meas_loc, { "isRunning": True })
107
+
108
+ return self.current_meas_loc
45
109
 
46
110
  def stop_measurement(self, future_cycle=None):
47
111
  '''Stop the current measurement and block until the change is confirmed.
48
112
 
49
113
  If 'future_cycle' is not None and in the future, schedule the stop command.
50
114
  '''
51
- # TODO :: PUT /api/meas/current {isRunning = False}
52
- pass
115
+ loc = self.current_meas_loc or self.get_location("/api/measurements/current")
116
+ self.patch(loc, { "isRunning": False })
117
+ self.current_meas_loc = ''
53
118
 
54
- def __init__(self, host='127.0.0.1', port=5066, session=None):
119
+ def __init__(self, host='127.0.0.1', port=5066):
55
120
  super().__init__(host, port)
56
121
  self.url = f"http://{self.host}:{self.port}"
57
- if session is None:
58
- session = requests.sessions.Session()
59
- self.session = session
60
- # ??
61
- self.current_avg_endpoint = None
62
- self.comp_dict = dict()
122
+ self._http_adapter = requests.adapters.HTTPAdapter(max_retries=self._retry_policy)
123
+ self.session = None
124
+ self.current_meas_loc = None
125
+ try:
126
+ self.connect(timeout_s=3.3)
127
+ except TimeoutError:
128
+ log.warning("no connection! make sure the DB-API is running and try again")
63
129
 
64
130
  def get(self, endpoint, **kwargs):
65
- return self._get_object(endpoint, **kwargs).json()
131
+ """Make a GET request to `endpoint` and parse JSON if applicable."""
132
+ try:
133
+ r = self._fetch_object(endpoint, 'get', **kwargs)
134
+ if 'json' in r.headers.get('content-type', ''):
135
+ return r.json()
136
+ if 'text' in r.headers.get('content-type', ''):
137
+ return r.text
138
+ else:
139
+ log.warning(f"unexpected 'content-type: {r.headers['content-type']}'")
140
+ log.info(f"did you mean to use `{type(self).__name__}.download(..)` instead?")
141
+ return r.content
142
+
143
+ except requests.exceptions.HTTPError as e:
144
+ if e.response.status_code == 410: # Gone
145
+ log.debug(f"nothing there at '{endpoint}' 0_o ?!")
146
+ return None
147
+ raise
148
+
149
+ def get_location(self, endpoint, **kwargs):
150
+ """Returns the actual location that `endpoint` points to (may be a redirect)."""
151
+ r = self._fetch_object(endpoint, 'get', **(kwargs | { "allow_redirects": False }))
152
+ return r.headers.get('Location', r.request.path_url)
66
153
 
67
154
  def post(self, endpoint, data, **kwargs):
68
- return self._create_object(endpoint, data, 'post', **kwargs).headers.get('Location')
155
+ """Append to the collection at `endpoint` the object defined by `data`."""
156
+ r = self._create_object(endpoint, data, 'post', **kwargs)
157
+ return _unsafe(r.status_code, r.headers.get('Location', '')) # no default location known!
69
158
 
70
159
  def put(self, endpoint, data, **kwargs):
71
- return self._create_object(endpoint, data, 'put', **kwargs).headers.get('Location')
160
+ """Replace the entire object at `endpoint` with `data`."""
161
+ r = self._create_object(endpoint, data, 'put', **kwargs)
162
+ return _unsafe(r.status_code, r.headers.get('Location', r.request.path_url))
163
+
164
+ def patch(self, endpoint, data, **kwargs):
165
+ """Change parts of the object at `endpoint` with fields in `data`."""
166
+ r = self._create_object(endpoint, data, 'patch', **kwargs)
167
+ return _unsafe(r.status_code, r.headers.get('Location', r.request.path_url))
168
+
169
+ def delete(self, endpoint, **kwargs):
170
+ """Attempt to delete the object at `endpoint`."""
171
+ r = self._fetch_object(endpoint, data, 'delete', **kwargs)
172
+ return _unsafe(r.status_code, r.headers.get('Location', r.request.path_url))
173
+
174
+ def link(self, parent_ep, child_ep, **kwargs):
175
+ """Make the object at `parent_e[nd]p[oint]` refer to `child_e[nd]p[oint]`"""
176
+ r = self._make_link(parent_ep, child_ep, sever=False, **kwargs)
177
+ return _unsafe(r.status_code, r.headers.get('Location', r.request.path_url))
178
+
179
+ def unlink(self, parent_ep, child_ep, **kwargs):
180
+ """Destroy the reference from `parent_e[nd]p[oint]` to `child_e[nd]p[oint]`"""
181
+ r = self._make_link(parent_ep, child_ep, sever=True, **kwargs)
182
+ return _unsafe(r.status_code, r.headers.get('Location', r.request.path_url))
72
183
 
73
184
  def upload(self, endpoint, filename):
185
+ """Upload the file at `filename` to `endpoint`."""
74
186
  if not endpoint.startswith('/'):
75
187
  endpoint = '/' + endpoint
76
- with open(filename) as f:
188
+ with open(filename, 'rb') as f:
77
189
  # Note (important!): this is a "form-data" entry, where the server
78
190
  # expects the "name" to be 'file' and rejects it otherwise:
79
191
  name = 'file'
80
- r = self.session.post(self.url + endpoint, files=[(name, (filename, f, ''))])
192
+ r = self._create_object(endpoint, None, 'post',
193
+ # Note: the requests library will set the content-type automatically
194
+ # and also add a randomly generated "boundary" to separate files:
195
+ #headers={'content-type': 'multipart/form-data'}, No!
196
+ files=[(name, (filename, f, ''))])
81
197
  r.raise_for_status()
82
198
 
83
- return r
199
+ return _unsafe(r.status_code, r.headers.get('Location', r.request.path_url))
84
200
 
85
- def _get_object(self, endpoint, **kwargs):
201
+ def download(self, endpoint, out_file='.'):
202
+ """Download from `endpoint` into `out_file` (may be a directory).
203
+
204
+ Returns:
205
+ status_code, actual_filename
206
+ """
207
+ if not endpoint.startswith('/'):
208
+ endpoint = '/' + endpoint
209
+
210
+ out_file = os.path.abspath(out_file)
211
+
212
+ content_type = 'application/octet-stream'
213
+ r = self._fetch_object(endpoint, 'get', stream=True, headers={'accept': content_type})
214
+ assert r.headers['content-type'] == content_type, "unexcepted content-type"
215
+
216
+ content_dispo = r.headers['content-disposition'].split('; ')
217
+ #['attachment',
218
+ # 'filename=2025_10_06__13_23_32.h5',
219
+ # "filename*=UTF-8''2025_10_06__13_23_32.h5"]
220
+ filename = next(
221
+ (dispo.split('=')[1] for dispo in content_dispo if dispo.startswith("filename="))
222
+ , None)
223
+ if os.path.isdir(out_file):
224
+ assert filename, "no out_file given and server didn't supply filename"
225
+ out_file = os.path.join(out_file, filename)
226
+
227
+ with open(out_file, mode='xb') as f:
228
+ # chunk_size must be of type int or None. A value of None will
229
+ # function differently depending on the value of `stream`.
230
+ # stream=True will read data as it arrives in whatever size the
231
+ # chunks are received. If stream=False, data is returned as
232
+ # a single chunk.
233
+ for chunk in r.iter_content(chunk_size=None):
234
+ f.write(chunk)
235
+ r.close()
236
+
237
+ return _unsafe(r.status_code, out_file)
238
+
239
+ def _fetch_object(self, endpoint, method='get', **kwargs):
86
240
  if not endpoint.startswith('/'):
87
241
  endpoint = '/' + endpoint
88
242
  if 'headers' not in kwargs:
89
- kwargs['headers'] = {'content-type': 'application/hal+json'}
90
- elif 'content-type' not in (k.lower() for k in kwargs['headers']):
91
- kwargs['headers'].update({'content-type': 'application/hal+json'})
243
+ kwargs['headers'] = {'accept': 'application/json'}
244
+ elif 'accept' not in (k.lower() for k in kwargs['headers']):
245
+ kwargs['headers'].update({'accept': 'application/json'})
92
246
  if 'timeout' not in kwargs:
93
247
  # https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
94
248
  kwargs['timeout'] = (6.06, 27)
95
- r = self.session.request('get', self.url + endpoint, **kwargs)
249
+ r = self.session.request(method, self.url + endpoint, **kwargs)
96
250
  r.raise_for_status()
97
-
251
+
252
+ return r
253
+
254
+ def _make_link(self, parent_href, child_href, *, sever=False, **kwargs):
255
+ verb = "LINK" if not sever else "UNLINK"
256
+ if 'timeout' not in kwargs:
257
+ # https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
258
+ kwargs['timeout'] = (6.06, 27)
259
+ r = self.session.request(verb, self.url + parent_href,
260
+ headers={"location": child_href}, **kwargs)
261
+ r.raise_for_status()
262
+
98
263
  return r
99
264
 
100
265
  def _create_object(self, endpoint, data, method='post', **kwargs):
101
266
  if not endpoint.startswith('/'):
102
267
  endpoint = '/' + endpoint
103
- if not isinstance(data, str):
104
- data = json.dumps(data, ensure_ascii=False) # default is `True`, escapes Umlaute!
105
- if 'headers' not in kwargs:
106
- kwargs['headers'] = {'content-type': 'application/hal+json'}
107
- elif 'content-type' not in (k.lower() for k in kwargs['headers']):
108
- kwargs['headers'].update({'content-type': 'application/hal+json'})
268
+ if data is not None:
269
+ if not isinstance(data, str):
270
+ # Note: default is `ensure_ascii=True`, but this escapes Umlaute!
271
+ data = json.dumps(data, ensure_ascii=False)
272
+ if 'headers' not in kwargs:
273
+ kwargs['headers'] = {'content-type': 'application/json'}
274
+ elif 'content-type' not in (k.lower() for k in kwargs['headers']):
275
+ kwargs['headers'].update({'content-type': 'application/json'})
109
276
  if 'timeout' not in kwargs:
110
277
  # https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
111
278
  kwargs['timeout'] = (6.06, 27)
@@ -122,8 +289,9 @@ class IoniConnect(_IoniClientBase):
122
289
  from operator import attrgetter
123
290
 
124
291
  # Note: the DB-API distinguishes between peaks with
125
- # different center *and* name, so this is our key:
126
- make_key = lambda peak: (peak['center'], peak['name'])
292
+ # different center *and* name, while the PyTRMS 'Peak'
293
+ # only distinguishes by center, so this is our key:
294
+ make_key = lambda p_info: (p_info['center'], p_info['name'])
127
295
 
128
296
  if isinstance(peaktable, str):
129
297
  log.info(f"loading peaktable '{peaktable}'...")
@@ -147,55 +315,74 @@ class IoniConnect(_IoniClientBase):
147
315
  updates[make_key(payload)] = {'payload': payload}
148
316
 
149
317
  log.info(f"fetching current peaktable from the server...")
318
+ pt_server = self.get('/api/peaks')['_embedded']['peaks']
150
319
  # create a comparable collection of peaks already on the database by
151
320
  # reducing the keys in the response to what we actually want to update:
152
321
  db_peaks = {make_key(p): {
153
- 'payload': {k: p[k] for k in conv.keys()},
154
- 'self': p['_links']['self'],
155
- 'parent': p['_links'].get('parent'),
322
+ 'payload': {k: p[k] for k in conv.keys()},
323
+ 'self': p['_links']['self'],
324
+ 'parent': p['_links'].get('parent'),
156
325
  } for p in self.get('/api/peaks')['_embedded']['peaks']}
157
326
 
158
327
  to_update = updates.keys() & db_peaks.keys()
159
328
  to_upload = updates.keys() - db_peaks.keys()
160
- updated = 0
329
+ updated = up_to_date = 0
161
330
  for key in sorted(to_update):
162
331
  # check if an existing peak needs an update
163
332
  if db_peaks[key]['payload'] == updates[key]['payload']:
164
333
  # nothing to do..
165
334
  log.debug(f"up-to-date: {key}")
166
- continue
167
-
168
- self.put(db_peaks[key]['self']['href'], updates[key]['payload'])
169
- log.info(f"updated: {key}")
170
- updated += 1
335
+ up_to_date += 1
336
+ else:
337
+ self.put(db_peaks[key]['self']['href'], updates[key]['payload'])
338
+ log.info(f"updated: {key}")
339
+ updated += 1
171
340
 
172
341
  if len(to_upload):
173
342
  # Note: POSTing the embedded-collection is *miles faster*
174
343
  # than doing separate requests for each peak!
175
- payload = {'_embedded': {'peaks': [updates[key]['payload'] for key in sorted(to_upload)]}}
344
+ payload = {
345
+ '_embedded': {
346
+ 'peaks': [updates[key]['payload']
347
+ for key in sorted(to_upload)]
348
+ }
349
+ }
176
350
  self.post('/api/peaks', payload)
177
351
  for key in sorted(to_upload):
178
352
  log.info(f"added new: {key}")
353
+ # Note: we need the updated peaktable to learn about
354
+ # the href (id) assigned to newly added peaks:
355
+ pt_server = self.get('/api/peaks')['_embedded']['peaks']
356
+
357
+ log.info("repairing fitpeak~>nominal links...")
358
+ peak2href = {
359
+ Peak(p["center"], label=p["name"]): p["_links"]["self"]["href"]
360
+ for p in pt_server
361
+ }
362
+ to_link = set((peak2href[fitted], peak2href[fitted.parent])
363
+ for fitted in peaktable.fitted)
364
+
365
+ is_link = set((child["_links"]["self"]["href"], child["_links"]["parent"]["href"])
366
+ for child in pt_server if "parent" in child["_links"])
367
+
368
+ for child_href, parent_href in is_link & to_link:
369
+ log.debug(f"keep link {parent_href} <~> {child_href}")
370
+ pass
371
+
372
+ for child_href, parent_href in to_link - is_link:
373
+ log.debug(f"make link {parent_href} ~>> {child_href}")
374
+ self.link(parent_href, child_href)
179
375
 
180
- if len(peaktable.fitted):
181
- # Note: until now, we disregarded the peak-parent-relationship, so
182
- # make another request to the updated peak-table from the server...
183
- peak2href = {Peak(center=p["center"], label=p["name"]): p["_links"]["self"]["href"]
184
- for p in self.get('/api/peaks')['_embedded']['peaks']}
185
-
186
- for fitted in peaktable.fitted:
187
- fitted_href = peak2href[fitted]
188
- parent_href = peak2href[fitted.parent]
189
- r = self.session.request('link', self.url + parent_href, headers={"location": fitted_href})
190
- if not r.ok:
191
- log.error(f"LINK {parent_href} to Location: {fitted_href} failed\n\n[{r.status_code}]: {r.content}")
192
- r.raise_for_status()
193
- log.debug(f"linked parent {parent_href} ~> {fitted_href}")
376
+ for child_href, parent_href in is_link - to_link:
377
+ log.debug(f'break link {parent_href} ~x~ {child_href}')
378
+ self.unlink(parent_href, child_href)
194
379
 
195
380
  return {
196
381
  'added': len(to_upload),
197
382
  'updated': updated,
198
- 'up-to-date': len(to_update) - updated,
383
+ 'up-to-date': up_to_date,
384
+ 'linked': len(to_link - is_link),
385
+ 'unlinked': len(is_link - to_link),
199
386
  }
200
387
 
201
388
  def iter_events(self, event_re=r".*"):
pytrms/clients/dummy.py CHANGED
@@ -1,7 +1,8 @@
1
- from . import _logging
1
+ import logging
2
+
2
3
  from .._base import _IoniClientBase
3
4
 
4
- log = _logging.getLogger(__name__)
5
+ log = logging.getLogger(__name__)
5
6
 
6
7
 
7
8
  class IoniDummy(_IoniClientBase):
@@ -21,7 +22,7 @@ class IoniDummy(_IoniClientBase):
21
22
 
22
23
  __is_running = False
23
24
 
24
- def connect(self, timeout_s):
25
+ def connect(self, timeout_s=0):
25
26
  log.info(f'pretending to connect to server')
26
27
  self.__is_connected = True
27
28
 
@@ -37,7 +38,7 @@ class IoniDummy(_IoniClientBase):
37
38
  log.info(f'pretending to stop measurement ({future_cycle = })')
38
39
  self.__is_running = False
39
40
 
40
- def __init__(self, host='localhost', port=5687):
41
+ def __init__(self, host='localhost', port=1234):
41
42
  super().__init__(host, port)
42
43
  self.connect()
43
44
 
pytrms/clients/mqtt.py CHANGED
@@ -2,18 +2,18 @@ import os
2
2
  import time
3
3
  import json
4
4
  import queue
5
+ import logging
5
6
  from collections import deque, namedtuple
6
7
  from datetime import datetime
7
8
  from functools import wraps
8
9
  from itertools import cycle, chain, zip_longest
9
10
  from threading import Condition, RLock
10
11
 
11
- from . import _logging
12
12
  from . import _par_id_file
13
13
  from .._base import itype, _MqttClientBase, _IoniClientBase
14
14
 
15
15
 
16
- log = _logging.getLogger(__name__)
16
+ log = logging.getLogger(__name__)
17
17
 
18
18
  __all__ = ['MqttClient']
19
19
 
@@ -22,12 +22,6 @@ with open(_par_id_file) as f:
22
22
  from pandas import read_csv, isna
23
23
 
24
24
  _par_id_info = read_csv(f, sep='\t').drop(0).set_index('Name').fillna('')
25
- if isna(_par_id_info.at['MPV_1', 'Access']):
26
- log.warning(f'filling in read-properties still missing in {os.path.basename(_par_id_file)}')
27
- _par_id_info.at['MPV_1', 'Access'] = 'RW'
28
- _par_id_info.at['MPV_2', 'Access'] = 'RW'
29
- _par_id_info.at['MPV_3', 'Access'] = 'RW'
30
-
31
25
 
32
26
 
33
27
  ## >>>>>>>> adaptor functions <<<<<<<< ##
pytrms/clients/ssevent.py CHANGED
@@ -1,15 +1,16 @@
1
1
  import re
2
+ import time
3
+ import logging
2
4
  from collections import namedtuple
3
5
  from collections.abc import Iterable
4
6
 
5
7
  import requests
6
8
 
7
- from . import _logging
8
-
9
- log = _logging.getLogger(__name__)
9
+ log = logging.getLogger(__name__)
10
10
 
11
11
  _event_rv = namedtuple('ssevent', ['event', 'data'])
12
12
 
13
+
13
14
  class SSEventListener(Iterable):
14
15
 
15
16
  @staticmethod
@@ -29,7 +30,6 @@ class SSEventListener(Iterable):
29
30
  self._get = session.get
30
31
  else:
31
32
  self._get = requests.get
32
- self._connect_response = None
33
33
  self.subscriptions = set()
34
34
  if event_re is not None:
35
35
  self.subscribe(event_re)
@@ -37,46 +37,75 @@ class SSEventListener(Iterable):
37
37
  def subscribe(self, event_re):
38
38
  """Listen for events matching the given string or regular expression."""
39
39
  self.subscriptions.add(re.compile(event_re))
40
- if self._connect_response is None:
41
- r = self._get(self.uri, headers={'accept': 'text/event-stream'}, stream=True)
42
- if not r.ok:
43
- log.error(f"no connection to {self.uri} (got [{r.status_code}])")
44
- r.raise_for_status()
45
-
46
- self._connect_response = r
47
40
 
48
41
  def unsubscribe(self, event_re):
49
42
  """Stop listening for certain events."""
50
43
  self.subscriptions.remove(re.compile(event_re))
51
- if not len(self.subscriptions):
52
- log.debug(f"closing connection to {self.uri}")
53
- self._connect_response.close()
54
- self._connect_response = None
55
44
 
56
- def __iter__(self):
57
- if self._connect_response is None:
45
+ def follow_events(self, timeout_s=None, prime=False):
46
+ """Returns a generator that produces events as soon as they are emitted.
47
+
48
+ When `timeout_s` is given, a hard timeout is set, after which the stream
49
+ is closed and the generator raises `StopIteration`. This makes it possible
50
+ to e.g. collect events into a list or test for an event to occur.
51
+
52
+ With `prime=True` the first (pseudo-)event will be generated immediately
53
+ after the connection has been made. The event type is 'new connection'.
54
+
55
+ _Note_: The timeout cannot be accurate, because the `requests` library only
56
+ allows to check the timeout when either an event or a keep-alive is received!
57
+ This may take up to 13 seconds (currently set on the API). Also, the last
58
+ event may be discarded.
59
+
60
+ `iter(<instance>)` calls this method with `timeout_s=None`.
61
+ """
62
+ if not len(self.subscriptions):
58
63
  raise Exception("call .subscribe() first to listen for events")
59
64
 
65
+ log.debug(f"opening connection to {self.uri}")
66
+ _response = self._get(self.uri, headers={'accept': 'text/event-stream'}, stream=True)
67
+ if not _response.ok:
68
+ log.error(f"no connection to {self.uri} (got [{_response.status_code}])")
69
+ _response.raise_for_status()
70
+
71
+ if prime:
72
+ yield _event_rv('new connection', '/api/status')
73
+
74
+ started_at = time.monotonic()
60
75
  event = msg = ''
61
- for line in self._line_stream(self._connect_response): # blocks...
62
- if not line:
63
- # an empty line concludes an event
64
- if event and any(re.match(sub, event) for sub in self.subscriptions):
65
- yield _event_rv(event, msg)
66
-
67
- # Note: any further empty lines are ignored (may be used as keep-alive),
68
- # but in either case clear event and msg to rearm for the next event:
69
- event = msg = ''
70
- continue
71
-
72
- key, val = line.split(':', maxsplit=1)
73
- if not key:
74
- # this is a comment, starting with a colon ':' ...
75
- log.log(_logging.TRACE, "sse:" + val)
76
- elif key == 'event':
77
- event = val.lstrip()
78
- elif key == 'data':
79
- msg += val.lstrip()
80
- else:
81
- log.warning(f"unknown SSE-key <{key}> in stream")
76
+ try:
77
+ for line in self._line_stream(_response): # blocks...
78
+ elapsed_s = time.monotonic() - started_at
79
+ if timeout_s is not None and elapsed_s > timeout_s:
80
+ log.debug(f"no more events after {round(elapsed_s)} seconds")
81
+ return # (raises StopIteration)
82
+
83
+ if not line:
84
+ # an empty line concludes an event
85
+ if event and any(re.match(sub, event) for sub in self.subscriptions):
86
+ yield _event_rv(event, msg)
87
+
88
+ # Note: any further empty lines are ignored (may be used as keep-alive),
89
+ # but in either case clear event and msg to rearm for the next event:
90
+ event = msg = ''
91
+ continue
92
+
93
+ key, val = line.split(':', maxsplit=1)
94
+ if not key:
95
+ # this is a comment, starting with a colon ':' ...
96
+ log.log(logging.TRACE, "sse:" + val)
97
+ elif key == 'event':
98
+ event = val.lstrip()
99
+ elif key == 'data':
100
+ msg += val.lstrip()
101
+ else:
102
+ log.warning(f"unknown SSE-key <{key}> in stream")
103
+ finally:
104
+ _response.close()
105
+ log.debug(f"closed connection to {self.uri}")
106
+
107
+ def __iter__(self):
108
+ g = self.follow_events(timeout_s=None, prime=True)
109
+ assert next(g).event == 'new connection', "invalid program: pseude-event expected"
110
+ yield from g
82
111
 
pytrms/data/ParaIDs.csv CHANGED
@@ -69,9 +69,9 @@ ID Name DataType Access ServerName LVWriteQueueName SharedVariable PrettyName Un
69
69
  67 MPV_Dir_1 I32 PTR PTR_Write MPV Dir
70
70
  68 MPV_Dir_2 I32 PTR PTR_Write MPV Dir2
71
71
  69 MPV_Dir_3 I32 PTR PTR_Write MPV Dir3
72
- 70 MPV_1 PTR PTR_Write MPValve1
73
- 71 MPV_2 PTR PTR_Write MPValve2
74
- 72 MPV_3 PTR PTR_Write MPValve3
72
+ 70 MPV_1 RW PTR PTR_Write MPValve1
73
+ 71 MPV_2 RW PTR PTR_Write MPValve2
74
+ 72 MPV_3 RW PTR PTR_Write MPValve3
75
75
  73 DPS_Uf DBL RW PTR PTR_Write Uf V
76
76
  74 DPS_U1 DBL RW PTR PTR_Write UF1 V
77
77
  75 DPS_U2 DBL RW PTR PTR_Write UF2 V
pytrms/instrument.py CHANGED
@@ -1,11 +1,12 @@
1
+ """
2
+ @file instrument.py
3
+
4
+ """
1
5
  import os.path
2
6
  import time
3
7
  from abc import abstractmethod, ABC
4
8
 
5
- from .measurement import (
6
- RunningMeasurement,
7
- FinishedMeasurement,
8
- )
9
+ from ._base import _IoniClientBase
9
10
 
10
11
  __all__ = ['Instrument']
11
12
 
@@ -14,13 +15,35 @@ class Instrument(ABC):
14
15
  '''
15
16
  Class for controlling the PTR instrument remotely.
16
17
 
17
- This class reflects the states of the actual instrument, which can be currently idle
18
- or running a measurement. An idle instrument can start a measurement. A running
19
- instrument can be stopped. But trying to start a measurement twice will raise an
20
- exception (RuntimeError).
18
+ This class reflects the states of the actual instrument, which can be currently
19
+ either idle or running. A idle instrument can start a measurement. A running
20
+ instrument can be stopped.
21
+
22
+ The `Instrument` class wraps a `backend`. For testing purposes, we use a mock:
23
+ >>> from pytrms.clients import dummy
24
+ >>> backend = dummy.IoniDummy()
25
+
26
+ Note, that for every client PTR instrument there is only one instance of this class
27
+ (this is to prevent different instances to be in other states than the instrument).
28
+
29
+ >>> ptr = Instrument(backend)
30
+ >>> ptr
31
+ <_IdleInstrument [<IoniDummy @ 127.0.0.1[:1234]>]>
32
+
33
+ >>> id(ptr) == id(Instrument(dummy.IoniDummy())) # singleton ID is always the same
34
+ True
35
+
36
+ Trying to start an instrument twice will raise a RuntimeError!
37
+
38
+ >>> ptr.start_measurement(filename='foo %y %M')
39
+ >>> ptr.is_running
40
+ True
41
+
42
+ >>> ptr.start_measurement()
43
+ Traceback (most recent call last):
44
+ ...
45
+ RuntimeError: can't start <_RunningInstrument>
21
46
 
22
- Note, that for every client PTR instrument there is only one instance of this class.
23
- This is to prevent different instances to be in other states than the instrument.
24
47
  '''
25
48
  __instance = None
26
49
 
@@ -28,7 +51,7 @@ class Instrument(ABC):
28
51
  # Note: we get ourselves a nifty little state-machine :)
29
52
  self.__class__ = newstate
30
53
 
31
- def __new__(cls, backend):
54
+ def __new__(cls, *args, **kwargs):
32
55
  # Note (reminder): If __new__() does not return an instance of cls,
33
56
  # then the new instance’s __init__() method will *not* be invoked!
34
57
  #
@@ -38,6 +61,9 @@ class Instrument(ABC):
38
61
  if cls._Instrument__instance is not None:
39
62
  return cls._Instrument__instance
40
63
 
64
+ backend = args[0] # fetch it from first argument (passed to __init__)
65
+ assert isinstance(backend, (_IoniClientBase)), f"backend must implement {type(_IoniClientBase)}"
66
+
41
67
  if backend.is_running:
42
68
  inst = object.__new__(_RunningInstrument)
43
69
  else:
@@ -47,9 +73,9 @@ class Instrument(ABC):
47
73
 
48
74
  return inst
49
75
 
50
- def __init__(self, backend):
51
- # Note: this will be called *once* per Python process!
52
- self.backend = backend
76
+ @property
77
+ def is_running(self):
78
+ return type(self) is _RunningInstrument
53
79
 
54
80
  @property
55
81
  def is_local(self):
@@ -57,6 +83,13 @@ class Instrument(ABC):
57
83
  host = str(self.backend.host)
58
84
  return 'localhost' in host or '127.0.0.1' in host
59
85
 
86
+ def __init__(self, backend):
87
+ # Note: this will be called *once* per Python process! see __new__() method.
88
+ self.backend = backend
89
+
90
+ def __repr__(self):
91
+ return f'<{self.__class__.__name__} [{self.backend}]>'
92
+
60
93
  def get(self, varname):
61
94
  """Get the current value of a setting."""
62
95
  # TODO :: this is not an interface implementation...
@@ -76,15 +109,29 @@ class Instrument(ABC):
76
109
  """Set a variable to a new value."""
77
110
  return self.backend.set(varname, value, unit='-')
78
111
 
79
- _current_sourcefile = ''
80
-
81
112
  def start_measurement(self, filename=''):
113
+ """Start a new measurement.
114
+
115
+ 'filename' is the filename of the datafile to write to. If left blank, start
116
+ a "quick measurement", for which IoniTOF writes to its default folder.
117
+
118
+ If pointing to a file and the file exist on the (local) server, this raises
119
+ an exception! To create unique filenames, use placeholders for year (%Y),
120
+ month (%m), and so on, for example `filename=D:/Sauerteig_%Y-%m-%d_%H-%M-%S.h5`.
121
+ The `filename` is passed through `strftime` with the current date and time.
122
+
123
+ see also:
124
+ """
82
125
  # this method must be implemented by each state
83
- raise RuntimeError("can't start %s" % self.__class__)
126
+ raise RuntimeError("can't start <%s>" % type(self).__name__)
127
+
128
+ # (see also: this docstring)
129
+ start_measurement.__doc__ += time.strftime.__doc__
84
130
 
85
131
  def stop_measurement(self):
132
+ """Stop a running measurement."""
86
133
  # this method must be implemented by each state
87
- raise RuntimeError("can't stop %s" % self.__class__)
134
+ raise RuntimeError("can't stop <%s>" % type(self).__name__)
88
135
 
89
136
 
90
137
  class _IdleInstrument(Instrument):
@@ -110,18 +157,12 @@ class _IdleInstrument(Instrument):
110
157
  raise RuntimeError(f'filename exists and cannot be overwritten')
111
158
 
112
159
  self.backend.start_measurement(filename)
113
- self._current_sourcefile = filename
114
- self._new_state(RunningInstrument)
115
-
116
- return RunningMeasurement(self)
160
+ self._new_state(_RunningInstrument)
117
161
 
118
162
 
119
163
  class _RunningInstrument(Instrument):
120
164
 
121
165
  def stop_measurement(self):
122
166
  self.backend.stop_measurement()
123
- self._new_state(IdleInstrument)
124
-
125
- # TODO :: this catches only one sourcefile.. it'll do for simple cases:
126
- return FinishedMeasurement(self._current_sourcefile)
167
+ self._new_state(_IdleInstrument)
127
168
 
pytrms/peaktable.py CHANGED
@@ -22,8 +22,7 @@ other custom formats.
22
22
  ... [
23
23
  ... {"label":"H3O+",
24
24
  ... "center":21.0219,
25
- ... "formula":"?",
26
- ... "parent":"?",
25
+ ... "formula":"<unknown>",
27
26
  ... "isotopic_abundance":0.002,
28
27
  ... "k_rate":2.10,
29
28
  ... "multiplier":488
@@ -53,7 +52,7 @@ Peaks may be modified and the PeakTable exported in the same format:
53
52
  "center": 21.0219,
54
53
  "label": "H3O+",
55
54
  "formula": "H3O",
56
- "parent": "?",
55
+ "parent": null,
57
56
  "isotopic_abundance": 0.678,
58
57
  "k_rate": 2.1,
59
58
  "multiplier": 488.0,
@@ -107,7 +106,7 @@ class Peak:
107
106
  """
108
107
  _exact_decimals = 4
109
108
 
110
- def __init__(self, center, label='', formula='', parent=None, borders=(),
109
+ def __init__(self, center, label='', *, formula='', parent=None, borders=(),
111
110
  isotopic_abundance=1.0, k_rate=2.0, multiplier=1.0,
112
111
  resolution=1000, shift=0):
113
112
  self.center = round(float(center), ndigits=Peak._exact_decimals)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pytrms
3
- Version: 0.9.7
3
+ Version: 0.9.8
4
4
  Summary: Python bundle for proton-transfer reaction mass-spectrometry (PTR-MS).
5
5
  License: GPL-2.0
6
6
  Author: Moritz Koenemann
@@ -1,28 +1,28 @@
1
- pytrms/__init__.py,sha256=jqpXWWSo2wsyj3kgPz7ppNOmUqCxTuF1NVVdTm8zdMw,1134
1
+ pytrms/__init__.py,sha256=znPgfmjFqRfx_UoKYLhpNTtDlMVqrQc8cmYHwigABaY,2821
2
2
  pytrms/_base/__init__.py,sha256=2KUO7R_SPZxsV0Xe_Hy4e2HnQI5zDzOYJc4H7G3ZQpE,850
3
3
  pytrms/_base/ioniclient.py,sha256=eTkksDwB8u9i-rnwCGH7o8rXcNbez-QakXLqQFJJCw0,1368
4
4
  pytrms/_base/mqttclient.py,sha256=DxETIjK_qIvxCGiHLzHXdvyGcCgiwYfggWlXrxzwpqY,5347
5
5
  pytrms/_version.py,sha256=yRCN1kvPaX0FHycK0NBHTluhkf5euj8U7WNTKdVQafg,828
6
- pytrms/clients/__init__.py,sha256=79EUbgBItW49t8Kjl8HF3d109mTwB2YmEc320RdA3yI,1347
7
- pytrms/clients/db_api.py,sha256=HsbJsocRt2nkTyFSuiBsLfIyDf_6F4NWCB34-YxZkSw,8627
8
- pytrms/clients/dummy.py,sha256=Ciyw8kErRpCilNRIdTBtuZ2HS3MLVWTuI4XsnAYPncY,1153
6
+ pytrms/clients/__init__.py,sha256=_IHR-GMeAemtrkeQR5I4lPvO9wVR0pEWuswgdi4Rw0I,263
7
+ pytrms/clients/db_api.py,sha256=ZZuKz1PrWPL5Zu4FFgXY1Ovz7f1Evt_Kt1cDRP51sHs,17488
8
+ pytrms/clients/dummy.py,sha256=BaCAhR30eE-ULu8U0bE-7L_cTdAeWsW0Dn1C_cwiCjw,1147
9
9
  pytrms/clients/ioniclient.py,sha256=cp37XSoCzLamjDzcUCrKbp530YMstQ0Eoj81r5tClL8,2390
10
10
  pytrms/clients/modbus.py,sha256=Mgxps17amE033P6cCJ5Bv4r8vdWZC2SiyxqfQ7KfEhA,26219
11
- pytrms/clients/mqtt.py,sha256=S5zZgRE3GST2cVyq3jsoJ-hhQM9U-jErpTn4LxXzrA0,32119
12
- pytrms/clients/ssevent.py,sha256=guRk7ny43KAB2SYTj6UvocdH786nu31iMFU-SeBob9Q,2946
11
+ pytrms/clients/mqtt.py,sha256=ybtRbKeRfxz2bPOgOtlJDAkCP79fWEu47z7pliZHAKQ,31809
12
+ pytrms/clients/ssevent.py,sha256=zj5LPkIbGm3APOJuol9GPc_A_zsas6gjY-w9sM-LQwo,4354
13
13
  pytrms/compose/__init__.py,sha256=hbjX-rzFlBnZpp7OP8edyZAw0r2-im1MtWxQNU8tG_A,28
14
14
  pytrms/compose/composition.py,sha256=VU0E50zIdqS4cIzTdMMDxWWCUd5jkLbuPcNAET93oqo,11779
15
15
  pytrms/data/IoniTofPrefs.ini,sha256=BGyTijnmtdGqa-kGbAuB1RysG4MPK9nlF9TR069tKwE,1887
16
- pytrms/data/ParaIDs.csv,sha256=H4ssQixGYvGMcYSdRcycmGbIrO2J8imh6FGwUNsmmM4,27395
16
+ pytrms/data/ParaIDs.csv,sha256=CWOXx83OTmHRgsl39Si35DwND94UnvhdIrkekrnbpNg,27401
17
17
  pytrms/helpers.py,sha256=0GhSlX4zwMIQqNx2-G7WrLecoYIG7MVFZb5doODaenA,4985
18
- pytrms/instrument.py,sha256=jO37ZUlCkItXNzjT9Hh-mx6Eyt4VD4DBQATEiKUEb4M,4432
18
+ pytrms/instrument.py,sha256=x0lBK5O9BBLNdRAZMnc4DGsQSCQe0Lu-WH5pgNGkoDI,5824
19
19
  pytrms/measurement.py,sha256=iHsEWmJhCSFxMON_N-5mtCmFqigBqGQM4AImqE7pS44,7629
20
- pytrms/peaktable.py,sha256=tFRNNJoztV-l28Ml6W0K0qPq-A_BttH7NiRUdt6xzdM,17202
20
+ pytrms/peaktable.py,sha256=w76wPgVARQH8bOppDejpvVHjSDY88ZZYkGBMmgRFmTM,17195
21
21
  pytrms/plotting/__init__.py,sha256=sfL4k2PeBmzIf-5Z_2rnkeM8As3psbPxaVvmpi1T48Q,62
22
22
  pytrms/plotting/plotting.py,sha256=WzP4Is2PSu_NdhY73HsCU8iEHmLgnIgNY-opsxwIjH8,675
23
23
  pytrms/readers/__init__.py,sha256=2r9dXwPRqYkIGpM0EjY-m_Ti3qtxE0jeC9FnudDDoXc,72
24
24
  pytrms/readers/ionitof_reader.py,sha256=c8W5aglrY1bmuljnaAqx6TGWAPGXXMkuHjJ1aRw3Wrc,17624
25
- pytrms-0.9.7.dist-info/LICENSE,sha256=gXf5dRMhNSbfLPYYTY_5hsZ1r7UU1OaKQEAQUhuIBkM,18092
26
- pytrms-0.9.7.dist-info/METADATA,sha256=04R29OtNVEqD7vJvBBdZQOFPgGYt9ZkIgNXk4GWtqcU,812
27
- pytrms-0.9.7.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
28
- pytrms-0.9.7.dist-info/RECORD,,
25
+ pytrms-0.9.8.dist-info/LICENSE,sha256=gXf5dRMhNSbfLPYYTY_5hsZ1r7UU1OaKQEAQUhuIBkM,18092
26
+ pytrms-0.9.8.dist-info/METADATA,sha256=ZA7Pb3jx131IEc0pjISnvg6vpXlmiPdGoIi3Uot7hJM,812
27
+ pytrms-0.9.8.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
28
+ pytrms-0.9.8.dist-info/RECORD,,
File without changes