aspy21 0.2.0b2__tar.gz → 0.2.0b4__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.0b2 → aspy21-0.2.0b4}/PKG-INFO +1 -1
  2. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/examples/search_and_read.py +47 -14
  3. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/examples/search_tags.py +16 -4
  4. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/_version.py +3 -3
  5. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/client.py +108 -21
  6. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/query_builder.py +51 -0
  7. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21.egg-info/PKG-INFO +1 -1
  8. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/tests/test_client.py +36 -11
  9. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/.env.example +0 -0
  10. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/.github/SETUP_GUIDE.md +0 -0
  11. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/.github/workflows/release.yml +0 -0
  12. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/.github/workflows/tests.yml +0 -0
  13. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/.gitignore +0 -0
  14. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/.pre-commit-config.yaml +0 -0
  15. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/CHANGELOG.md +0 -0
  16. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/CODE_OF_CONDUCT.md +0 -0
  17. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/CONTRIBUTING.md +0 -0
  18. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/LICENSE +0 -0
  19. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/README.md +0 -0
  20. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/SECURITY.md +0 -0
  21. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/docs/_static/logo.png +0 -0
  22. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/examples/README.md +0 -0
  23. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/examples/basic_usage.py +0 -0
  24. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/justfile +0 -0
  25. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/pyproject.toml +0 -0
  26. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/scripts/bump_version.py +0 -0
  27. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/setup.cfg +0 -0
  28. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/__init__.py +0 -0
  29. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/cache.py +0 -0
  30. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/models.py +0 -0
  31. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21/utils.py +0 -0
  32. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21.egg-info/SOURCES.txt +0 -0
  33. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21.egg-info/dependency_links.txt +0 -0
  34. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21.egg-info/requires.txt +0 -0
  35. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/src/aspy21.egg-info/top_level.txt +0 -0
  36. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/tests/__init__.py +0 -0
  37. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/tests/conftest.py +0 -0
  38. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/tests/test_auth.py +0 -0
  39. {aspy21-0.2.0b2 → aspy21-0.2.0b4}/tests/test_logging.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aspy21
3
- Version: 0.2.0b2
3
+ Version: 0.2.0b4
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>
@@ -3,6 +3,7 @@
3
3
  import os
4
4
  from pathlib import Path
5
5
 
6
+ import pandas as pd
6
7
  from dotenv import load_dotenv
7
8
 
8
9
  from aspy21 import AspenClient, ReaderType, configure_logging
@@ -50,17 +51,25 @@ try:
50
51
  print("Searching for temperature tags...")
51
52
 
52
53
  # Get list of tag names matching pattern
53
- tag_names = client.search(tag="TEMP*", return_desc=False, max_results=5)
54
+ tag_names_result = client.search(tag="TEMP*", return_desc=False, max_results=5)
55
+ # Type narrowing: return_desc=False guarantees list[str]
56
+ assert isinstance(tag_names_result, list) and (
57
+ not tag_names_result or isinstance(tag_names_result[0], str)
58
+ )
59
+ tag_names: list[str] = tag_names_result # type: ignore[assignment]
54
60
  print(f"Found {len(tag_names)} tags: {tag_names}")
55
61
 
56
62
  if tag_names:
57
63
  print("\nReading last hour of data...")
