aspy21 0.2.0b0__tar.gz → 0.2.0b2__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 (39) hide show
  1. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/PKG-INFO +1 -1
  2. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21/_version.py +3 -3
  3. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21/client.py +85 -111
  4. aspy21-0.2.0b2/src/aspy21/query_builder.py +130 -0
  5. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21.egg-info/PKG-INFO +1 -1
  6. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21.egg-info/SOURCES.txt +1 -0
  7. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/tests/test_client.py +229 -0
  8. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/.env.example +0 -0
  9. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/.github/SETUP_GUIDE.md +0 -0
  10. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/.github/workflows/release.yml +0 -0
  11. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/.github/workflows/tests.yml +0 -0
  12. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/.gitignore +0 -0
  13. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/.pre-commit-config.yaml +0 -0
  14. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/CHANGELOG.md +0 -0
  15. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/CODE_OF_CONDUCT.md +0 -0
  16. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/CONTRIBUTING.md +0 -0
  17. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/LICENSE +0 -0
  18. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/README.md +0 -0
  19. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/SECURITY.md +0 -0
  20. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/docs/_static/logo.png +0 -0
  21. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/examples/README.md +0 -0
  22. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/examples/basic_usage.py +0 -0
  23. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/examples/search_and_read.py +0 -0
  24. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/examples/search_tags.py +0 -0
  25. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/justfile +0 -0
  26. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/pyproject.toml +0 -0
  27. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/scripts/bump_version.py +0 -0
  28. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/setup.cfg +0 -0
  29. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21/__init__.py +0 -0
  30. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21/cache.py +0 -0
  31. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21/models.py +0 -0
  32. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21/utils.py +0 -0
  33. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21.egg-info/dependency_links.txt +0 -0
  34. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21.egg-info/requires.txt +0 -0
  35. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/src/aspy21.egg-info/top_level.txt +0 -0
  36. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/tests/__init__.py +0 -0
  37. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/tests/conftest.py +0 -0
  38. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/tests/test_auth.py +0 -0
  39. {aspy21-0.2.0b0 → aspy21-0.2.0b2}/tests/test_logging.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aspy21
3
- Version: 0.2.0b0
3
+ Version: 0.2.0b2
4
4
  Summary: Modern Python client for Aspen InfoPlus.21 (IP.21) with pandas DataFrame output, tag search, and automatic retry logic
5
5
  Author: bazdalaz
6
6
  Maintainer-email: bazdalaz <bazdalaz@users.noreply.github.com>
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.2.0b0'
32
- __version_tuple__ = version_tuple = (0, 2, 0, 'b0')
31
+ __version__ = version = '0.2.0b2'
32
+ __version_tuple__ = version_tuple = (0, 2, 0, 'b2')
33
33
 
34
- __commit_id__ = commit_id = 'gb22963aab'
34
+ __commit_id__ = commit_id = 'gfe38a9fe3'
@@ -12,6 +12,7 @@ import pandas as pd
12
12
  from tenacity import retry, stop_after_attempt, wait_exponential
13
13
 
14
14
  from .models import ReaderType
15
+ from .query_builder import build_read_query, build_sql_search_query
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  from httpx import Auth
@@ -127,72 +128,9 @@ class AspenClient:
127
128
  """Close the HTTP client connection."""
128
129
  self._client.close()
129
130
 
