aspy21 0.1.1b8__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.
aspy21/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """
2
+ aspy21 - Modern Python client for Aspen InfoPlus.21 (IP.21).
3
+
4
+ A high-performance REST API client for accessing Aspen process data
5
+ with pandas DataFrame output and flexible batching.
6
+ """
7
+
8
+ from .client import AspenClient, configure_logging
9
+ from .models import ReaderType
10
+
11
+ try:
12
+ from ._version import __version__ # type: ignore[import-not-found]
13
+ except ImportError:
14
+ # Development mode - no version file generated yet
15
+ __version__ = "0.0.0.dev0"
16
+
17
+ __all__ = ["AspenClient", "ReaderType", "configure_logging", "__version__"]
aspy21/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.1b8'
32
+ __version_tuple__ = version_tuple = (0, 1, 1, 'b8')
33
+
34
+ __commit_id__ = commit_id = None
aspy21/cache.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+
6
+
7
+ class SmartCache:
8
+ def __init__(self, db_path: Path):
9
+ self.db_path = Path(db_path)
10
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
11
+ self._init()
12
+
13
+ def _init(self) -> None:
14
+ with sqlite3.connect(self.db_path) as conn:
15
+ conn.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value BLOB)")
16
+ conn.execute("""
17
+ CREATE TABLE IF NOT EXISTS hist (
18
+ tag TEXT,
19
+ ts TEXT,
20
+ value BLOB,
21
+ status INTEGER,
22
+ PRIMARY KEY (tag, ts)
23
+ )
24
+ """)
25
+
26
+ def put_meta(self, key: str, value: bytes) -> None:
27
+ with sqlite3.connect(self.db_path) as conn:
28
+ conn.execute("REPLACE INTO meta (key, value) VALUES (?,?)", (key, value))
29
+
30
+ def get_meta(self, key: str) -> bytes | None:
31
+ with sqlite3.connect(self.db_path) as conn:
32
+ cur = conn.execute("SELECT value FROM meta WHERE key=?", (key,))
33
+ row = cur.fetchone()
34
+ return row[0] if row else None
aspy21/client.py ADDED
@@ -0,0 +1,684 @@
1
+ """Main client for interacting with Aspen InfoPlus.21 REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from collections.abc import Iterable
8
+ from typing import TYPE_CHECKING
9
+
10
+ import httpx
11
+ import pandas as pd
12
+ from tenacity import retry, stop_after_attempt, wait_exponential
13
+
14
+ from .models import ReaderType
15
+
16
+ if TYPE_CHECKING:
17
+ from httpx import Auth
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def configure_logging(level: str | None = None) -> None:
23
+ """Configure logging level for aspy21 library.
24
+
25
+ Args:
26
+ level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
27
+ If None, reads from ASPEN_LOG_LEVEL environment variable.
28
+ Defaults to WARNING if not set.
29
+ """
30
+ if level is None:
31
+ level = os.getenv("ASPEN_LOG_LEVEL", "WARNING")
32
+
33
+ numeric_level = getattr(logging, level.upper(), logging.WARNING)
34
+ logger.setLevel(numeric_level)
35
+
36
+ # Only add handler if logger doesn't have one already
37
+ if not logger.handlers:
38
+ handler = logging.StreamHandler()
39
+ formatter = logging.Formatter(
40
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
41
+ )
42
+ handler.setFormatter(formatter)
43
+ logger.addHandler(handler)
44
+
45
+
46
+ class AspenClient:
47
+ """Client for Aspen InfoPlus.21 REST API.
48
+
49
+ Provides methods to read historical and real-time process data from
50
+ Aspen IP.21 historian via REST API with automatic batching, retries,
51
+ and pandas DataFrame output.
52
+
53
+ Supports context manager protocol for automatic resource cleanup.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ base_url: str,
59
+ *,
60
+ auth: Auth | tuple[str, str] | None = None,
61
+ timeout: float = 30.0,
62
+ verify_ssl: bool = True,
63
+ datasource: str | None = None,
64
+ ) -> None:
65
+ """Initialize the Aspen client.
66
+
67
+ Args:
68
+ base_url: Base URL of the Aspen ProcessData REST API
69
+ auth: Authentication as (username, password) tuple or httpx Auth object.
70
+ If None, no authentication is used.
71
+ timeout: Request timeout in seconds (default: 30.0)
72
+ verify_ssl: Whether to verify SSL certificates (default: True)
73
+ datasource: Aspen datasource name. If None, uses server default.
74
+
75
+ Example:
76
+ Using context manager with authentication:
77
+ >>> with AspenClient(
78
+ ... base_url="https://aspen.example.com/ProcessData",
79
+ ... auth=("user", "pass")
80
+ ... ) as client:
81
+ ... df = client.read(["TAG1"], "2025-01-01", "2025-01-02")
82
+
83
+ Without authentication:
84
+ >>> with AspenClient(base_url="http://aspen.example.com/ProcessData") as client:
85
+ ... df = client.read(["TAG1"], "2025-01-01", "2025-01-02")
86
+
87
+ With datasource for search:
88
+ >>> with AspenClient(
89
+ ... base_url="https://aspen.example.com/ProcessData",
90
+ ... auth=("user", "pass"),
91
+ ... datasource="IP21"
92
+ ... ) as client:
93
+ ... tags = client.search(tag="TEMP*")
94
+ """
95
+ self.base_url = base_url.rstrip("/")
96
+ self.timeout = timeout
97
+ self.verify_ssl = verify_ssl
98
+ self.datasource = datasource or "" # Empty string = use server default
99
+ self.auth = auth
100
+
101
+ self._client = httpx.Client(timeout=timeout, verify=verify_ssl, auth=auth)
102
+
103
+ logger.info(f"Initialized AspenClient for {self.base_url}")
104
+ logger.debug(
105
+ f"Config: timeout={timeout}s, verify_ssl={verify_ssl}, datasource={datasource}"
106
+ )
107
+
108
+ def __enter__(self) -> AspenClient:
109
+ """Enter context manager.
110
+
111
+ Returns:
112
+ self for use in with statement
113
+ """
114
+ return self
115
+
116
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
117
+ """Exit context manager and close connection.
118
+
119
+ Args:
120
+ exc_type: Exception type if an exception occurred
121
+ exc_val: Exception value if an exception occurred
122
+ exc_tb: Exception traceback if an exception occurred
123
+ """
124
+ self.close()
125
+
126
+ def close(self) -> None:
127
+ """Close the HTTP client connection."""
128
+ self._client.close()
129
+
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
+ def _parse_aspen_response(
194
+ self, response: dict, tag_name: str, include_status: bool = True
195
+ ) -> pd.DataFrame:
196
+ """Parse Aspen REST API response into DataFrame.
197
+
198
+ Args:
199
+ response: Response dict from Aspen API
200
+ tag_name: Name of the tag being queried
201
+ include_status: Whether to include status column
202
+
203
+ Returns:
204
+ DataFrame with timestamp index and tag data
205
+
206
+ Aspen API returns:
207
+ {
208
+ "data": [
209
+ {
210
+ "samples": [
211
+ {"t": timestamp_ms, "v": value, "s": status},
212
+ ...
213
+ ]
214
+ }
215
+ ]
216
+ }
217
+ """
218
+ try:
219
+ # Get data array
220
+ data = response.get("data", [])
221
+ if not data or not isinstance(data, list):
222
+ logger.warning(f"No data array in response for tag {tag_name}")
223
+ return pd.DataFrame()
224
+
225
+ # Get first element (should contain samples)
226
+ tag_data = data[0] if len(data) > 0 else {}
227
+
228
+ # Check for errors in samples
229
+ samples = tag_data.get("samples", [])
230
+ if samples and isinstance(samples, list) and len(samples) > 0:
231
+ first_sample = samples[0]
232
+ # Check if first sample contains an error
233
+ if "er" in first_sample and first_sample.get("er", 0) != 0:
234
+ error_msg = first_sample.get("es", "Unknown error")
235
+ logger.warning(f"API error for tag {tag_name}: {error_msg}")
236
+ return pd.DataFrame()
237
+
238
+ if not samples:
239
+ logger.warning(f"No samples found for tag {tag_name}")
240
+ return pd.DataFrame()
241
+
242
+ # Build DataFrame
243
+ rows = []
244
+ for sample in samples:
245
+ # Skip error samples
246
+ if "er" in sample:
247
+ continue
248
+
249
+ row = {
250
+ "time": pd.to_datetime(sample["t"], unit="ms", utc=True).tz_convert(None),
251
+ tag_name: sample.get("v"),
252
+ }
253
+ if include_status:
254
+ row["status"] = sample.get("s", 0)
255
+ rows.append(row)
256
+
257
+ if not rows:
258
+ logger.warning(f"No valid data samples for tag {tag_name}")
259
+ return pd.DataFrame()
260
+
261
+ df = pd.DataFrame(rows)
262
+ if not df.empty:
263
+ df = df.set_index("time")
264
+
265
+ return df
266
+
267
+ except Exception as e:
268
+ logger.error(f"Error parsing response for tag {tag_name}: {e}")
269
+ logger.debug(f"Response was: {response}")
270
+ return pd.DataFrame()
271
+
272
+ @retry(wait=wait_exponential(multiplier=0.5, min=0.5, max=8), stop=stop_after_attempt(3))
273
+ def _fetch(self, xml_query: str) -> dict:
274
+ """Fetch data from API endpoint with automatic retry.
275
+
276
+ Args:
277
+ xml_query: XML query string
278
+
279
+ Returns:
280
+ JSON response as dictionary
281
+
282
+ Raises:
283
+ httpx.HTTPError: If the request fails after retries
284
+ """
285
+ logger.debug(f"POST {self.base_url}")
286
+ logger.debug(f"Query XML: {xml_query}")
287
+
288
+ try:
289
+ # Try sending XML as POST body with correct content type
290
+ r = self._client.post(
291
+ self.base_url, content=xml_query, headers={"Content-Type": "text/xml"}
292
+ )
293
+ logger.debug(f"Response status: {r.status_code}")
294
+ logger.debug(f"Response headers: {dict(r.headers)}")
295
+
296
+ r.raise_for_status()
297
+ response_data = r.json()
298
+
299
+ logger.debug(
300
+ f"Response data keys: {list(response_data.keys()) if response_data else 'empty'}"
301
+ )
302
+ logger.debug(f"Full response: {response_data}") # Show complete response for debugging
303
+
304
+ return response_data
305
+
306
+ except httpx.HTTPStatusError as e:
307
+ logger.error(f"HTTP {e.response.status_code} error for {self.base_url}")
308
+ logger.error(f"Response body: {e.response.text[:500]}")
309
+ raise
310
+ except httpx.RequestError as e:
311
+ logger.error(f"Request error: {type(e).__name__}: {e}")
312
+ raise
313
+ except Exception as e:
314
+ logger.error(f"Unexpected error: {type(e).__name__}: {e}")
315
+ raise
316
+
317
+ def read(
318
+ self,
319
+ tags: Iterable[str],
320
+ start: str,
321
+ end: str,
322
+ interval: int | None = None,
323
+ read_type: ReaderType = ReaderType.RAW,
324
+ include_status: bool = True,
325
+ max_rows: int = 100000,
326
+ ) -> pd.DataFrame:
327
+ """Read historical data for multiple tags.
328
+
329
+ Args:
330
+ tags: List of tag names to retrieve
331
+ start: Start timestamp (ISO format or compatible string)
332
+ end: End timestamp (ISO format or compatible string)
333
+ interval: Optional interval in seconds for aggregated data
334
+ read_type: Type of data retrieval (RAW, INT, SNAPSHOT, AVG)
335
+ include_status: Include status column in output (default: True)
336
+ max_rows: Maximum number of rows to return per tag (default: 100000)
337
+
338
+ Returns:
339
+ pandas DataFrame with time index and columns for each tag.
340
+ If include_status=True, includes a 'status' column.
341
+
342
+ Example:
343
+ >>> client = AspenClient("https://aspen.example.com/ProcessData")
344
+ >>> df = client.read(
345
+ ... ["ATI111", "AP101.PV"],
346
+ ... start="2025-01-01 00:00:00",
347
+ ... end="2025-01-01 01:00:00",
348
+ ... read_type=ReaderType.RAW
349
+ ... )
350
+
351
+ >>> # Get more rows if needed
352
+ >>> df = client.read(
353
+ ... ["ATI111"],
354
+ ... start="2025-01-01 00:00:00",
355
+ ... end="2025-01-02 00:00:00",
356
+ ... max_rows=500000
357
+ ... )
358
+ """
359
+ tags_list = list(tags)
360
+ logger.info(f"Reading {len(tags_list)} tag(s) from {start} to {end} (max_rows={max_rows})")
361
+ logger.debug(f"Tags: {tags_list}")
362
+ logger.debug(f"Reader type: {read_type.value}, Interval: {interval}")
363
+
364
+ frames: list[pd.DataFrame] = []
365
+
366
+ # Process each tag individually (Aspen API queries one tag at a time)
367
+ for tag_idx, tag in enumerate(tags_list, 1):
368
+ logger.debug(f"Processing tag {tag_idx}/{len(tags_list)}: {tag}")
369
+
370
+ # Generate XML query for this tag
371
+ xml_query = self._generate_xml_query(
372
+ tag=tag,
373
+ start=start,
374
+ end=end,
375
+ read_type=read_type,
376
+ interval=interval,
377
+ datasource=self.datasource,
378
+ max_rows=max_rows,
379
+ )
380
+
381
+ response = self._fetch(xml_query)
382
+ logger.debug(f"Received response for tag: {tag}")
383
+
384
+ # Parse the Aspen-style response
385
+ df = self._parse_aspen_response(response, tag, include_status=include_status)
386
+ logger.debug(f"Parsed {len(df)} data points for tag {tag}")
387
+
388
+ if not df.empty:
389
+ frames.append(df)
390
+
391
+ if not frames:
392
+ logger.warning("No data returned from API")
393
+ return pd.DataFrame()
394
+
395
+ # Merge frames by index (time), combining columns for different tags
396
+ out = pd.concat(frames, axis=1)
397
+ out = out.sort_index()
398
+
399
+ logger.info(f"Successfully retrieved {len(out)} rows for {len(out.columns)} column(s)")
400
+ return out
401
+
402
+ def _search_by_sql(
403
+ self,
404
+ description: str,
405
+ tag_pattern: str = "*",
406
+ max_results: int = 10000,
407
+ ) -> list[dict[str, str]]:
408
+ """Search for tags by description using SQL endpoint.
409
+
410
+ This is an internal method that uses the Aspen SQL endpoint to search
411
+ by tag description (ip_description field).
412
+
413
+ Args:
414
+ description: Description pattern to search for (SQL LIKE pattern)
415
+ tag_pattern: Optional tag name pattern to filter by (default: "*")
416
+ max_results: Maximum number of results to return (default: 10000)
417
+
418
+ Returns:
419
+ List of dictionaries with 'name' and 'description' keys
420
+
421
+ Raises:
422
+ ValueError: If datasource is not configured
423
+ """
424
+ if not self.datasource:
425
+ raise ValueError(
426
+ "Datasource is required for SQL search. "
427
+ "Please set datasource when creating AspenClient: "
428
+ "AspenClient(base_url=..., datasource='your_datasource')"
429
+ )
430
+
431
+ # Build SQL query - search for description pattern
432
+ # Note: SQL LIKE uses % as wildcard, not *
433
+ sql_pattern = description.replace("*", "%")
434
+ if not sql_pattern.startswith("%"):
435
+ sql_pattern = f"%{sql_pattern}%"
436
+
437
+ sql_query = (
438
+ f"Select name, name->ip_description d, name->ip_input_value "
439
+ f"from all_records where d like '{sql_pattern}'"
440
+ )
441
+
442
+ # Build XML request for SQL endpoint
443
+ xml = (
444
+ f'<SQL g="aspy21_search" t="SQLplus" ds="{self.datasource}" '
445
+ f'dso="CHARINT=N;CHARFLOAT=N;CHARTIME=N;CONVERTERRORS=N" '
446
+ f'm="{max_results}" to="30" response="Original" s="1">'
447
+ f"<![CDATA[{sql_query}]]>"
448
+ f"</SQL>"
449
+ )
450
+
451
+ logger.debug(f"SQL query XML: {xml}")
452
+
453
+ sql_url = f"{self.base_url}/SQL"
454
+ logger.info(f"SQL request: POST {sql_url}")
455
+
456
+ try:
457
+ response = self._client.post(sql_url, content=xml, headers={"Content-Type": "text/xml"})
458
+
459
+ logger.debug(f"Response status: {response.status_code}")
460
+ response.raise_for_status()
461
+
462
+ data = response.json()
463
+ logger.debug(f"SQL response keys: {list(data.keys())}")
464
+
465
+ # Parse SQL response format
466
+ # Expected: {"data": [{"g": "...", "r": "D", "cols": [...], "rows": [{"fld": [...]}]}]}
467
+ if "data" not in data or not isinstance(data["data"], list):
468
+ logger.warning("Unexpected SQL response structure")
469
+ logger.debug(f"Full response: {data}")
470
+ return []
471
+
472
+ data_array = data["data"]
473
+ if not data_array or len(data_array) == 0:
474
+ logger.info("SQL search returned 0 results")
475
+ return []
476
+
477
+ # Get first result set
478
+ result_set = data_array[0]
479
+ if not isinstance(result_set, dict):
480
+ logger.warning("Unexpected result set structure")
481
+ return []
482
+
483
+ # Check for errors
484
+ if "result" in result_set:
485
+ result = result_set["result"]
486
+ if isinstance(result, dict) and result.get("er", 0) != 0:
487
+ error_msg = result.get("es", "Unknown error")
488
+ logger.error(f"API error from SQL endpoint: {error_msg}")
489
+ raise ValueError(f"SQL API error: {error_msg}")
490
+
491
+ # Get rows array
492
+ rows = result_set.get("rows", [])
493
+ if not rows:
494
+ logger.info("SQL search returned 0 results")
495
+ return []
496
+
497
+ logger.debug(f"Found {len(rows)} rows in SQL response")
498
+
499
+ results = []
500
+ for row in rows:
501
+ if not isinstance(row, dict) or "fld" not in row:
502
+ continue
503
+
504
+ # Extract field values: fld is array of {"i": index, "v": value}
505
+ fields = row["fld"]
506
+ if len(fields) < 2:
507
+ continue
508
+
509
+ tag_name = fields[0].get("v", "")
510
+ tag_desc = fields[1].get("v", "")
511
+
512
+ # Apply tag pattern filter if specified
513
+ if tag_pattern != "*":
514
+ # Convert wildcard pattern to regex for matching
515
+ import re
516
+
517
+ regex_pattern = tag_pattern.replace("*", ".*").replace("?", ".")
518
+ if not re.match(f"^{regex_pattern}$", tag_name, re.IGNORECASE):
519
+ continue
520
+
521
+ results.append({"name": tag_name, "description": tag_desc})
522
+
523
+ logger.info(f"Found {len(results)} matching tags via SQL")
524
+ return results[:max_results]
525
+
526
+ except Exception as e:
527
+ logger.error(f"Error in SQL search: {type(e).__name__}: {e}")
528
+ raise
529
+
530
+ def search(
531
+ self,
532
+ tag: str = "*",
533
+ description: str | None = None,
534
+ case_sensitive: bool = False,
535
+ max_results: int = 10000,
536
+ ) -> list[dict[str, str]]:
537
+ """Search for tags by name pattern and/or description.
538
+
539
+ Supports wildcards:
540
+ - '*' matches any number of characters
541
+ - '?' matches exactly one character
542
+
543
+ When description parameter is provided, uses SQL endpoint for more
544
+ efficient server-side filtering. Otherwise uses Browse endpoint.
545
+
546
+ Args:
547
+ tag: Tag name pattern with wildcards (e.g., "TEMP*", "?AI_10?", "*" for all tags).
548
+ Defaults to "*" (all tags).
549
+ description: Description pattern to filter by (case-insensitive substring match).
550
+ When provided, uses SQL endpoint for server-side search.
551
+ case_sensitive: Whether tag name matching should be case-sensitive (default: False).
552
+ Only applies to Browse endpoint (tag-only search).
553
+ max_results: Maximum number of results to return (default: 10000)
554
+
555
+ Returns:
556
+ List of dictionaries with 'name' and 'description' keys
557
+
558
+ Raises:
559
+ ValueError: If datasource is not configured
560
+
561
+ Example:
562
+ >>> # Find all temperature tags
563
+ >>> tags = client.search(tag="TEMP*")
564
+ >>>
565
+ >>> # Find all tags
566
+ >>> tags = client.search(tag="*")
567
+ >>>
568
+ >>> # Search by description only
569
+ >>> tags = client.search(description="reactor")
570
+ >>>
571
+ >>> # Combine name and description
572
+ >>> tags = client.search(tag="*AI*", description="reactor")
573
+ >>>
574
+ >>> for tag in tags:
575
+ ... print(f"{tag['name']}: {tag['description']}")
576
+ """
577
+ import urllib.parse
578
+
579
+ if not self.datasource:
580
+ raise ValueError(
581
+ "Datasource is required for search. "
582
+ "Please set datasource when creating AspenClient: "
583
+ "AspenClient(base_url=..., datasource='your_datasource')"
584
+ )
585
+
586
+ logger.info(f"Searching tags: pattern={tag}, description={description}")
587
+
588
+ # If description is provided, use SQL endpoint for efficient server-side search
589
+ if description:
590
+ return self._search_by_sql(
591
+ description=description, tag_pattern=tag, max_results=max_results
592
+ )
593
+
594
+ # Otherwise, use Browse endpoint for tag name search
595
+ # Initialize results list
596
+ results: list[dict[str, str]] = []
597
+
598
+ # Build query parameters
599
+ # Note: The key is "dataSource" (camelCase)
600
+ params = {
601
+ "dataSource": self.datasource,
602
+ "tag": tag,
603
+ "max": max_results,
604
+ "getTrendable": 0,
605
+ }
606
+
607
+ # Construct Browse endpoint URL with manually encoded query string
608
+ # We need to keep * unencoded for wildcard matching
609
+ # Following tagreader's approach: encode with safe="*"
610
+ encoded_params = urllib.parse.urlencode(params, safe="*", quote_via=urllib.parse.quote)
611
+ browse_url = f"{self.base_url}/Browse?{encoded_params}"
612
+
613
+ logger.info(f"Browse request: GET {browse_url}")
614
+ logger.debug(f"Query params: {params}")
615
+
616
+ try:
617
+ # Make GET request to Browse endpoint
618
+ # URL already contains query string, so don't pass params again
619
+ response = self._client.get(browse_url)
620
+
621
+ # Log the actual request details
622
+ logger.debug(f"Request URL: {response.request.url}")
623
+ logger.debug(f"Request method: {response.request.method}")
624
+ logger.debug(f"Response status: {response.status_code}")
625
+
626
+ response.raise_for_status()
627
+ data = response.json()
628
+
629
+ logger.debug(f"Browse response keys: {list(data.keys())}")
630
+
631
+ # Check for API error response
632
+ if "data" in data and isinstance(data["data"], dict):
633
+ data_obj = data["data"]
634
+ logger.debug(f"data['data'] keys: {list(data_obj.keys())}")
635
+
636
+ # Check for error in result
637
+ if "result" in data_obj:
638
+ result = data_obj["result"]
639
+ if isinstance(result, dict) and result.get("er", 0) != 0:
640
+ error_msg = result.get("es", "Unknown error")
641
+ logger.error(f"API error from Browse endpoint: {error_msg}")
642
+ raise ValueError(f"Browse API error: {error_msg}")
643
+
644
+ # Check if tags key exists
645
+ if "tags" not in data_obj:
646
+ logger.warning("No 'tags' key in response - search returned no results")
647
+ logger.debug(f"Full response: {data}")
648
+ return []
649
+
650
+ tags_data = data_obj["tags"]
651
+ else:
652
+ logger.error(f"Unexpected response structure: {data}")
653
+ return []
654
+
655
+ logger.debug(f"tags_data type: {type(tags_data)}, length: {len(tags_data)}")
656
+
657
+ if not tags_data:
658
+ logger.info("Search returned 0 tags")
659
+ return []
660
+
661
+ for tag_entry in tags_data:
662
+ tag_name = tag_entry.get("t", "")
663
+ # Response contains "m" (map type) like "IP_ANALOGMAP", "KPI_VALUEMAP"
664
+ # and optionally "n" (description). Use "n" if available, otherwise "m"
665
+ tag_desc = tag_entry.get("n", tag_entry.get("m", ""))
666
+
667
+ # Apply case-insensitive filtering if needed
668
+ if (
669
+ not case_sensitive
670
+ and tag
671
+ and "*" not in tag
672
+ and "?" not in tag
673
+ and tag.lower() not in tag_name.lower()
674
+ ):
675
+ continue
676
+
677
+ results.append({"name": tag_name, "description": tag_desc})
678
+
679
+ logger.info(f"Found {len(results)} matching tags")
680
+ return results[:max_results]
681
+
682
+ except Exception as e:
683
+ logger.error(f"Error searching tags: {type(e).__name__}: {e}")
684
+ raise
aspy21/models.py ADDED
@@ -0,0 +1,19 @@
1
+ """Data models for Aspen InfoPlus.21 client."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class ReaderType(str, Enum):
7
+ """Reader types for Aspen IP.21 data retrieval.
8
+
9
+ Attributes:
10
+ RAW: Raw data points as stored in the historian
11
+ INT: Interpolated values at specified intervals
12
+ SNAPSHOT: Current snapshot of tag values
13
+ AVG: Average values over specified intervals
14
+ """
15
+
16
+ RAW = "RAW"
17
+ INT = "INT"
18
+ SNAPSHOT = "SNAPSHOT"
19
+ AVG = "AVG"
aspy21/utils.py ADDED
@@ -0,0 +1,27 @@
1
+ """Utility functions for the Aspen client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from itertools import islice
7
+ from typing import TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ def chunked(iterable: Iterable[T], size: int) -> Iterable[list[T]]:
13
+ """Split an iterable into chunks of specified size.
14
+
15
+ Args:
16
+ iterable: The input iterable to be chunked
17
+ size: Maximum size of each chunk
18
+
19
+ Yields:
20
+ Lists of items, each containing up to 'size' elements
21
+ """
22
+ it = iter(iterable)
23
+ while True:
24
+ batch = list(islice(it, size))
25
+ if not batch:
26
+ return
27
+ yield batch
@@ -0,0 +1,358 @@
1
+ Metadata-Version: 2.4
2
+ Name: aspy21
3
+ Version: 0.1.1b8
4
+ Summary: Modern Python client for Aspen InfoPlus.21 (IP.21) with pandas DataFrame output, tag search, and automatic retry logic
5
+ Author: bazdalaz
6
+ Maintainer-email: bazdalaz <bazdalaz@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/bazdalaz/aspy21
9
+ Project-URL: Repository, https://github.com/bazdalaz/aspy21
10
+ Project-URL: Issues, https://github.com/bazdalaz/aspy21/issues
11
+ Keywords: aspen,ip21,historian,process data,industrial,ot,scada,aspentech
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Manufacturing
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: System :: Monitoring
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Operating System :: OS Independent
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.9
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Requires-Dist: httpx>=0.27
31
+ Requires-Dist: pandas>=2.0
32
+ Requires-Dist: tenacity>=9.0
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest>=8.2; extra == "dev"
35
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
36
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
37
+ Requires-Dist: respx>=0.21; extra == "dev"
38
+ Requires-Dist: ruff>=0.8; extra == "dev"
39
+ Requires-Dist: pyright>=1.1.389; extra == "dev"
40
+ Requires-Dist: types-python-dateutil; extra == "dev"
41
+ Requires-Dist: pre-commit>=3.5; extra == "dev"
42
+ Requires-Dist: build>=1.0; extra == "dev"
43
+ Requires-Dist: twine>=5.0; extra == "dev"
44
+ Provides-Extra: examples
45
+ Requires-Dist: python-dotenv>=1.0; extra == "examples"
46
+ Dynamic: license-file
47
+
48
+
49
+ # Aspy21
50
+
51
+ **Python Client for Aspen InfoPlus.21 (IP.21)**
52
+
53
+ [![PyPI version](https://img.shields.io/pypi/v/aspy21.svg)](https://pypi.org/project/aspy21/)
54
+ [![Python versions](https://img.shields.io/pypi/pyversions/aspy21.svg)](https://pypi.org/project/aspy21/)
55
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
56
+ [![Tests](https://github.com/bazdalaz/aspy21/actions/workflows/tests.yml/badge.svg)](https://github.com/bazdalaz/aspy21/actions)
57
+
58
+ > **Note**: This is an independent, unofficial client library. Not affiliated with AspenTech.
59
+
60
+ ---
61
+
62
+ ## Overview
63
+
64
+ **aspy21** is a modern, high-performance Python client for Aspen InfoPlus.21 (IP.21) built on the AspenOne ProcessData REST API. It provides unified access to process historian data with pandas DataFrame output, automatic batching, and intelligent retry logic.
65
+
66
+ ### Key Capabilities
67
+
68
+ - REST-based communication with Aspen IP.21 historian
69
+ - Basic HTTP authentication (cross-platform compatible)
70
+ - Tag search with wildcards and description filtering
71
+ - Unified interface for analog, discrete, and text tags
72
+ - Support for RAW, INT, SNAPSHOT, and AVG reader types
73
+ - Pandas DataFrame output with optional status columns
74
+ - Configurable row limits and query parameters
75
+ - Built-in retry logic with exponential backoff
76
+ - Type-annotated and fully tested
77
+
78
+ ### Use Cases
79
+
80
+ - Industrial data analysis and reporting
81
+ - Integration with data analytics pipelines
82
+ - Process monitoring and dashboard development
83
+ - Time-series data extraction and transformation
84
+
85
+ ---
86
+
87
+ ## Installation
88
+
89
+ Install via pip:
90
+
91
+ ```bash
92
+ pip install aspy21
93
+ ```
94
+
95
+ ### Requirements
96
+
97
+ - Python 3.9+
98
+ - httpx >= 0.27
99
+ - pandas >= 2.0
100
+ - tenacity >= 9.0
101
+
102
+ ---
103
+
104
+ ## Quick Start
105
+
106
+ ### Basic Usage with Context Manager
107
+
108
+ ```python
109
+ from aspy21 import AspenClient, ReaderType
110
+
111
+ # Initialize client using context manager (recommended)
112
+ with AspenClient(
113
+ base_url="https://aspen.myplant.local/ProcessData",
114
+ auth=("user", "password")
115
+ ) as client:
116
+ # Read historical data
117
+ df = client.read(
118
+ tags=["ATI111", "AP101.PV"],
119
+ start="2025-06-20 08:00:00",
120
+ end="2025-06-20 09:00:00",
121
+ interval=600,
122
+ read_type=ReaderType.RAW
123
+ )
124
+
125
+ print(df)
126
+ # Connection automatically closed
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Authentication
132
+
133
+ ### HTTP Basic Authentication
134
+
135
+ ```python
136
+ with AspenClient(
137
+ base_url="https://aspen.example.com/ProcessData",
138
+ auth=("user", "password")
139
+ ) as client:
140
+ # Your code here
141
+ pass
142
+ ```
143
+
144
+ ### No Authentication
145
+
146
+ For public or internal endpoints:
147
+
148
+ ```python
149
+ with AspenClient(
150
+ base_url="http://aspen.example.com/ProcessData"
151
+ ) as client:
152
+ # Your code here
153
+ pass
154
+ ```
155
+
156
+ ---
157
+
158
+ ## API Reference
159
+
160
+ ### AspenClient
161
+
162
+ **Constructor Parameters** (all except `base_url` are keyword-only):
163
+
164
+ | Parameter | Type | Default | Description |
165
+ |-----------|------|---------|-------------|
166
+ | `base_url` | str | required | Base URL of Aspen ProcessData REST API |
167
+ | `auth` | Auth\|tuple\|None | None | Authentication as (username, password) tuple or httpx Auth object |
168
+ | `timeout` | float | 30.0 | Request timeout in seconds |
169
+ | `verify_ssl` | bool | True | Whether to verify SSL certificates |
170
+ | `datasource` | str\|None | None | Aspen datasource name (required for search) |
171
+
172
+ ### read() Method
173
+
174
+ **Parameters**:
175
+
176
+ | Parameter | Type | Default | Description |
177
+ |-----------|------|---------|-------------|
178
+ | `tags` | Iterable[str] | required | List of tag names to retrieve |
179
+ | `start` | str | required | Start timestamp (ISO format) |
180
+ | `end` | str | required | End timestamp (ISO format) |
181
+ | `interval` | int\|None | None | Interval in seconds for aggregated data |
182
+ | `read_type` | ReaderType | RAW | Data retrieval mode |
183
+ | `include_status` | bool | True | Include status column in output |
184
+ | `max_rows` | int | 100000 | Maximum rows to return per tag |
185
+
186
+ **Returns**: pandas.DataFrame with time index and columns for each tag.
187
+
188
+ ### search() Method
189
+
190
+ **Parameters**:
191
+
192
+ | Parameter | Type | Default | Description |
193
+ |-----------|------|---------|-------------|
194
+ | `tag` | str | required | Tag name pattern with wildcards (`*`, `?`). Use `"*"` for all tags. |
195
+ | `description` | str\|None | None | Description filter (case-insensitive substring) |
196
+ | `case_sensitive` | bool | False | Whether tag matching is case-sensitive |
197
+ | `max_results` | int | 10000 | Maximum number of results to return |
198
+
199
+ **Returns**: List of dicts with 'name' and 'description' keys.
200
+
201
+ **Requirements**:
202
+ - `tag` parameter is required
203
+ - `datasource` must be configured in AspenClient
204
+
205
+ **Wildcards**:
206
+ - `*` - Matches any number of characters
207
+ - `?` - Matches exactly one character
208
+
209
+ **Examples**:
210
+ ```python
211
+ # Find all temperature tags
212
+ tags = client.search(tag="TEMP*")
213
+
214
+ # Search all tags by description
215
+ tags = client.search(tag="*", description="reactor")
216
+
217
+ # Combine filters
218
+ tags = client.search(tag="AI_1*", description="pressure")
219
+ ```
220
+
221
+ ### ReaderType Enum
222
+
223
+ Available reader types:
224
+
225
+ - `ReaderType.RAW` - Raw data points as stored
226
+ - `ReaderType.INT` - Interpolated values at intervals
227
+ - `ReaderType.SNAPSHOT` - Current snapshot of tag values
228
+ - `ReaderType.AVG` - Average values over intervals
229
+
230
+ ---
231
+
232
+ ## Configuration
233
+
234
+ ### Connection Settings
235
+
236
+ ```python
237
+ with AspenClient(
238
+ base_url="https://aspen.example.com/ProcessData",
239
+ auth=("user", "password"),
240
+ timeout=60.0, # Request timeout (seconds)
241
+ verify_ssl=True, # SSL certificate verification
242
+ datasource="IP21" # Required for search operations
243
+ ) as client:
244
+ # Your code here
245
+ pass
246
+ ```
247
+
248
+ ### Retry Behavior
249
+
250
+ The client automatically retries failed requests with exponential backoff:
251
+
252
+ - Maximum attempts: 3
253
+ - Initial delay: 0.5 seconds
254
+ - Maximum delay: 8 seconds
255
+
256
+ ---
257
+
258
+ ## Error Handling
259
+
260
+ ```python
261
+ from aspy21 import AspenClient
262
+ import httpx
263
+
264
+ try:
265
+ with AspenClient(
266
+ base_url="https://aspen.example.com/ProcessData",
267
+ auth=("user", "password")
268
+ ) as client:
269
+ df = client.read(
270
+ tags=["ATI111"],
271
+ start="2025-06-20 08:00:00",
272
+ end="2025-06-20 09:00:00"
273
+ )
274
+ except httpx.HTTPStatusError as e:
275
+ print(f"HTTP error: {e.response.status_code}")
276
+ except httpx.RequestError as e:
277
+ print(f"Connection error: {e}")
278
+ ```
279
+
280
+ ---
281
+
282
+ ## Development
283
+
284
+ ### Running Tests
285
+
286
+ ```bash
287
+ # Install development dependencies
288
+ pip install -e ".[dev]"
289
+
290
+ # Run tests
291
+ pytest
292
+
293
+ # Run with coverage
294
+ pytest --cov=src/aspy21 --cov-report=html
295
+ ```
296
+
297
+ ### Type Checking
298
+
299
+ ```bash
300
+ pyright
301
+ ```
302
+
303
+ ### Code Formatting
304
+
305
+ ```bash
306
+ ruff format
307
+ ```
308
+
309
+ ---
310
+
311
+ ## Dependencies
312
+
313
+ ### Core Dependencies
314
+
315
+ - **httpx** (>= 0.27) - HTTP client with async support
316
+ - **pandas** (>= 2.0) - DataFrame output and date parsing
317
+ - **tenacity** (>= 9.0) - Retry logic with exponential backoff
318
+
319
+ ### Development Dependencies
320
+
321
+ - pytest, pytest-cov - Testing framework
322
+ - respx - HTTP mocking for tests
323
+ - ruff - Code formatting and linting
324
+ - pyright - Static type checking
325
+ - pre-commit - Git hooks
326
+
327
+ ---
328
+
329
+ ## License
330
+
331
+ This project is licensed under the MIT License. See [LICENSE](LICENSE) file for details.
332
+
333
+ ---
334
+
335
+ ## Contributing
336
+
337
+ Contributions are welcome. Please ensure:
338
+
339
+ 1. All tests pass (`pytest`)
340
+ 2. Code is formatted (`ruff format`)
341
+ 3. Type checking passes (`pyright`)
342
+ 4. Coverage remains above 75%
343
+
344
+ ---
345
+
346
+ ## Support
347
+
348
+ For issues, questions, or feature requests, please open an issue on [GitHub](https://github.com/bazdalaz/aspy21/issues).
349
+
350
+ ---
351
+
352
+ ## Disclaimer
353
+
354
+ This project is an independent open-source client library and is not affiliated with, endorsed by, or sponsored by AspenTech. "Aspen InfoPlus.21", "IP.21", and "AspenTech" are trademarks or registered trademarks of Aspen Technology, Inc.
355
+
356
+ This software interacts with Aspen InfoPlus.21 systems through their documented REST API endpoints. Users must have appropriate licenses and authorization to access AspenTech systems.
357
+
358
+ Users are responsible for compliance with their AspenTech license agreements and applicable terms of service. This library merely provides a technical interface and does not grant any rights to AspenTech software or services.
@@ -0,0 +1,11 @@
1
+ aspy21/__init__.py,sha256=ClsPhRKAP51Ne01EeSNPxz45pYPKranPX5g5Jlfjmfw,534
2
+ aspy21/_version.py,sha256=Kq5TYovpH5dQ4g5K3qTstRlYHf54jgTfsA0GNDsUyPc,712
3
+ aspy21/cache.py,sha256=pRIaxaI3yNJcOZ7SiLI_KNBRdMqbFq1sDPW7y-ezqtU,1158
4
+ aspy21/client.py,sha256=OP5D7IhWHTWv_kmrBT-1qnE3KijnGNwgBrPswj6AnFs,24941
5
+ aspy21/models.py,sha256=IXHAB2e0xB_lBmWZ7Z0bmSilejFjUzRpDKNJPdRgByk,468
6
+ aspy21/utils.py,sha256=7BFx1O1zGaN3fxqdYC_-vYx91ARDFmSHuxdMblxmmUc,646
7
+ aspy21-0.1.1b8.dist-info/licenses/LICENSE,sha256=N4WQNfHzhpXKz7lrcW0D94maukUHJ8dHgCDpykLvmSI,1065
8
+ aspy21-0.1.1b8.dist-info/METADATA,sha256=Ej_MiU3XmKojzWFZaa0-Ap0wtIgXsk3xKxJ06-3iP7I,10011
9
+ aspy21-0.1.1b8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
+ aspy21-0.1.1b8.dist-info/top_level.txt,sha256=Xc09Tx1mB3tpPre7oO14j05v2BZvjeyvkwYFpy6TMrQ,7
11
+ aspy21-0.1.1b8.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 bazdalaz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ aspy21