tagreader 6.2.1__tar.gz → 6.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tagreader
3
- Version: 6.2.1
3
+ Version: 6.3.0
4
4
  Summary: Tagreader is a Python package for reading trend data from the OSIsoft PI and Aspen Infoplus.21 IMS systems.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -25,7 +25,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
25
  Classifier: Topic :: Utilities
26
26
  Requires-Dist: certifi (>=2024.12.14,<2026.0.0)
27
27
  Requires-Dist: diskcache (>=5.6.1)
28
- Requires-Dist: msal-bearer (>=1.3.3)
28
+ Requires-Dist: msal-bearer (>=1.5.2)
29
29
  Requires-Dist: pandas (>=1)
30
30
  Requires-Dist: platformdirs (>=4.3.7)
31
31
  Requires-Dist: pycryptodome (>=3.20.0)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tagreader"
3
- version = "6.2.1"
3
+ version = "6.3.0"
4
4
  description = "Tagreader is a Python package for reading trend data from the OSIsoft PI and Aspen Infoplus.21 IMS systems."
5
5
  authors = [
6
6
  { name = "Einar S. Idsø", email = "eiids@equinor.com" },
@@ -32,7 +32,7 @@ dependencies = [
32
32
  "certifi>=2024.12.14,<2026.0.0",
33
33
  "requests>=2.32.5",
34
34
  "requests-kerberos>=0",
35
- "msal-bearer>=1.3.3",
35
+ "msal-bearer>=1.5.2",
36
36
  "diskcache>=5.6.1",
37
37
  "pycryptodome>=3.20.0",
38
38
  "requests-ntlm>=1.1,<=2.0",
@@ -159,11 +159,11 @@ def get_handler(
159
159
 
160
160
  if imstype == IMSType.PIWEBAPI:
161
161
  return PIHandlerWeb(
162
- url=url,
163
162
  datasource=datasource,
164
- options=options,
165
- verify_ssl=verify_ssl,
163
+ url=url,
166
164
  auth=auth,
165
+ verify_ssl=verify_ssl,
166
+ options=options,
167
167
  cache=cache,
168
168
  )
169
169
 
@@ -365,7 +365,7 @@ class IMSClient:
365
365
  end=end,
366
366
  )
367
367
  frames.append(df)
368
- if len(df) < self.handler._max_rows:
368
+ if len(df) < self.handler.max_rows:
369
369
  break
370
370
  start = df.index[-1]
371
371
 
@@ -86,17 +86,11 @@ def get_url_aspen(use_internal: bool = True) -> str:
86
86
  return r"https://ewepwapa1pep04-statoilsrm.msappproxy.net/ProcessExplorer/ProcessData/AtProcessDataREST.dll"
87
87
 
88
88
 
89
- def list_aspenone_sources(
90
- url: Optional[str] = None,
91
- auth: Optional[Any] = None,
92
- verify_ssl: Optional[Union[bool, str]] = True,
89
+ def _fetch_aspenone_sources(
90
+ url: str,
91
+ auth: Any,
92
+ verify_ssl: Optional[Union[bool, str]],
93
93
  ) -> List[str]:
94
- if url is None:
95
- url = get_url_aspen()
96
-
97
- if auth is None:
98
- auth = get_auth_aspen()
99
-
100
94
  if verify_ssl is None:
101
95
  verify_ssl = get_verify_ssl()
102
96
 
@@ -117,17 +111,35 @@ def list_aspenone_sources(
117
111
  return []
118
112
 
119
113
 
120
- def list_piwebapi_sources(
114
+ # Keyed on url only so default-auth calls share the cache regardless of auth or verify_ssl.
115
+ _aspenone_sources_cache: Dict[str, List[str]] = {}
116
+
117
+
118
+ def list_aspenone_sources(
121
119
  url: Optional[str] = None,
122
120
  auth: Optional[Any] = None,
123
121
  verify_ssl: Optional[Union[bool, str]] = True,
124
122
  ) -> List[str]:
125
123
  if url is None:
126
- url = get_url_pi()
124
+ url = get_url_aspen()
127
125
 
128
- if auth is None:
129
- auth = get_auth_pi()
126
+ if auth is not None:
127
+ return _fetch_aspenone_sources(url, auth, verify_ssl)
130
128
 
129
+ if url not in _aspenone_sources_cache:
130
+ url_sources = _fetch_aspenone_sources(url, get_auth_aspen(), verify_ssl)
131
+ if len(url_sources) > 0:
132
+ _aspenone_sources_cache[url] = url_sources
133
+ else:
134
+ return []
135
+ return list(_aspenone_sources_cache[url])
136
+
137
+
138
+ def _fetch_piwebapi_sources(
139
+ url: str,
140
+ auth: Any,
141
+ verify_ssl: Optional[Union[bool, str]],
142
+ ) -> List[str]:
131
143
  if verify_ssl is None:
132
144
  verify_ssl = get_verify_ssl()
133
145
 
@@ -147,6 +159,28 @@ def list_piwebapi_sources(
147
159
  return []
148
160
 
149
161
 
162
+ # Keyed on url only so default-auth calls share the cache regardless of auth or verify_ssl.
163
+ _piwebapi_sources_cache: Dict[str, List[str]] = {}
164
+
165
+
166
+ def list_piwebapi_sources(
167
+ url: Optional[str] = None,
168
+ auth: Optional[Any] = None,
169
+ verify_ssl: Optional[Union[bool, str]] = True,
170
+ ) -> List[str]:
171
+ if url is None:
172
+ url = get_url_pi()
173
+
174
+ if auth is not None:
175
+ return _fetch_piwebapi_sources(url, auth, verify_ssl)
176
+
177
+ if url not in _piwebapi_sources_cache:
178
+ url_sources = _fetch_piwebapi_sources(url, get_auth_pi(), verify_ssl)
179
+ if len(url_sources) > 0:
180
+ _piwebapi_sources_cache[url] = url_sources
181
+ return list(_piwebapi_sources_cache[url])
182
+
183
+
150
184
  def get_piwebapi_source_to_webid_dict(
151
185
  url: Optional[str] = None,
152
186
  auth: Optional[Any] = None,
@@ -186,6 +220,7 @@ class BaseHandlerWeb(ABC):
186
220
  ):
187
221
  self.datasource = datasource
188
222
  self.base_url = url
223
+ self.max_rows = 10000
189
224
  self.session = requests.Session()
190
225
  self.auth = auth
191
226
  self.session.auth = auth if auth is not None else get_auth_aspen()
@@ -193,20 +228,66 @@ class BaseHandlerWeb(ABC):
193
228
  urllib3.disable_warnings(InsecureRequestWarning)
194
229
  self.session.verify = verify_ssl if verify_ssl is not None else get_verify_ssl()
195
230
 
231
+ @property
232
+ def datasource(self) -> str:
233
+ return self._datasource
234
+
235
+ @datasource.setter
236
+ def datasource(self, value: Optional[str]) -> None:
237
+ self._datasource = value if value is not None else ""
238
+
239
+ @property
240
+ def base_url(self) -> str:
241
+ return self._base_url
242
+
243
+ @base_url.setter
244
+ def base_url(self, value: Optional[str]) -> None:
245
+ self._base_url = value if value is not None else ""
246
+
247
+ @property
248
+ def max_rows(self) -> int:
249
+ return self._max_rows
250
+
251
+ @max_rows.setter
252
+ def max_rows(self, value: int) -> None:
253
+ self._max_rows = value
254
+
196
255
  def fetch(
197
256
  self,
198
257
  url,
199
- params: Optional[Union[str, Dict[str, str]]] = None,
258
+ params: Optional[Union[bytes, str, Dict[str, str]]] = None,
200
259
  timeout: Optional[int] = None,
260
+ use_post: bool = False,
201
261
  ) -> Dict:
202
- res = self.session.get(
203
- url,
204
- params=params,
205
- timeout=(
206
- None,
207
- timeout,
208
- ),
209
- ) # Noqa. Read timeout, No connect timeout.
262
+ if use_post:
263
+ headers = {}
264
+ if isinstance(params, dict):
265
+ params = urllib.parse.urlencode(
266
+ params, safe="*", quote_via=urllib.parse.quote
267
+ ).encode("utf-8")
268
+ headers["Content-Type"] = (
269
+ "application/x-www-form-urlencoded; charset=utf-8"
270
+ )
271
+ elif isinstance(params, str):
272
+ params = params.encode("utf-8")
273
+ headers["Content-Type"] = "text/xml; charset=utf-8"
274
+
275
+ res = self.session.post(
276
+ url,
277
+ data=params,
278
+ headers=headers,
279
+ timeout=(None, timeout),
280
+ )
281
+ else:
282
+ res = self.session.get(
283
+ url,
284
+ params=params,
285
+ timeout=(
286
+ None,
287
+ timeout,
288
+ ),
289
+ ) # Noqa. Read timeout, No connect timeout.
290
+
210
291
  res.raise_for_status()
211
292
 
212
293
  if len(res.text) == 0:
@@ -228,15 +309,18 @@ class BaseHandlerWeb(ABC):
228
309
  return json.loads(txt)
229
310
 
230
311
  def connect(self):
312
+ if self.datasource == "":
313
+ raise ValueError("Datasource must be specified")
314
+
231
315
  try:
232
316
  self.verify_connection(self.datasource)
233
317
  except requests.ConnectionError:
234
318
  raise ConnectionError(
235
- f"Not able to connect to {self.base_url}. Check network connection."
319
+ f"Not able to connect to api at {self.base_url}. Check network connection."
236
320
  ) from None
237
321
 
238
322
  @abstractmethod
239
- def verify_connection(self, datasource: str): ...
323
+ def verify_connection(self, datasource: str) -> bool: ...
240
324
 
241
325
 
242
326
  class AspenHandlerWeb(BaseHandlerWeb):
@@ -248,7 +332,7 @@ class AspenHandlerWeb(BaseHandlerWeb):
248
332
  verify_ssl: Optional[Union[bool, str]] = True,
249
333
  options: Dict[str, Any] = dict(),
250
334
  ):
251
- if url is None:
335
+ if url is None or url == "":
252
336
  url = get_url_aspen()
253
337
  if auth is None:
254
338
  auth = get_auth_aspen()
@@ -259,7 +343,7 @@ class AspenHandlerWeb(BaseHandlerWeb):
259
343
  auth=auth,
260
344
  verify_ssl=verify_ssl,
261
345
  )
262
- self._max_rows = options.get("max_rows", 100000)
346
+ self.max_rows = int(options.get("max_rows", 100000))
263
347
  self._connection_string = "" # Used for raw SQL queries
264
348
 
265
349
  @staticmethod
@@ -271,6 +355,7 @@ class AspenHandlerWeb(BaseHandlerWeb):
271
355
  ) -> Dict[str, Any]:
272
356
  if not datasource:
273
357
  raise ValueError("Data source is required argument")
358
+
274
359
  # Aspen Web API expects single space instead of consecutive spaces.
275
360
  tag = " ".join(tag.split())
276
361
  params = {"datasource": datasource, "tag": tag, "max": max, "getTrendable": 0}
@@ -338,7 +423,7 @@ class AspenHandlerWeb(BaseHandlerWeb):
338
423
  f"<RT>{rt}</RT>"
339
424
  )
340
425
  if read_type in [ReaderType.RAW, ReaderType.SHAPEPRESERVING]:
341
- query += f"<X>{self._max_rows}</X>"
426
+ query += f"<X>{self.max_rows}</X>"
342
427
  if read_type not in [ReaderType.INT, ReaderType.SNAPSHOT]:
343
428
  query += f"<O>{outsiders}</O>"
344
429
  if read_type not in [ReaderType.RAW]:
@@ -372,7 +457,7 @@ class AspenHandlerWeb(BaseHandlerWeb):
372
457
 
373
458
  return query
374
459
 
375
- def verify_connection(self, datasource: str):
460
+ def verify_connection(self, datasource: str) -> bool:
376
461
  """Connects to the URL and verifies that the provided data source exists.
377
462
 
378
463
  :param datasource: Data source to look for
@@ -429,7 +514,7 @@ class AspenHandlerWeb(BaseHandlerWeb):
429
514
  def _get_maps(self, tagname: str):
430
515
  params = self.generate_get_map_query(tagname)
431
516
  url = urljoin(self.base_url, "TagInfo")
432
- data = self.fetch(url, params=params)
517
+ data = self.fetch(url, params=params, use_post=not tagname.isascii())
433
518
 
434
519
  if "tags" not in data["data"]:
435
520
  return {}
@@ -457,18 +542,12 @@ class AspenHandlerWeb(BaseHandlerWeb):
457
542
  ) -> Union[List[Tuple[str, str]], List[str]]:
458
543
  ret = []
459
544
 
460
- if tag is None and desc is None:
461
- raise ValueError("Tag is a required argument")
462
- # return ret
463
- elif tag is None and len(desc) == 0:
464
- raise ValueError("Tag is a required argument")
465
- # return ret
466
- elif desc is None and len(tag) == 0:
467
- raise ValueError("Tag is a required argument")
468
- # return ret
545
+ # Currently not possible to search only using description.
546
+ if not tag:
547
+ raise ValueError("Input tag is a required argument")
469
548
 
470
- if tag is None:
471
- raise ValueError("Tag is a required argument")
549
+ if not tag and not desc:
550
+ raise ValueError("Either tag or desc must be provided.")
472
551
 
473
552
  tag = tag.replace("%", "*") if isinstance(tag, str) else None
474
553
  # Prepare for regex
@@ -518,7 +597,7 @@ class AspenHandlerWeb(BaseHandlerWeb):
518
597
  def _get_tag_unit(self, tag: str):
519
598
  query = self.generate_get_unit_query(tag)
520
599
  url = urljoin(self.base_url, "TagInfo")
521
- data = self.fetch(url, params=query)
600
+ data = self.fetch(url, params=query, use_post=not tag.isascii())
522
601
  # try:
523
602
  attr_data = data["data"]["tags"][0]["attrData"]
524
603
  # except KeyError as e:
@@ -553,7 +632,7 @@ class AspenHandlerWeb(BaseHandlerWeb):
553
632
  query = self.generate_get_description_query(tag)
554
633
  url = urljoin(self.base_url, "TagInfo")
555
634
  try:
556
- data = self.fetch(url, params=query)
635
+ data = self.fetch(url, params=query, use_post=not tag.isascii())
557
636
  desc = data["data"]["tags"][0]["attrData"][0]["samples"][0]["v"]
558
637
  # except KeyError:
559
638
  # desc = ""
@@ -570,6 +649,7 @@ class AspenHandlerWeb(BaseHandlerWeb):
570
649
  read_type: ReaderType,
571
650
  metadata: Optional[Dict[str, str]],
572
651
  get_status: bool = False,
652
+ use_post: bool = False,
573
653
  ):
574
654
  if read_type not in [
575
655
  ReaderType.INT,
@@ -597,9 +677,13 @@ class AspenHandlerWeb(BaseHandlerWeb):
597
677
  # so we need to limit the range. Note -1 because INT normally includes
598
678
  # both start and end time.
599
679
  if read_type == ReaderType.INT:
600
- end = min(end, start + sample_time * (self._max_rows - 1))
680
+ end = min(end, start + sample_time * (self.max_rows - 1))
601
681
 
602
682
  tag_name, map_name = self.split_tagmap(tag)
683
+ if tag_name is None or len(tag_name) == 0:
684
+ raise ValueError(f"Input tag: {tag} returns no tag_name. Invalid input.")
685
+ if map_name is None:
686
+ map_name = ""
603
687
 
604
688
  params = self.generate_read_query(
605
689
  tagname=tag_name,
@@ -611,15 +695,29 @@ class AspenHandlerWeb(BaseHandlerWeb):
611
695
  metadata={},
612
696
  )
613
697
 
614
- data = self.fetch(url, params=params)
698
+ # Consider use_post = use_post or not tag.isascii()
699
+ data = self.fetch(url, params=params, use_post=use_post)
615
700
 
616
701
  if len(data) == 0: # Normally for timestamps in future
617
702
  return pd.DataFrame(columns=[tag])
618
703
 
619
704
  if "er" in data["data"][0]["samples"][0]:
705
+ if not use_post and data["data"][0]["samples"][0]["er"] == 5:
706
+ return self.read_tag(
707
+ tag=tag,
708
+ start=start,
709
+ end=end,
710
+ sample_time=sample_time,
711
+ read_type=read_type,
712
+ metadata=metadata,
713
+ get_status=get_status,
714
+ use_post=True,
715
+ )
716
+
620
717
  logger.warning(
621
718
  f"API error for {tag}: {data['data'][0]['samples'][0]['es']} params: {params}"
622
719
  )
720
+
623
721
  return pd.DataFrame(columns=[tag])
624
722
  if get_status:
625
723
  # The "l" field maps 1:1 to ODBC status field values 0, 1, 2, 4, 5, 6
@@ -645,12 +743,17 @@ class AspenHandlerWeb(BaseHandlerWeb):
645
743
 
646
744
  @staticmethod
647
745
  def generate_sql_query(
648
- connection_string: Optional[str],
649
- datasource: Optional[str],
650
- query: Optional[str],
746
+ query: str,
747
+ connection_string: Optional[str] = None,
748
+ datasource: Optional[str] = None,
651
749
  max_rows: int = 100000,
652
- ):
653
- if connection_string is not None:
750
+ ) -> str:
751
+ if not query:
752
+ raise ValueError("Query must not be empty")
753
+
754
+ if not connection_string and not datasource:
755
+ raise ValueError("Either connection_string or datasource must be provided")
756
+ if connection_string:
654
757
  connection_string = (
655
758
  f'<SQL c="{connection_string}" m="{max_rows}" to="30" s="1">'
656
759
  )
@@ -660,31 +763,20 @@ class AspenHandlerWeb(BaseHandlerWeb):
660
763
  'dso="CHARINT=N;CHARFLOAT=N;CHARTIME=N;CONVERTERRORS=N" '
661
764
  f'm="{max_rows}" to="30" s="1">'
662
765
  )
663
- query = query.replace("\t", " ").replace(
664
- "\n", " "
665
- ) # Replace new lines and tabs that are typical in formatted SQL queries with spaces.
666
-
766
+ # Replace new lines and tabs that are typical in formatted SQL queries with spaces.
767
+ query = query.replace("\t", " ").replace("\n", " ")
667
768
  # Need a solution to LIKE comments. These have a % symbol in the query that does not seem to pass through the request
668
-
669
769
  connection_string += f"<![CDATA[{query}]]></SQL>"
670
770
  return connection_string
671
771
 
672
772
  def query_sql(self, query: str, parse: bool = True) -> Union[str, pd.DataFrame]:
673
773
  url = urljoin(self.base_url, "SQL")
674
- if self._connection_string is None:
675
- params = self.generate_sql_query(
676
- datasource=self.datasource,
677
- query=query,
678
- max_rows=self._max_rows,
679
- connection_string=None,
680
- )
681
- else:
682
- params = self.generate_sql_query(
683
- connection_string=self._connection_string,
684
- query=query,
685
- max_rows=self._max_rows,
686
- datasource=None,
687
- )
774
+ params = self.generate_sql_query(
775
+ query=query,
776
+ connection_string=self._connection_string,
777
+ datasource=self.datasource,
778
+ max_rows=self.max_rows,
779
+ )
688
780
 
689
781
  res = self.session.get(url, params=params)
690
782
  res.raise_for_status()
@@ -711,15 +803,14 @@ class AspenHandlerWeb(BaseHandlerWeb):
711
803
  class PIHandlerWeb(BaseHandlerWeb):
712
804
  def __init__(
713
805
  self,
714
- url: Optional[str],
715
806
  datasource: Optional[str],
807
+ url: Optional[str],
716
808
  auth: Optional[Any],
717
809
  verify_ssl: Optional[Union[bool, str]],
718
810
  options: Dict[str, Union[int, float, str]],
719
811
  cache: Optional[Union[SmartCache, BucketCache]],
720
812
  ):
721
- self._max_rows = options.get("max_rows", 10000)
722
- if url is None:
813
+ if url is None or url == "":
723
814
  url = get_url_pi()
724
815
  if auth is None:
725
816
  auth = get_auth_pi()
@@ -729,7 +820,7 @@ class PIHandlerWeb(BaseHandlerWeb):
729
820
  auth=auth,
730
821
  verify_ssl=verify_ssl,
731
822
  )
732
- self._max_rows = options.get("max_rows", 10000)
823
+ self.max_rows = int(options.get("max_rows", 10000))
733
824
  self.web_id_cache = cache
734
825
 
735
826
  @staticmethod
@@ -779,7 +870,7 @@ class PIHandlerWeb(BaseHandlerWeb):
779
870
  query = " AND ".join(q)
780
871
  params = {"query": f"{query}"}
781
872
 
782
- if datasource is not None:
873
+ if isinstance(datasource, str) and len(datasource) > 0:
783
874
  params["dataserverwebid"] = (
784
875
  f"{get_piwebapi_source_to_webid_dict(auth=auth)[datasource]}"
785
876
  )
@@ -806,6 +897,9 @@ class PIHandlerWeb(BaseHandlerWeb):
806
897
  ]:
807
898
  raise NotImplementedError
808
899
 
900
+ if not tag:
901
+ raise ValueError("Tag must be provided.")
902
+
809
903
  web_id = tag
810
904
 
811
905
  seconds = 0
@@ -818,7 +912,6 @@ class PIHandlerWeb(BaseHandlerWeb):
818
912
  ReaderType.SNAPSHOT: "value",
819
913
  ReaderType.SHAPEPRESERVING: "plot",
820
914
  }.get(read_type, "summary")
821
-
822
915
  url = f"streams/{web_id}/{get_action}"
823
916
  params = {}
824
917
 
@@ -865,7 +958,7 @@ class PIHandlerWeb(BaseHandlerWeb):
865
958
  params["selectedFields"] += ";Good;Questionable;Substituted"
866
959
 
867
960
  if read_type == ReaderType.RAW:
868
- params["maxCount"] = self._max_rows
961
+ params["maxCount"] = self.max_rows
869
962
 
870
963
  return url, params
871
964
 
File without changes
File without changes
File without changes
File without changes
File without changes