130
- def _generate_xml_query(
131
- self,
132
- tag: str,
133
- start: str,
134
- end: str,
135
- read_type: ReaderType,
136
- interval: int | None = None,
137
- datasource: str = "", # Empty string = use default datasource
138
- max_rows: int = 100000,
139
- ) -> str:
140
- """Generate XML query for Aspen REST API.
141
-
142
- Args:
143
- tag: Tag name
144
- start: Start timestamp (ISO format)
145
- end: End timestamp (ISO format)
146
- read_type: Type of read (RAW, INT, etc.)
147
- interval: Interval in seconds (for aggregated reads)
148
- datasource: Data source name (default: IP21)
149
- max_rows: Maximum number of rows to return
150
-
151
- Returns:
152
- XML query string
153
- """
154
- # Convert ISO timestamps to milliseconds since epoch
155
- start_dt = pd.to_datetime(start)
156
- end_dt = pd.to_datetime(end)
157
- start_ms = int(start_dt.timestamp() * 1000)
158
- end_ms = int(end_dt.timestamp() * 1000)
159
-
160
- # Map ReaderType to Aspen RT codes
161
- rt_map = {
162
- ReaderType.RAW: 0,
163
- ReaderType.INT: 1,
164
- ReaderType.SNAPSHOT: 2, # Will use Attribute endpoint
165
- ReaderType.AVG: 10,
166
- }
167
- rt_code = rt_map.get(read_type, 0)
168
-
169
- # Build XML query
170
- xml = '<Q f="d" allQuotes="1">'
171
- xml += "<Tag>"
172
- xml += f"<N><![CDATA[{tag}]]></N>"
173
- # Only include datasource if specified
174
- if datasource:
175
- xml += f"<D><![CDATA[{datasource}]]></D>"
176
- xml += "<F><![CDATA[VAL]]></F>"
177
- xml += "<HF>0</HF>" # History format: 0=Raw
178
- xml += f"<St>{start_ms}</St>"
179
- xml += f"<Et>{end_ms}</Et>"
180
- xml += f"<RT>{rt_code}</RT>"
181
- xml += f"<X>{max_rows}</X>"
182
-
183
- # Add interval for aggregated reads
184
- if interval and rt_code >= 10:
185
- xml += f"<P>{interval}</P>"
186
- xml += "<PU>3</PU>" # Period unit: 3=seconds
187
-
188
- xml += "</Tag>"
189
- xml += "</Q>"
190
-
191
- return xml
192
-
193
131
  def _parse_aspen_response(
194
132
  self, response: dict, tag_name: str, include_status: bool = True
195
- ) -> pd.DataFrame:
133
+ ) -> tuple[pd.DataFrame, str]:
196
134
  """Parse Aspen REST API response into DataFrame.
197
135
 
198
136
  Args:
@@ -201,7 +139,7 @@ class AspenClient:
201
139
  include_status: Whether to include status column
202
140
 
203
141
  Returns:
204
- DataFrame with timestamp index and tag data
142
+ Tuple of (DataFrame with timestamp index and tag data, tag description)
205
143
 
206
144
  Aspen API returns:
207
145
  {
@@ -220,11 +158,19 @@ class AspenClient:
220
158
  data = response.get("data", [])
221
159
  if not data or not isinstance(data, list):
222
160
  logger.warning(f"No data array in response for tag {tag_name}")
223
- return pd.DataFrame()
161
+ return pd.DataFrame(), ""
224
162
 
225
163
  # Get first element (should contain samples)
226
164
  tag_data = data[0] if len(data) > 0 else {}
227
165
 
166
+ # Extract description if available (from IP_DESCRIPTION field)
167
+ description = ""
168
+ if "l" in tag_data and isinstance(tag_data["l"], list) and len(tag_data["l"]) > 0:
169
+ # "l" contains list of field values, IP_DESCRIPTION is second field if requested
170
+ fields = tag_data["l"]
171
+ if len(fields) > 1:
172
+ description = fields[1] if fields[1] is not None else ""
173
+
228
174
  # Check for errors in samples
229
175
  samples = tag_data.get("samples", [])
230
176
  if samples and isinstance(samples, list) and len(samples) > 0:
@@ -233,11 +179,11 @@ class AspenClient:
233
179
  if "er" in first_sample and first_sample.get("er", 0) != 0:
234
180
  error_msg = first_sample.get("es", "Unknown error")
235
181
  logger.warning(f"API error for tag {tag_name}: {error_msg}")
236
- return pd.DataFrame()
182
+ return pd.DataFrame(), description
237
183
 
238
184
  if not samples:
239
185
  logger.warning(f"No samples found for tag {tag_name}")
240
- return pd.DataFrame()
186
+ return pd.DataFrame(), description
241
187
 
242
188
  # Build DataFrame
243
189
  rows = []
@@ -256,18 +202,18 @@ class AspenClient:
256
202
 
257
203
  if not rows:
258
204
  logger.warning(f"No valid data samples for tag {tag_name}")
259
- return pd.DataFrame()
205
+ return pd.DataFrame(), description
260
206
 
261
207
  df = pd.DataFrame(rows)
262
208
  if not df.empty:
263
209
  df = df.set_index("time")
264
210
 
265
- return df
211
+ return df, description
266
212
 
267
213
  except Exception as e:
268
214
  logger.error(f"Error parsing response for tag {tag_name}: {e}")
269
215
  logger.debug(f"Response was: {response}")
270
- return pd.DataFrame()
216
+ return pd.DataFrame(), ""
271
217
 
272
218
  @retry(wait=wait_exponential(multiplier=0.5, min=0.5, max=8), stop=stop_after_attempt(3))
273
219
  def _fetch(self, xml_query: str) -> dict:
@@ -323,7 +269,9 @@ class AspenClient:
323
269
  read_type: ReaderType = ReaderType.RAW,
324
270
  include_status: bool = True,
325
271
  max_rows: int = 100000,
326
- ) -> pd.DataFrame:
272
+ as_json: bool = False,
273
+ with_description: bool = False,
274
+ ) -> pd.DataFrame | list[dict]:
327
275
  """Read historical data for multiple tags.