58
- df = client.read(
64
+ result = client.read(
59
65
  tags=tag_names,
60
66
  start="2025-01-31 08:00:00",
61
67
  end="2025-01-31 09:00:00",
62
68
  read_type=ReaderType.RAW,
63
69
  )
70
+ # Type narrowing: as_json=False (default) guarantees pd.DataFrame
71
+ assert isinstance(result, pd.DataFrame)
72
+ df: pd.DataFrame = result
64
73
 
65
74
  print(f"Data shape: {df.shape}")
66
75
  print(f"\nFirst few rows:\n{df.head()}")
@@ -72,7 +81,12 @@ try:
72
81
  print("Searching for reactor tags...")
73
82
 
74
83
  # Get tags with descriptions for display/logging
75
- tags = client.search(tag="*", description="reactor", max_results=5)
84
+ tags_result = client.search(tag="*", description="reactor", max_results=5)
85
+ # Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
86
+ assert isinstance(tags_result, list) and (
87
+ not tags_result or isinstance(tags_result[0], dict)
88
+ )
89
+ tags: list[dict[str, str]] = tags_result # type: ignore[assignment]
76
90
 
77
91
  print(f"Found {len(tags)} tags:")
78
92
  for tag in tags:
@@ -80,16 +94,19 @@ try:
80
94
 
81
95
  if tags:
82
96
  # Extract just the names for reading
83
- tag_names = [tag["name"] for tag in tags]
97
+ tag_names_list = [tag["name"] for tag in tags]
84
98
 
85
99
  print("\nReading 10-minute averages...")
86
- df = client.read(
87
- tags=tag_names,
100
+ result = client.read(
101
+ tags=tag_names_list,
88
102
  start="2025-01-31 08:00:00",
89
103
  end="2025-01-31 09:00:00",
90
104
  read_type=ReaderType.AVG,
91
105
  interval=600, # 10 minute averages
92
106
  )
107
+ # Type narrowing: as_json=False (default) guarantees pd.DataFrame
108
+ assert isinstance(result, pd.DataFrame)
109
+ df: pd.DataFrame = result
93
110
 
94
111
  print(f"Data shape: {df.shape}")
95
112
  print(f"\nFirst few rows:\n{df.head()}")
@@ -101,18 +118,26 @@ try:
101
118
  print("Searching for all V1-01 tags...")
102
119
 
103
120
  # Find all tags related to V1-01
104
- tag_names = client.search(description="V1-01", return_desc=False, max_results=10)
105
- print(f"Found {len(tag_names)} tags: {tag_names[:5]}...")
106
-
107
- if tag_names:
121
+ tag_names_result2 = client.search(description="V1-01", return_desc=False, max_results=10)
122
+ # Type narrowing: return_desc=False guarantees list[str]
123
+ assert isinstance(tag_names_result2, list) and (
124
+ not tag_names_result2 or isinstance(tag_names_result2[0], str)
125
+ )
126
+ tag_names_v101: list[str] = tag_names_result2 # type: ignore[assignment]
127
+ print(f"Found {len(tag_names_v101)} tags: {tag_names_v101[:5]}...")
128
+
129
+ if tag_names_v101:
108
130
  print("\nReading hourly averages...")
109
- df = client.read(
110
- tags=tag_names[:5], # Limit to first 5 tags for demo
131
+ result = client.read(
132
+ tags=tag_names_v101[:5], # Limit to first 5 tags for demo
111
133
  start="2025-01-31 00:00:00",
112
134
  end="2025-01-31 12:00:00",
113
135
  read_type=ReaderType.AVG,
114
136
  interval=3600, # 1 hour averages
115
137
  )
138
+ # Type narrowing: as_json=False (default) guarantees pd.DataFrame
139
+ assert isinstance(result, pd.DataFrame)
140
+ df: pd.DataFrame = result
116
141
 
117
142
  print(f"Data shape: {df.shape}")
118
143
  print(f"\nFirst few rows:\n{df.head()}")
@@ -124,7 +149,12 @@ try:
124
149
  print("Searching for all G* tags with V1-01 in description...")
125
150
 
126
151
  # Search broadly
127
- all_tags = client.search(tag="G*", description="V1-01", max_results=20)
152
+ all_tags_result = client.search(tag="G*", description="V1-01", max_results=20)
153
+ # Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
154
+ assert isinstance(all_tags_result, list) and (
155
+ not all_tags_result or isinstance(all_tags_result[0], dict)
156
+ )
157
+ all_tags: list[dict[str, str]] = all_tags_result # type: ignore[assignment]
128
158
  print(f"Found {len(all_tags)} tags total")
129
159
 
130
160
  # Filter to only temperature and pressure tags
@@ -140,12 +170,15 @@ try:
140
170
 
141
171
  if selected_tags:
142
172
  print("\nReading raw data...")
143
- df = client.read(
173
+ result = client.read(
144
174
  tags=selected_tags[:3], # Limit to first 3 for demo
145
175
  start="2025-01-31 08:00:00",
146
176
  end="2025-01-31 09:00:00",
147
177
  read_type=ReaderType.RAW,
148
178
  )
179
+ # Type narrowing: as_json=False (default) guarantees pd.DataFrame
180
+ assert isinstance(result, pd.DataFrame)
181
+ df: pd.DataFrame = result
149
182
 
150
183
  print(f"Data shape: {df.shape}")
151
184
  print(f"\nFirst few rows:\n{df.head()}")
@@ -47,7 +47,10 @@ try:
47
47
  # Example 1: Search by tag name pattern with wildcards
48
48
  print("Example 1: Search all tags starting with 'TEMP'")
49
49
  print("-" * 80)
50
- tags = client.search(tag="TEMP*", max_results=10)
50
+ tags_raw = client.search(tag="TEMP*", max_results=10)
51
+ # Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
52
+ assert isinstance(tags_raw, list) and (not tags_raw or isinstance(tags_raw[0], dict))
53
+ tags: list[dict[str, str]] = tags_raw # type: ignore[assignment]
51
54
  print(f"Found {len(tags)} tags:\n")
52
55
  for tag in tags[:5]: # Show first 5
53
56
  print(f" {tag['name']:30} - {tag['description']}")
@@ -56,7 +59,10 @@ try:
56
59
  # Example 2: Search by description only (uses SQL endpoint)
57
60
  print("Example 2: Search all tags with description containing 'V1-01'")
58
61
  print("-" * 80)
59
- tags = client.search(description="V1-01", max_results=10)
62
+ tags_raw = client.search(description="V1-01", max_results=10)
63
+ # Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
64
+ assert isinstance(tags_raw, list) and (not tags_raw or isinstance(tags_raw[0], dict))
65
+ tags: list[dict[str, str]] = tags_raw # type: ignore[assignment]
60
66
  print(f"Found {len(tags)} tags:\n")
61
67
  for tag in tags[:5]:
62
68
  print(f" {tag['name']:30} - {tag['description']}")
@@ -65,7 +71,10 @@ try:
65
71
  # Example 3: Combine tag pattern and description
66
72
  print("Example 3: Search 'AI*' tags with 'temperature' in description")
67
73
  print("-" * 80)
68
- tags = client.search(tag="AI*", description="temperature", max_results=10)
74
+ tags_raw = client.search(tag="AI*", description="temperature", max_results=10)
75
+ # Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
76
+ assert isinstance(tags_raw, list) and (not tags_raw or isinstance(tags_raw[0], dict))
77
+ tags: list[dict[str, str]] = tags_raw # type: ignore[assignment]
69
78
  print(f"Found {len(tags)} tags:\n")
70
79
  for tag in tags[:5]:
71
80
  print(f" {tag['name']:30} - {tag['description']}")
@@ -83,7 +92,10 @@ try:
83
92
  # Example 5: Case-insensitive search (default)
84
93
  print("Example 5: Case-insensitive search (lowercase query)")
85
94
  print("-" * 80)
86
- tags = client.search(tag="*", description="reactor", max_results=5)
95
+ tags_raw = client.search(tag="*", description="reactor", max_results=5)
96
+ # Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
97
+ assert isinstance(tags_raw, list) and (not tags_raw or isinstance(tags_raw[0], dict))
98
+ tags: list[dict[str, str]] = tags_raw # type: ignore[assignment]
87
99
  print(f"Found {len(tags)} tags (matches 'Reactor', 'REACTOR', etc.):\n")
88
100
  for tag in tags[:5]:
89
101
  print(f" {tag['name']:30} - {tag['description']}")
@@ -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.0b2'
32
- __version_tuple__ = version_tuple = (0, 2, 0, 'b2')
31
+ __version__ = version = '0.2.0b4'
32
+ __version_tuple__ = version_tuple = (0, 2, 0, 'b4')
33
33
 
34
- __commit_id__ = commit_id = 'gfe38a9fe3'
34
+ __commit_id__ = commit_id = 'gb96f5454a'
@@ -12,7 +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
+ from .query_builder import build_history_sql_query, build_read_query, build_sql_search_query
16
16
 
17
17
  if TYPE_CHECKING:
18
18
  from httpx import Auth
@@ -128,6 +128,56 @@ class AspenClient:
128
128
  """Close the HTTP client connection."""
129
129
  self._client.close()
130
130
 
131
+ def _parse_sql_history_response(
132
+ self, response: list[dict], tag_name: str
133
+ ) -> tuple[pd.DataFrame, str]:
134
+ """Parse SQL history response into DataFrame.
135
+
136
+ Args:
137
+ response: SQL response as list of record dictionaries
138
+ tag_name: Name of the tag being queried
139
+
140
+ Returns:
141
+ Tuple of (DataFrame with timestamp index and tag data, tag description)
142
+
143
+ SQL response format:
144
+ [
145
+ {"ts": "2025-11-02T00:00:00.000000Z", "name": "TAG",
146
+ "name->ip_description": "desc", "value": 22.85},
147
+ ...
148
+ ]
149
+ """
150
+ try:
151
+ if not response or not isinstance(response, list):
152
+ logger.warning(f"No data in SQL response for tag {tag_name}")
153
+ return pd.DataFrame(), ""
154
+
155
+ # Extract description from first record if available
156
+ description = ""
157
+ if response and "name->ip_description" in response[0]:
158
+ description = response[0]["name->ip_description"] or ""
159
+
160
+ # Build DataFrame from records
161
+ rows = []
162
+ for record in response:
163
+ timestamp = pd.to_datetime(record["ts"])
164
+ value = record["value"]
165
+ rows.append({"time": timestamp, tag_name: value})
166
+
167
+ if not rows:
168
+ logger.warning(f"No valid data in SQL response for tag {tag_name}")
169
+ return pd.DataFrame(), description
170
+
171
+ df = pd.DataFrame(rows)
172
+ df = df.set_index("time")
173
+
174
+ return df, description
175
+
176
+ except Exception as e:
177
+ logger.error(f"Error parsing SQL response for tag {tag_name}: {e}")
178
+ logger.debug(f"Response was: {response}")
179
+ return pd.DataFrame(), ""
180
+
131
181
  def _parse_aspen_response(
132
182
  self, response: dict, tag_name: str, include_status: bool = True
133
183
  ) -> tuple[pd.DataFrame, str]:
@@ -284,7 +334,7 @@ class AspenClient:
284
334
  max_rows: Maximum number of rows to return per tag (default: 100000)
285
335
  as_json: Return data as list of dictionaries instead of DataFrame (default: False)
286
336
  with_description: Include tag descriptions in response (default: False).
287
- Automatically enabled when as_json=True.
337
+ Note: Some Aspen servers may not support this field.
288
338
 
289
339
  Returns:
290
340
  If as_json=False: pandas DataFrame with time index and columns for each tag.
@@ -326,30 +376,67 @@ class AspenClient:
326
376
  frames: list[pd.DataFrame] = []
327
377
  tag_descriptions: dict[str, str] = {}
328
378
 
379
+ # Determine if we should use SQL endpoint (for RAW/INT with datasource configured)
380
+ use_sql = read_type in (ReaderType.RAW, ReaderType.INT) and self.datasource
381
+
382
+ if use_sql:
383
+ logger.debug(f"Using SQL endpoint for {read_type.value} read")
384
+ else:
385
+ logger.debug(f"Using XML endpoint for {read_type.value} read")
386
+
329
387
  # Process each tag individually (Aspen API queries one tag at a time)
330
388
  for tag_idx, tag in enumerate(tags_list, 1):
331
389
  logger.debug(f"Processing tag {tag_idx}/{len(tags_list)}: {tag}")
332
390
 
333
- # Generate XML query for this tag
334
- xml_query = build_read_query(
335
- tag=tag,
336
- start=start,
337
- end=end,
338
- read_type=read_type,
339
- interval=interval,
340
- datasource=self.datasource,
341
- max_rows=max_rows,
342
- with_description=with_description or as_json, # Request description if needed
343
- )
344
-
345
- response = self._fetch(xml_query)
346
- logger.debug(f"Received response for tag: {tag}")
391
+ if use_sql:
392
+ # Use SQL endpoint for historical data
393
+ xml_query = build_history_sql_query(
394
+ tag=tag,
395
+ start=start,
396
+ end=end,
397
+ datasource=self.datasource,
398
+ max_rows=max_rows,
399
+ with_description=with_description or as_json,
400
+ )
401
+
402
+ sql_url = f"{self.base_url}/SQL"
403
+ logger.debug(f"POST {sql_url}")
404
+ logger.debug(f"SQL query XML: {xml_query}")
405
+
406
+ response = self._client.post(
407
+ sql_url, content=xml_query, headers={"Content-Type": "text/xml"}
408
+ )
409
+ response.raise_for_status()
410
+ sql_response = response.json()
411
+
412
+ logger.debug(f"SQL response type: {type(sql_response)}")
413
+ logger.debug(f"SQL response: {sql_response}")
414
+
415
+ # Parse SQL response (simple JSON array)
416
+ df, description = self._parse_sql_history_response(sql_response, tag)
417
+ logger.debug(f"Parsed {len(df)} data points for tag {tag}")
347
418
 
348
- # Parse the Aspen-style response
349
- df, description = self._parse_aspen_response(
350
- response, tag, include_status=include_status
351
- )
352
- logger.debug(f"Parsed {len(df)} data points for tag {tag}")
419
+ else:
420
+ # Use original XML endpoint for other read types
421
+ xml_query = build_read_query(
422
+ tag=tag,
423
+ start=start,
424
+ end=end,
425
+ read_type=read_type,
426
+ interval=interval,
427
+ datasource=self.datasource,
428
+ max_rows=max_rows,
429
+ with_description=with_description,
430
+ )
431
+
432
+ response = self._fetch(xml_query)
433
+ logger.debug(f"Received response for tag: {tag}")
434
+
435
+ # Parse the Aspen-style response
436
+ df, description = self._parse_aspen_response(
437
+ response, tag, include_status=include_status
438
+ )
439
+ logger.debug(f"Parsed {len(df)} data points for tag {tag}")
353
440
 
354
441
  if not df.empty:
355
442
  frames.append(df)
@@ -77,6 +77,57 @@ def build_read_query(
77
77
  return xml
78
78
 
79
79
 
80
+ def build_history_sql_query(
81
+ tag: str,
82
+ start: str,
83
+ end: str,
84
+ datasource: str,
85
+ max_rows: int = 100000,
86
+ with_description: bool = True,
87
+ ) -> str:
88
+ """Generate SQL query for historical data read.
89
+
90
+ Args:
91
+ tag: Tag name
92
+ start: Start timestamp (ISO format)
93
+ end: End timestamp (ISO format)
94
+ datasource: Aspen datasource name
95
+ max_rows: Maximum number of rows to return
96
+ with_description: Include ip_description field in response
97
+
98
+ Returns:
99
+ XML query string for SQL endpoint
100
+ """
101
+ # Convert timestamps to Aspen SQL format (DD-MMM-YY)
102
+ start_dt = pd.to_datetime(start)
103
+ end_dt = pd.to_datetime(end)
104
+ start_sql = start_dt.strftime("%d-%b-%y")
105
+ end_sql = end_dt.strftime("%d-%b-%y")
106
+
107
+ # Build SELECT clause
108
+ if with_description:
109
+ select_fields = "ts, name, name->ip_description, value"
110
+ else:
111
+ select_fields = "ts, name, value"
112
+
113
+ # Build SQL query
114
+ sql_query = (
115
+ f"Select {select_fields} from history "
116
+ f"where name = '{tag}' and ts between '{start_sql}' and '{end_sql}'"
117
+ )
118
+
119
+ # Build XML request for SQL endpoint
120
+ xml = (
121
+ f'<SQL g="aspy21_history" t="SQLplus" ds="{datasource}" '
122
+ f'dso="CHARINT=N;CHARFLOAT=N;CHARTIME=N;CONVERTERRORS=N" '
123
+ f'm="{max_rows}" to="30" response="Record" s="1">'
124
+ f"<![CDATA[{sql_query}]]>"
125
+ f"</SQL>"
126
+ )
127
+
128
+ return xml
129
+
130
+
80
131
  def build_sql_search_query(
81
132
  datasource: str,
82
133
  description: str,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aspy21
3
- Version: 0.2.0b2
3
+ Version: 0.2.0b4
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>
@@ -214,12 +214,19 @@ def test_aggregated_read_with_interval(mock_api):
214
214
 
215
215
 
216
216
  def test_datasource_parameter(mock_api):
217
- """Test that datasource parameter is included in XML."""
218
- route = mock_api.post("https://aspen.local/ProcessData/AtProcessDataREST.dll")
217
+ """Test that datasource parameter is included in SQL query for RAW reads."""
218
+ # RAW reads with datasource use SQL endpoint
219
+ route = mock_api.post("https://aspen.local/ProcessData/AtProcessDataREST.dll/SQL")
219
220
  route.mock(
220
221
  return_value=httpx.Response(
221
222
  200,
222
- json={"data": [{"samples": [{"t": 1750406400000, "v": 1.0, "s": 8}]}]},
223
+ json=[
224
+ {
225
+ "ts": "2025-06-20T08:00:00",
226
+ "name": "TAG1",
227
+ "value": 1.0,
228
+ }
229
+ ],
223
230
  )
224
231
  )
225
232
 
@@ -237,12 +244,12 @@ def test_datasource_parameter(mock_api):
237
244
  read_type=ReaderType.RAW,
238
245
  )
239
246
 
240
- # Verify datasource is in the XML
247
+ # Verify datasource is in the SQL query XML
241
248
  assert route.called
242
249
  request = route.calls.last.request
243
250
  request_body = request.content.decode("utf-8")
244
251
 
245
- assert "<D><![CDATA[MY_DATASOURCE]]></D>" in request_body
252
+ assert 'ds="MY_DATASOURCE"' in request_body
246
253
  assert isinstance(df, pd.DataFrame)
247
254
  c.close()
248
255
 
@@ -302,7 +309,10 @@ def test_search_by_tag_pattern(mock_api):
302
309
  )
303
310
 
304
311
  # Search with wildcard
305
- results = c.search(tag="TEMP*")
312
+ results_raw = c.search(tag="TEMP*")
313
+ # Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
314
+ assert isinstance(results_raw, list) and (not results_raw or isinstance(results_raw[0], dict))
315
+ results: list[dict[str, str]] = results_raw # type: ignore[assignment]
306
316
 
307
317
  assert isinstance(results, list)
308
318
  assert len(results) == 3
@@ -358,7 +368,10 @@ def test_search_by_description(mock_api):
358
368
  )
359
369
 
360
370
  # Search by description - should use SQL endpoint
361
- results = c.search(description="reactor")
371
+ results_raw = c.search(description="reactor")
372
+ # Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
373
+ assert isinstance(results_raw, list) and (not results_raw or isinstance(results_raw[0], dict))
374
+ results: list[dict[str, str]] = results_raw # type: ignore[assignment]
362
375
 
363
376
  assert len(results) == 2
364
377
  assert results[0]["name"] == "TEMP_101"
@@ -416,7 +429,10 @@ def test_search_combined_filters(mock_api):
416
429
  )
417
430
 
418
431
  # Search with both filters - SQL WHERE clause filters server-side
419
- results = c.search(tag="AI_1*", description="reactor")
432
+ results_raw = c.search(tag="AI_1*", description="reactor")
433
+ # Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
434
+ assert isinstance(results_raw, list) and (not results_raw or isinstance(results_raw[0], dict))
435
+ results: list[dict[str, str]] = results_raw # type: ignore[assignment]
420
436
 
421
437
  assert len(results) == 2
422
438
  assert all("AI_1" in tag["name"] for tag in results)
@@ -470,7 +486,10 @@ def test_search_case_insensitive(mock_api):
470
486
  )
471
487
 
472
488
  # Search with lowercase description (should use SQL endpoint)
473
- results = c.search(description="REACTOR")
489
+ results_raw = c.search(description="REACTOR")
490
+ # Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
491
+ assert isinstance(results_raw, list) and (not results_raw or isinstance(results_raw[0], dict))
492
+ results: list[dict[str, str]] = results_raw # type: ignore[assignment]
474
493
 
475
494
  assert len(results) == 2 # Case-insensitive match
476
495
  c.close()
@@ -525,7 +544,10 @@ def test_search_return_desc_false(mock_api):
525
544
  )
526
545
 
527
546
  # Search with return_desc=False - should return list of strings
528
- results = c.search(tag="TEMP*", return_desc=False)
547
+ results_raw = c.search(tag="TEMP*", return_desc=False)
548
+ # Type narrowing: return_desc=False guarantees list[str]
549
+ assert isinstance(results_raw, list) and (not results_raw or isinstance(results_raw[0], str))
550
+ results: list[str] = results_raw # type: ignore[assignment]
529
551
 
530
552
  assert isinstance(results, list)
531
553
  assert len(results) == 2
@@ -561,7 +583,10 @@ def test_search_by_tag_only(mock_api):
561
583
  )
562
584
 
563
585
  # Search by tag only (should use Browse endpoint, not SQL)
564
- results = c.search(tag="AI*")
586
+ results_raw = c.search(tag="AI*")
587
+ # Type narrowing: return_desc=True (default) guarantees list[dict[str, str]]
588
+ assert isinstance(results_raw, list) and (not results_raw or isinstance(results_raw[0], dict))
589
+ results: list[dict[str, str]] = results_raw # type: ignore[assignment]
565
590
 
566
591
  assert len(results) == 2
567
592
  assert results[0]["name"] == "AI_101"
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