328
276
 
329
277
  Args:
@@ -334,12 +282,21 @@ class AspenClient:
334
282
  read_type: Type of data retrieval (RAW, INT, SNAPSHOT, AVG)
335
283
  include_status: Include status column in output (default: True)
336
284
  max_rows: Maximum number of rows to return per tag (default: 100000)
285
+ as_json: Return data as list of dictionaries instead of DataFrame (default: False)
286
+ with_description: Include tag descriptions in response (default: False).
287
+ Automatically enabled when as_json=True.
337
288
 
338
289
  Returns:
339
- pandas DataFrame with time index and columns for each tag.
340
- If include_status=True, includes a 'status' column.
290
+ If as_json=False: pandas DataFrame with time index and columns for each tag.
291
+ If include_status=True, includes a 'status' column.
292
+ If as_json=True: List of dictionaries, each containing:
293
+ - timestamp: ISO format timestamp string
294
+ - tag: Tag name
295
+ - description: Tag description (from IP_DESCRIPTION field)
296
+ - value: Tag value
341
297
 
342
298
  Example:
299
+ >>> # DataFrame output (default)
343
300
  >>> client = AspenClient("https://aspen.example.com/ProcessData")
344
301
  >>> df = client.read(
345
302
  ... ["ATI111", "AP101.PV"],
@@ -348,13 +305,18 @@ class AspenClient:
348
305
  ... read_type=ReaderType.RAW
349
306
  ... )
350
307
 
351
- >>> # Get more rows if needed
352
- >>> df = client.read(
308
+ >>> # JSON output with descriptions
309
+ >>> data = client.read(
353
310
  ... ["ATI111"],
354
311
  ... start="2025-01-01 00:00:00",
355
- ... end="2025-01-02 00:00:00",
356
- ... max_rows=500000
312
+ ... end="2025-01-01 01:00:00",
313
+ ... as_json=True
357
314
  ... )
315
+ >>> # Returns: [
316
+ >>> # {"timestamp": "2025-01-01T00:00:00", "tag": "ATI111",
317
+ >>> # "description": "Temperature sensor", "value": 25.5},
318
+ >>> # ...
319
+ >>> # ]
358
320
  """
359
321
  tags_list = list(tags)
360
322
  logger.info(f"Reading {len(tags_list)} tag(s) from {start} to {end} (max_rows={max_rows})")
@@ -362,13 +324,14 @@ class AspenClient:
362
324
  logger.debug(f"Reader type: {read_type.value}, Interval: {interval}")
363
325
 
364
326
  frames: list[pd.DataFrame] = []
327
+ tag_descriptions: dict[str, str] = {}
365
328
 
366
329
  # Process each tag individually (Aspen API queries one tag at a time)
367
330
  for tag_idx, tag in enumerate(tags_list, 1):
368
331
  logger.debug(f"Processing tag {tag_idx}/{len(tags_list)}: {tag}")
369
332
 
370
333
  # Generate XML query for this tag
371
- xml_query = self._generate_xml_query(
334
+ xml_query = build_read_query(
372
335
  tag=tag,
373
336
  start=start,
374
337
  end=end,
@@ -376,20 +339,27 @@ class AspenClient:
376
339
  interval=interval,
377
340
  datasource=self.datasource,
378
341
  max_rows=max_rows,
342
+ with_description=with_description or as_json, # Request description if needed
379
343
  )
380
344
 
381
345
  response = self._fetch(xml_query)
382
346
  logger.debug(f"Received response for tag: {tag}")
383
347
 
384
348
  # Parse the Aspen-style response
385
- df = self._parse_aspen_response(response, tag, include_status=include_status)
349
+ df, description = self._parse_aspen_response(
350
+ response, tag, include_status=include_status
351
+ )
386
352
  logger.debug(f"Parsed {len(df)} data points for tag {tag}")
387
353
 
388
354
  if not df.empty:
389
355
  frames.append(df)
356
+ if description:
357
+ tag_descriptions[tag] = description
390
358
 
391
359
  if not frames:
392
360
  logger.warning("No data returned from API")
361
+ if as_json:
362
+ return []
393
363
  return pd.DataFrame()
394
364
 
395
365
  # Merge frames by index (time), combining columns for different tags
@@ -397,6 +367,35 @@ class AspenClient:
397
367
  out = out.sort_index()
398
368
 
399
369
  logger.info(f"Successfully retrieved {len(out)} rows for {len(out.columns)} column(s)")
370
+
371
+ # Convert to JSON format if requested
372
+ if as_json:
373
+ json_data: list[dict] = []
374
+ for idx, row in out.iterrows():
375
+ # Iterate through each tag (column) in this row
376
+ for tag in tags_list:
377
+ if tag in row.index:
378
+ value = row[tag]
379
+ # Skip NaN values - use isinstance check to avoid Series.__bool__ issue
380
+ if isinstance(value, (int, float, str)) and pd.notna(value):
381
+ # idx is pandas Timestamp, which has isoformat method
382
+ # type: ignore is needed because iterrows() returns Hashable for index
383
+ ts = (
384
+ idx.isoformat() # type: ignore[union-attr]
385
+ if hasattr(idx, "isoformat")
386
+ else str(idx)
387
+ )
388
+ json_data.append(
389
+ {
390
+ "timestamp": ts,
391
+ "tag": tag,
392
+ "description": tag_descriptions.get(tag, ""),
393
+ "value": value,
394
+ }
395
+ )
396
+ logger.debug(f"Converted to {len(json_data)} JSON records")
397
+ return json_data
398
+
400
399
  return out
401
400
 
402
401
  def _search_by_sql(
@@ -432,37 +431,12 @@ class AspenClient:
432
431
  "AspenClient(base_url=..., datasource='your_datasource')"
433
432
  )
434
433
 
435
- # Build SQL query - search for description pattern
436
- # Note: SQL LIKE uses % as wildcard, not *
437
- sql_pattern = description.replace("*", "%")
438
- if not sql_pattern.startswith("%"):
439
- sql_pattern = f"%{sql_pattern}%"
440
-
441
- # Build WHERE clause - filter by both name and description in SQL
442
- where_clauses = [f"d like '{sql_pattern}'"]
443
-
444
- # Add name pattern filter if not wildcard
445
- if tag_pattern != "*":
446
- name_pattern = tag_pattern.replace("*", "%").replace("?", "_")
447
- # Add wildcards to name pattern if not already present
448
- if "%" not in name_pattern and "_" not in name_pattern:
449
- name_pattern = f"%{name_pattern}%"
450
- where_clauses.append(f"name like '{name_pattern}'")
451
-
452
- where_clause = " and ".join(where_clauses)
453
-
454
- sql_query = (
455
- f"Select name, name->ip_description d, name->ip_input_value "
456
- f"from all_records where {where_clause}"
457
- )
458
-
459
- # Build XML request for SQL endpoint
460
- xml = (
461
- f'<SQL g="aspy21_search" t="SQLplus" ds="{self.datasource}" '
462
- f'dso="CHARINT=N;CHARFLOAT=N;CHARTIME=N;CONVERTERRORS=N" '
463
- f'm="{max_results}" to="30" response="Original" s="1">'
464
- f"<![CDATA[{sql_query}]]>"
465
- f"</SQL>"
434
+ # Build XML query for SQL endpoint
435
+ xml = build_sql_search_query(
436
+ datasource=self.datasource,
437
+ description=description,
438
+ tag_pattern=tag_pattern,
439
+ max_results=max_results,
466
440
  )
467
441
 
468
442
  logger.debug(f"SQL query XML: {xml}")
@@ -0,0 +1,130 @@
1
+ """XML query generation for Aspen InfoPlus.21 REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+
7
+ from .models import ReaderType
8
+
9
+
10
+ def build_read_query(
11
+ tag: str,
12
+ start: str,
13
+ end: str,
14
+ read_type: ReaderType,
15
+ interval: int | None = None,
16
+ datasource: str = "",
17
+ max_rows: int = 100000,
18
+ with_description: bool = False,
19
+ ) -> str:
20
+ """Generate XML query for Aspen REST API data read.
21
+
22
+ Args:
23
+ tag: Tag name
24
+ start: Start timestamp (ISO format)
25
+ end: End timestamp (ISO format)
26
+ read_type: Type of read (RAW, INT, etc.)
27
+ interval: Interval in seconds (for aggregated reads)
28
+ datasource: Data source name (default: IP21)
29
+ max_rows: Maximum number of rows to return
30
+ with_description: Include ip_description field in response
31
+
32
+ Returns:
33
+ XML query string
34
+ """
35
+ # Convert ISO timestamps to milliseconds since epoch
36
+ start_dt = pd.to_datetime(start)
37
+ end_dt = pd.to_datetime(end)
38
+ start_ms = int(start_dt.timestamp() * 1000)
39
+ end_ms = int(end_dt.timestamp() * 1000)
40
+
41
+ # Map ReaderType to Aspen RT codes
42
+ rt_map = {
43
+ ReaderType.RAW: 0,
44
+ ReaderType.INT: 1,
45
+ ReaderType.SNAPSHOT: 2, # Will use Attribute endpoint
46
+ ReaderType.AVG: 10,
47
+ }
48
+ rt_code = rt_map.get(read_type, 0)
49
+
50
+ # Build XML query
51
+ xml = '<Q f="d" allQuotes="1">'
52
+ xml += "<Tag>"
53
+ xml += f"<N><![CDATA[{tag}]]></N>"
54
+ # Only include datasource if specified
55
+ if datasource:
56
+ xml += f"<D><![CDATA[{datasource}]]></D>"
57
+ xml += "<F><![CDATA[VAL]]></F>"
58
+
59
+ # Add description field if requested
60
+ if with_description:
61
+ xml += "<F><![CDATA[IP_DESCRIPTION]]></F>"
62
+
63
+ xml += "<HF>0</HF>" # History format: 0=Raw
64
+ xml += f"<St>{start_ms}</St>"
65
+ xml += f"<Et>{end_ms}</Et>"
66
+ xml += f"<RT>{rt_code}</RT>"
67
+ xml += f"<X>{max_rows}</X>"
68
+
69
+ # Add interval for aggregated reads
70
+ if interval and rt_code >= 10:
71
+ xml += f"<P>{interval}</P>"
72
+ xml += "<PU>3</PU>" # Period unit: 3=seconds
73
+
74
+ xml += "</Tag>"
75
+ xml += "</Q>"
76
+
77
+ return xml
78
+
79
+
80
+ def build_sql_search_query(
81
+ datasource: str,
82
+ description: str,
83
+ tag_pattern: str = "*",
84
+ max_results: int = 10000,
85
+ ) -> str:
86
+ """Generate XML query for SQL-based tag search.
87
+
88
+ Args:
89
+ datasource: Aspen datasource name
90
+ description: Description pattern to search for (supports * wildcards)
91
+ tag_pattern: Tag name pattern (supports * and ? wildcards)
92
+ max_results: Maximum number of results
93
+
94
+ Returns:
95
+ XML query string for SQL endpoint
96
+ """
97
+ # Build SQL query - search for description pattern
98
+ # Note: SQL LIKE uses % as wildcard, not *
99
+ sql_pattern = description.replace("*", "%")
100
+ if not sql_pattern.startswith("%"):
101
+ sql_pattern = f"%{sql_pattern}%"
102
+
103
+ # Build WHERE clause - filter by both name and description in SQL
104
+ where_clauses = [f"d like '{sql_pattern}'"]
105
+
106
+ # Add name pattern filter if not wildcard
107
+ if tag_pattern != "*":
108
+ name_pattern = tag_pattern.replace("*", "%").replace("?", "_")
109
+ # Add wildcards to name pattern if not already present
110
+ if "%" not in name_pattern and "_" not in name_pattern:
111
+ name_pattern = f"%{name_pattern}%"
112
+ where_clauses.append(f"name like '{name_pattern}'")
113
+
114
+ where_clause = " and ".join(where_clauses)
115
+
116
+ sql_query = (
117
+ f"Select name, name->ip_description d, name->ip_input_value "
118
+ f"from all_records where {where_clause}"
119
+ )
120
+
121
+ # Build XML request for SQL endpoint
122
+ xml = (
123
+ f'<SQL g="aspy21_search" t="SQLplus" ds="{datasource}" '
124
+ f'dso="CHARINT=N;CHARFLOAT=N;CHARTIME=N;CONVERTERRORS=N" '
125
+ f'm="{max_results}" to="30" response="Original" s="1">'
126
+ f"<![CDATA[{sql_query}]]>"
127
+ f"</SQL>"
128
+ )
129
+
130
+ return xml
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aspy21
3
- Version: 0.2.0b0
3
+ Version: 0.2.0b2
4
4
  Summary: Modern Python client for Aspen InfoPlus.21 (IP.21) with pandas DataFrame output, tag search, and automatic retry logic
5
5
  Author: bazdalaz
6
6
  Maintainer-email: bazdalaz <bazdalaz@users.noreply.github.com>
@@ -23,6 +23,7 @@ src/aspy21/_version.py
23
23
  src/aspy21/cache.py
24
24
  src/aspy21/client.py
25
25
  src/aspy21/models.py
26
+ src/aspy21/query_builder.py
26
27
  src/aspy21/utils.py
27
28
  src/aspy21.egg-info/PKG-INFO
28
29
  src/aspy21.egg-info/SOURCES.txt
@@ -655,3 +655,232 @@ def test_context_manager_returns_self():
655
655
  assert returned is client
656
656
 
657
657
  client.close()
658
+
659
+
660
+ def test_read_as_json_basic(mock_api):
661
+ """Test reading data with as_json=True returns list of dictionaries."""
662
+ mock_api.post("https://aspen.local/ProcessData/AtProcessDataREST.dll").mock(
663
+ return_value=httpx.Response(
664
+ 200,
665
+ json={
666
+ "data": [
667
+ {
668
+ "l": ["VAL", "Temperature sensor"], # Field values including description
669
+ "samples": [
670
+ {"t": 1704110400000, "v": 25.5, "s": 8}, # 2024-01-01 12:00:00
671
+ {"t": 1704114000000, "v": 26.0, "s": 8}, # 2024-01-01 13:00:00
672
+ ],
673
+ }
674
+ ]
675
+ },
676
+ )
677
+ )
678
+
679
+ with AspenClient(
680
+ base_url="https://aspen.local/ProcessData/AtProcessDataREST.dll",
681
+ timeout=2,
682
+ verify_ssl=False,
683
+ ) as client:
684
+ data = client.read(
685
+ tags=["TAG1"],
686
+ start="2024-01-01 12:00:00",
687
+ end="2024-01-01 13:00:00",
688
+ read_type=ReaderType.RAW,
689
+ as_json=True,
690
+ )
691
+
692
+ # Verify it's a list of dicts
693
+ assert isinstance(data, list)
694
+ assert len(data) == 2
695
+
696
+ # Verify structure of first record
697
+ assert "timestamp" in data[0]
698
+ assert "tag" in data[0]
699
+ assert "description" in data[0]
700
+ assert "value" in data[0]
701
+
702
+ # Verify values
703
+ assert data[0]["tag"] == "TAG1"
704
+ assert data[0]["description"] == "Temperature sensor"
705
+ assert data[0]["value"] == 25.5
706
+ assert isinstance(data[0]["timestamp"], str)
707
+
708
+ assert data[1]["value"] == 26.0
709
+
710
+
711
+ def test_read_with_description_dataframe(mock_api):
712
+ """Test reading data with with_description=True still returns DataFrame."""
713
+ route = mock_api.post("https://aspen.local/ProcessData/AtProcessDataREST.dll")
714
+ route.mock(
715
+ return_value=httpx.Response(
716
+ 200,
717
+ json={
718
+ "data": [
719
+ {
720
+ "l": ["VAL", "Pressure sensor"], # Field values including description
721
+ "samples": [
722
+ {"t": 1704110400000, "v": 101.3, "s": 8},
723
+ ],
724
+ }
725
+ ]
726
+ },
727
+ )
728
+ )
729
+
730
+ with AspenClient(
731
+ base_url="https://aspen.local/ProcessData/AtProcessDataREST.dll",
732
+ timeout=2,
733
+ verify_ssl=False,
734
+ ) as client:
735
+ df = client.read(
736
+ tags=["PRESS1"],
737
+ start="2024-01-01 12:00:00",
738
+ end="2024-01-01 13:00:00",
739
+ read_type=ReaderType.RAW,
740
+ with_description=True,
741
+ )
742
+
743
+ # Should still return DataFrame when as_json=False
744
+ assert isinstance(df, pd.DataFrame)
745
+ assert not df.empty
746
+ assert "PRESS1" in df.columns
747
+
748
+ # Verify IP_DESCRIPTION field was requested in XML
749
+ request = route.calls.last.request
750
+ request_body = request.content.decode("utf-8")
751
+ assert "<F><![CDATA[IP_DESCRIPTION]]></F>" in request_body
752
+
753
+
754
+ def test_read_as_json_multiple_tags(mock_api):
755
+ """Test as_json with multiple tags."""
756
+
757
+ def response_handler(request):
758
+ # Return different data based on which tag is requested
759
+ body = request.content.decode("utf-8")
760
+ if "TAG1" in body:
761
+ return httpx.Response(
762
+ 200,
763
+ json={
764
+ "data": [
765
+ {
766
+ "l": ["VAL", "Temperature sensor"],
767
+ "samples": [
768
+ {"t": 1704110400000, "v": 25.5, "s": 8},
769
+ ],
770
+ }
771
+ ]
772
+ },
773
+ )
774
+ else: # TAG2
775
+ return httpx.Response(
776
+ 200,
777
+ json={
778
+ "data": [
779
+ {
780
+ "l": ["VAL", "Pressure sensor"],
781
+ "samples": [
782
+ {"t": 1704110400000, "v": 101.3, "s": 8},
783
+ ],
784
+ }
785
+ ]
786
+ },
787
+ )
788
+
789
+ mock_api.post("https://aspen.local/ProcessData/AtProcessDataREST.dll").mock(
790
+ side_effect=response_handler
791
+ )
792
+
793
+ with AspenClient(
794
+ base_url="https://aspen.local/ProcessData/AtProcessDataREST.dll",
795
+ timeout=2,
796
+ verify_ssl=False,
797
+ ) as client:
798
+ data = client.read(
799
+ tags=["TAG1", "TAG2"],
800
+ start="2024-01-01 12:00:00",
801
+ end="2024-01-01 13:00:00",
802
+ read_type=ReaderType.RAW,
803
+ as_json=True,
804
+ )
805
+
806
+ # Should have 2 records (one per tag, same timestamp)
807
+ assert isinstance(data, list)
808
+ assert len(data) == 2
809
+
810
+ # Verify both tags are present
811
+ tags = {record["tag"] for record in data}
812
+ assert tags == {"TAG1", "TAG2"}
813
+
814
+ # Verify descriptions
815
+ tag1_record = next(r for r in data if r["tag"] == "TAG1")
816
+ tag2_record = next(r for r in data if r["tag"] == "TAG2")
817
+
818
+ assert tag1_record["description"] == "Temperature sensor"
819
+ assert tag1_record["value"] == 25.5
820
+
821
+ assert tag2_record["description"] == "Pressure sensor"
822
+ assert tag2_record["value"] == 101.3
823
+
824
+
825
+ def test_read_as_json_empty_response(mock_api):
826
+ """Test as_json=True with empty response returns empty list."""
827
+ mock_api.post("https://aspen.local/ProcessData/AtProcessDataREST.dll").mock(
828
+ return_value=httpx.Response(
829
+ 200,
830
+ json={"data": [{"samples": []}]},
831
+ )
832
+ )
833
+
834
+ with AspenClient(
835
+ base_url="https://aspen.local/ProcessData/AtProcessDataREST.dll",
836
+ timeout=2,
837
+ verify_ssl=False,
838
+ ) as client:
839
+ data = client.read(
840
+ tags=["TAG1"],
841
+ start="2024-01-01 12:00:00",
842
+ end="2024-01-01 13:00:00",
843
+ read_type=ReaderType.RAW,
844
+ as_json=True,
845
+ )
846
+
847
+ # Should return empty list
848
+ assert isinstance(data, list)
849
+ assert len(data) == 0
850
+
851
+
852
+ def test_read_as_json_without_description(mock_api):
853
+ """Test as_json=True when description is not available in response."""
854
+ mock_api.post("https://aspen.local/ProcessData/AtProcessDataREST.dll").mock(
855
+ return_value=httpx.Response(
856
+ 200,
857
+ json={
858
+ "data": [
859
+ {
860
+ # No "l" field - description not available
861
+ "samples": [
862
+ {"t": 1704110400000, "v": 25.5, "s": 8},
863
+ ],
864
+ }
865
+ ]
866
+ },
867
+ )
868
+ )
869
+
870
+ with AspenClient(
871
+ base_url="https://aspen.local/ProcessData/AtProcessDataREST.dll",
872
+ timeout=2,
873
+ verify_ssl=False,
874
+ ) as client:
875
+ data = client.read(
876
+ tags=["TAG1"],
877
+ start="2024-01-01 12:00:00",
878
+ end="2024-01-01 13:00:00",
879
+ read_type=ReaderType.RAW,
880
+ as_json=True,
881
+ )
882
+
883
+ # Should still work, but description should be empty string
884
+ assert len(data) == 1
885
+ assert data[0]["description"] == ""
886
+ assert data[0]["value"] == 25.5
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
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
File without changes
File without changes