switch-api 0.6.31__tar.gz → 0.6.33__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 (82) hide show
  1. {switch_api-0.6.31 → switch_api-0.6.33}/HISTORY.md +23 -0
  2. {switch_api-0.6.31 → switch_api-0.6.33}/PKG-INFO +24 -1
  3. {switch_api-0.6.31 → switch_api-0.6.33}/setup.py +1 -1
  4. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/__init__.py +1 -1
  5. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_compute/_compute.py +8 -1
  6. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/integration/helpers.py +2 -2
  7. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/integration/integration.py +10 -1
  8. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api.egg-info/PKG-INFO +24 -1
  9. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api.egg-info/SOURCES.txt +2 -0
  10. switch_api-0.6.33/tests/test_carbon_calculation.py +91 -0
  11. switch_api-0.6.33/tests/test_carbon_response_diagnostics.py +74 -0
  12. {switch_api-0.6.31 → switch_api-0.6.33}/AUTHORS.rst +0 -0
  13. {switch_api-0.6.31 → switch_api-0.6.33}/LICENCE +0 -0
  14. {switch_api-0.6.31 → switch_api-0.6.33}/MANIFEST.in +0 -0
  15. {switch_api-0.6.31 → switch_api-0.6.33}/README.md +0 -0
  16. {switch_api-0.6.31 → switch_api-0.6.33}/pyproject.toml +0 -0
  17. {switch_api-0.6.31 → switch_api-0.6.33}/setup.cfg +0 -0
  18. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_authentication/__init__.py +0 -0
  19. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_authentication/_authentication.py +0 -0
  20. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_authentication/_credentials_store/__init__.py +0 -0
  21. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_authentication/_credentials_store/_credentials_store.py +0 -0
  22. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_authentication/_msal/__init__.py +0 -0
  23. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_authentication/_msal/_custom_application.py +0 -0
  24. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_compute/__init__.py +0 -0
  25. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_compute/_commands.py +0 -0
  26. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_compute/_iotservice.py +0 -0
  27. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_compute/_models.py +0 -0
  28. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_compute/_utils.py +0 -0
  29. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_guide/__init__.py +0 -0
  30. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_guide/main.py +0 -0
  31. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_guide/processor.py +0 -0
  32. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_utils/__init__.py +0 -0
  33. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_utils/_constants.py +0 -0
  34. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_utils/_marketplace.py +0 -0
  35. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_utils/_mqtt.py +0 -0
  36. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_utils/_platform.py +0 -0
  37. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_utils/_sql.py +0 -0
  38. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/_utils/_utils.py +0 -0
  39. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/analytics/__init__.py +0 -0
  40. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/analytics/analytics.py +0 -0
  41. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/cache/__init__.py +0 -0
  42. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/cache/cache.py +0 -0
  43. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/controls/__init__.py +0 -0
  44. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/controls/_constants.py +0 -0
  45. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/controls/_enums.py +0 -0
  46. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/controls/_mqtt.py +0 -0
  47. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/controls/controls.py +0 -0
  48. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/dataset/__init__.py +0 -0
  49. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/dataset/dataset.py +0 -0
  50. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/email/__init__.py +0 -0
  51. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/email/email_sender.py +0 -0
  52. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/error_handlers/__init__.py +0 -0
  53. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/error_handlers/error_handlers.py +0 -0
  54. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/extensions/__init__.py +0 -0
  55. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/extensions/extensions.py +0 -0
  56. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/extensions/field_meta.py +0 -0
  57. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/extensions/helpers.py +0 -0
  58. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/extensions/pipeline.py +0 -0
  59. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/initialize.py +0 -0
  60. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/integration/__init__.py +0 -0
  61. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/integration/_utils.py +0 -0
  62. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/pipeline/__init__.py +0 -0
  63. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/pipeline/automation.py +0 -0
  64. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/pipeline/definitions.py +0 -0
  65. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/pipeline/pipeline.py +0 -0
  66. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/platform_insights/__init__.py +0 -0
  67. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/platform_insights/platform_insights.py +0 -0
  68. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/realtime/__init__.py +0 -0
  69. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/realtime/_mqtt.py +0 -0
  70. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/realtime/_redis.py +0 -0
  71. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api/realtime/realtime.py +0 -0
  72. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api.egg-info/dependency_links.txt +0 -0
  73. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api.egg-info/requires.txt +0 -0
  74. {switch_api-0.6.31 → switch_api-0.6.33}/switch_api.egg-info/top_level.txt +0 -0
  75. {switch_api-0.6.31 → switch_api-0.6.33}/tests/test_authentication.py +0 -0
  76. {switch_api-0.6.31 → switch_api-0.6.33}/tests/test_controls.py +0 -0
  77. {switch_api-0.6.31 → switch_api-0.6.33}/tests/test_dataset.py +0 -0
  78. {switch_api-0.6.31 → switch_api-0.6.33}/tests/test_error_handlers.py +0 -0
  79. {switch_api-0.6.31 → switch_api-0.6.33}/tests/test_initialize.py +0 -0
  80. {switch_api-0.6.31 → switch_api-0.6.33}/tests/test_integration.py +0 -0
  81. {switch_api-0.6.31 → switch_api-0.6.33}/tests/test_pipeline.py +0 -0
  82. {switch_api-0.6.31 → switch_api-0.6.33}/tests/test_timer_deployment.py +0 -0
@@ -1,5 +1,28 @@
1
1
  # History
2
2
 
3
+ #0.6.33
4
+
5
+ ### Fixed
6
+
7
+ In the `integration` module:
8
+
9
+ - Fixed `unknown type object` errors in carbon calculations by converting numeric object and pandas extension arrays into NumPy arrays supported by `numexpr`. Existing NumPy numeric dtypes and original input values are preserved.
10
+
11
+ ### Added
12
+
13
+ In the `_compute` module:
14
+
15
+ - Added HTTP status, response elapsed time, content type, and exception type to carbon-expression JSON parsing failure logs. Response bodies and exception messages are omitted. The existing empty-result fallback is retained.
16
+
17
+ #0.6.32
18
+
19
+ ### Changed
20
+
21
+ In the `integration` module:
22
+
23
+ - Changed the default `retries` for `get_device_sensors()` from `0` to `5`, enabling the existing retry policy for each page request by default. Callers can override the retry count, including `retries=0` to disable retries.
24
+ - Changed the default `retries` for `get_data()` from `0` to `5` to match its documented default. Callers can still override the retry count.
25
+
3
26
  #0.6.31
4
27
 
5
28
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switch_api
3
- Version: 0.6.31
3
+ Version: 0.6.33
4
4
  Summary: A complete package for data ingestion into the Switch Automation Platform.
5
5
  Author: Switch Automation Pty Ltd.
6
6
  License: MIT License
@@ -58,6 +58,29 @@ pip install switch_api
58
58
 
59
59
  # History
60
60
 
61
+ #0.6.33
62
+
63
+ ### Fixed
64
+
65
+ In the `integration` module:
66
+
67
+ - Fixed `unknown type object` errors in carbon calculations by converting numeric object and pandas extension arrays into NumPy arrays supported by `numexpr`. Existing NumPy numeric dtypes and original input values are preserved.
68
+
69
+ ### Added
70
+
71
+ In the `_compute` module:
72
+
73
+ - Added HTTP status, response elapsed time, content type, and exception type to carbon-expression JSON parsing failure logs. Response bodies and exception messages are omitted. The existing empty-result fallback is retained.
74
+
75
+ #0.6.32
76
+
77
+ ### Changed
78
+
79
+ In the `integration` module:
80
+
81
+ - Changed the default `retries` for `get_device_sensors()` from `0` to `5`, enabling the existing retry policy for each page request by default. Callers can override the retry count, including `retries=0` to disable retries.
82
+ - Changed the default `retries` for `get_data()` from `0` to `5` to match its documented default. Callers can still override the retry count.
83
+
61
84
  #0.6.31
62
85
 
63
86
  ### Added
@@ -16,7 +16,7 @@ setup(
16
16
  long_description_content_type='text/markdown',
17
17
  license='MIT License',
18
18
  name="switch_api",
19
- version="0.6.31",
19
+ version="0.6.33",
20
20
  packages=find_packages(include=["switch_api", "switch_api.*"]),
21
21
  install_requires=['pandas==1.5.3', 'requests', 'azure-storage-blob', 'pandera[io]==0.7.1', 'azure-servicebus',
22
22
  'msal>=1.11.0', 'paho-mqtt==1.6.1', 'uvicorn==0.22.0', 'fastapi==0.98.0', 'pyodbc==4.0.39', 'cachetools==5.5.0',
@@ -45,4 +45,4 @@ from . import _guide
45
45
  # log.addHandler(_ch)
46
46
  # https://dev.to/joaomcteixeira/setting-up-python-logging-for-a-library-app-6ml
47
47
 
48
- __version__ = "0.6.31"
48
+ __version__ = "0.6.33"
@@ -78,7 +78,14 @@ def get_carbon_calculation_expression(df: pd.DataFrame, api_inputs: ApiInputs) -
78
78
  logger.info('Successfully retrieved %d carbon calculation expressions.', len(carbon_calculations_df))
79
79
  return carbon_calculations_df
80
80
  except ValueError as e:
81
- logger.error("Failed to parse JSON for carbon calculations: %s", e)
81
+ logger.error(
82
+ "Failed to parse JSON for carbon calculations. exception_type=%s; "
83
+ "HTTP status=%s; elapsed_seconds=%.3f; content_type=%r",
84
+ type(e).__name__,
85
+ response_carbon.status_code,
86
+ response_carbon.elapsed.total_seconds(),
87
+ response_carbon.headers.get('Content-Type', ''),
88
+ )
82
89
  return pd.DataFrame()
83
90
 
84
91
  def _execute_iot_sql_query(api_inputs: ApiInputs, query: str) -> requests.Response:
@@ -268,7 +268,7 @@ def get_sites(api_inputs: ApiInputs, include_tag_groups: Union[list, bool] = Fal
268
268
 
269
269
  def get_device_sensors(api_inputs: ApiInputs, include_tag_groups: Union[list, bool] = False,
270
270
  include_metadata_keys: Union[list, bool] = False, sql_where_clause: str = None,
271
- top_count: int = None, retries: int = 0, backoff_factor: Union[int, float] = 0.3):
271
+ top_count: int = None, retries: int = 5, backoff_factor: Union[int, float] = 0.3):
272
272
  """Retrieve device and sensor information.
273
273
 
274
274
  Optionally include all or a subset of tag groups and/or metadata keys depending on the configuration of the
@@ -436,7 +436,7 @@ def get_device_sensors(api_inputs: ApiInputs, include_tag_groups: Union[list, bo
436
436
 
437
437
 
438
438
  def get_data(query_text, api_inputs: ApiInputs, query_language: QUERY_LANGUAGE = "kql",
439
- response_type: RESPONSE_TYPE = 'dataframe', retries: int = 0, backoff_factor: Union[int, float] = 2):
439
+ response_type: RESPONSE_TYPE = 'dataframe', retries: int = 5, backoff_factor: Union[int, float] = 2):
440
440
  """Retrieve data.
441
441
 
442
442
  Parameters
@@ -1901,9 +1901,18 @@ def upsert_timeseries(df: pandas.DataFrame, api_inputs: ApiInputs, is_local_time
1901
1901
  for expr in unique_expressions:
1902
1902
  expr_mask = valid_carbon_calcs["CarbonExpression"] == expr
1903
1903
  numexpr_expression = expr.replace("[Value]", "Value")
1904
+ value_series = valid_carbon_calcs.loc[expr_mask, "Value"]
1905
+ values = value_series.values
1906
+ # Preserve existing NumPy dtypes and their evaluation behavior.
1907
+ # Only normalize object or pandas extension arrays for numexpr.
1908
+ if not isinstance(values, np.ndarray) or values.dtype.kind == "O":
1909
+ numeric_values = pd.to_numeric(value_series, errors="raise")
1910
+ values = numeric_values.to_numpy()
1911
+ if values.dtype.kind == "O":
1912
+ values = numeric_values.to_numpy(dtype=np.float64, na_value=np.nan)
1904
1913
  valid_carbon_calcs.loc[expr_mask, "Carbon"] = numexpr.evaluate(
1905
1914
  numexpr_expression,
1906
- local_dict={"Value": valid_carbon_calcs.loc[expr_mask, "Value"].values}
1915
+ local_dict={"Value": values}
1907
1916
  )
1908
1917
 
1909
1918
  df = df.merge(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switch_api
3
- Version: 0.6.31
3
+ Version: 0.6.33
4
4
  Summary: A complete package for data ingestion into the Switch Automation Platform.
5
5
  Author: Switch Automation Pty Ltd.
6
6
  License: MIT License
@@ -58,6 +58,29 @@ pip install switch_api
58
58
 
59
59
  # History
60
60
 
61
+ #0.6.33
62
+
63
+ ### Fixed
64
+
65
+ In the `integration` module:
66
+
67
+ - Fixed `unknown type object` errors in carbon calculations by converting numeric object and pandas extension arrays into NumPy arrays supported by `numexpr`. Existing NumPy numeric dtypes and original input values are preserved.
68
+
69
+ ### Added
70
+
71
+ In the `_compute` module:
72
+
73
+ - Added HTTP status, response elapsed time, content type, and exception type to carbon-expression JSON parsing failure logs. Response bodies and exception messages are omitted. The existing empty-result fallback is retained.
74
+
75
+ #0.6.32
76
+
77
+ ### Changed
78
+
79
+ In the `integration` module:
80
+
81
+ - Changed the default `retries` for `get_device_sensors()` from `0` to `5`, enabling the existing retry policy for each page request by default. Callers can override the retry count, including `retries=0` to disable retries.
82
+ - Changed the default `retries` for `get_data()` from `0` to `5` to match its documented default. Callers can still override the retry count.
83
+
61
84
  #0.6.31
62
85
 
63
86
  ### Added
@@ -70,6 +70,8 @@ switch_api/realtime/_mqtt.py
70
70
  switch_api/realtime/_redis.py
71
71
  switch_api/realtime/realtime.py
72
72
  tests/test_authentication.py
73
+ tests/test_carbon_calculation.py
74
+ tests/test_carbon_response_diagnostics.py
73
75
  tests/test_controls.py
74
76
  tests/test_dataset.py
75
77
  tests/test_error_handlers.py
@@ -0,0 +1,91 @@
1
+ """Offline regression tests for the nested carbon calculation helper."""
2
+
3
+ import ast
4
+ from decimal import Decimal
5
+ import logging
6
+ from pathlib import Path
7
+ import unittest
8
+ from unittest.mock import patch
9
+
10
+ import numexpr
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+
15
+ def load_carbon_helper():
16
+ # Isolate the nested helper without initializing API clients or ingesting data.
17
+ source = Path(__file__).parents[1] / "switch_api/integration/integration.py"
18
+ tree = ast.parse(source.read_text(encoding="utf-8"))
19
+ upsert = next(node for node in tree.body
20
+ if isinstance(node, ast.FunctionDef) and node.name == "upsert_timeseries")
21
+ helper = next(node for node in upsert.body
22
+ if isinstance(node, ast.FunctionDef) and node.name == "add_carbon_column")
23
+ namespace = {"pd": pd, "np": np, "numexpr": numexpr,
24
+ "logger": logging.getLogger(__name__)}
25
+ exec(compile(ast.Module(body=[helper], type_ignores=[]), str(source), "exec"), namespace)
26
+ return namespace["add_carbon_column"]
27
+
28
+
29
+ class CarbonCalculationTests(unittest.TestCase):
30
+ def calculate(self, values, expression="[Value] * 0.5"):
31
+ frame = pd.DataFrame({
32
+ "ObjectPropertyId": ["SENSOR"] * len(values),
33
+ "TimestampLocal": pd.to_datetime(["2026-06-01"] * len(values)),
34
+ "Value": values,
35
+ })
36
+ original = frame.copy(deep=True)
37
+ calculations = pd.DataFrame({
38
+ "ObjectPropertyID": ["sensor", "sensor"],
39
+ "CarbonExpression": ["[Value] * 9", expression],
40
+ "FromDate": ["2026-01-01"] * 2,
41
+ "ToDate": ["2026-12-31"] * 2,
42
+ "ObjectPropertyTemplateID": [None, "template"],
43
+ })
44
+ result = load_carbon_helper()(frame, calculations)
45
+ pd.testing.assert_frame_equal(frame, original)
46
+ pd.testing.assert_series_equal(result["Value"], original["Value"])
47
+ self.assertEqual(len(result), len(frame))
48
+ self.assertNotIn("__row_id", result)
49
+ return result["Carbon"].to_numpy()
50
+
51
+ def test_numeric_input_representations(self):
52
+ for values in ([2.0, 4.0], [2, 4], ["2", "4"],
53
+ [Decimal("2"), Decimal("4")],
54
+ pd.Series([2, 4], dtype=object),
55
+ pd.Series([2, 4], dtype="Float64")):
56
+ with self.subTest(values=repr(values)):
57
+ np.testing.assert_allclose(self.calculate(values), [1, 2])
58
+
59
+ def test_missing_values_preserve_existing_zero_carbon_fallback(self):
60
+ for values in (["2", None], pd.Series([2, pd.NA], dtype="Float64"),
61
+ pd.Series([2, pd.NA], dtype="Int64")):
62
+ with self.subTest(values=repr(values)):
63
+ np.testing.assert_allclose(self.calculate(values), [1, 0])
64
+
65
+ def test_invalid_text_is_rejected(self):
66
+ with self.assertRaises(ValueError):
67
+ self.calculate(["invalid", "2"])
68
+
69
+ def test_existing_numpy_numeric_dtypes_are_preserved(self):
70
+ for dtype in (np.int32, np.int64, np.float32, np.float64):
71
+ with self.subTest(dtype=dtype), patch.object(
72
+ numexpr, "evaluate", wraps=numexpr.evaluate
73
+ ) as evaluate:
74
+ self.calculate(np.array([2, 4], dtype=dtype))
75
+ supplied = evaluate.call_args.kwargs["local_dict"]["Value"]
76
+ self.assertEqual(supplied.dtype, np.dtype(dtype))
77
+
78
+ def test_large_integer_precision_is_preserved_during_evaluation(self):
79
+ values = np.array([2**53 + 1, 2**53 + 3], dtype=np.int64)
80
+ np.testing.assert_array_equal(
81
+ self.calculate(values, "[Value] - 9007199254740992"), [1, 3]
82
+ )
83
+
84
+ def test_integer_only_expression_still_works(self):
85
+ np.testing.assert_array_equal(
86
+ self.calculate(np.array([2, 4], dtype=np.int64), "[Value] << 1"), [4, 8]
87
+ )
88
+
89
+
90
+ if __name__ == "__main__":
91
+ unittest.main()
@@ -0,0 +1,74 @@
1
+ """Offline tests of carbon response diagnostics; no API initialization required."""
2
+
3
+ import ast
4
+ from datetime import timedelta
5
+ import logging
6
+ from pathlib import Path
7
+ import unittest
8
+ from unittest.mock import Mock
9
+
10
+ import pandas as pd
11
+ import requests
12
+
13
+
14
+ class CarbonResponseDiagnosticsTests(unittest.TestCase):
15
+ def setUp(self):
16
+ source = Path(__file__).parents[1] / 'switch_api/_compute/_compute.py'
17
+ tree = ast.parse(source.read_text(encoding='utf-8'))
18
+ functions = [node for node in tree.body if isinstance(node, ast.FunctionDef)
19
+ and node.name == 'get_carbon_calculation_expression']
20
+ self.logger = logging.getLogger('carbon_response_test')
21
+ self.response = requests.Response()
22
+ self.response.status_code = 200
23
+ self.response.elapsed = timedelta(seconds=10.125)
24
+ self.response.headers['Content-Type'] = 'application/json'
25
+ self.namespace = dict(pd=pd, ApiInputs=object,
26
+ logger=self.logger,
27
+ COMMAND_CARBON_CALCULATION_EXPRESSION=Mock(return_value='query'),
28
+ _execute_iot_sql_query=Mock(return_value=self.response))
29
+ exec(compile(ast.Module(body=functions, type_ignores=[]), str(source), 'exec'), self.namespace)
30
+
31
+ def retrieve(self, body):
32
+ self.response._content = body.encode('utf-8')
33
+ frame = pd.DataFrame({'ObjectPropertyId': ['sensor'],
34
+ 'TimestampLocal': pd.to_datetime(['2026-06-01'])})
35
+ return self.namespace['get_carbon_calculation_expression'](frame, object())
36
+
37
+ def test_scalar_error_response_logs_diagnostics_and_keeps_fallback(self):
38
+ with self.assertLogs(self.logger, level='ERROR') as captured:
39
+ result = self.retrieve('{"error":"SQL timeout", "access_token":"private-token"}')
40
+ self.assertTrue(result.empty)
41
+ log = ' '.join(captured.output)
42
+ for expected in ('HTTP status=200', 'elapsed_seconds=10.125', 'application/json',
43
+ 'exception_type=ValueError'):
44
+ self.assertIn(expected, log)
45
+ self.assertNotIn('private-token', log)
46
+
47
+ def test_malformed_response_content_is_not_logged(self):
48
+ for body in ('{"password":"private-password",',
49
+ '{"api_key":"private-api-key",',
50
+ '<html>password=private-password</html>'):
51
+ with self.subTest(body=body), self.assertLogs(self.logger, level='ERROR') as captured:
52
+ result = self.retrieve(body)
53
+ self.assertTrue(result.empty)
54
+ log = ' '.join(captured.output)
55
+ self.assertNotIn('private-', log)
56
+ self.assertNotIn('response_body=', log)
57
+
58
+ def test_exception_message_is_not_logged(self):
59
+ from unittest.mock import patch
60
+ with patch.object(pd, 'read_json', side_effect=ValueError('private-payload')):
61
+ with self.assertLogs(self.logger, level='ERROR') as captured:
62
+ self.assertTrue(self.retrieve('invalid').empty)
63
+ self.assertNotIn('private-payload', ' '.join(captured.output))
64
+
65
+ def test_empty_and_valid_responses_do_not_log_parse_failures(self):
66
+ for body in ('', '[]', '[{"CarbonExpression":"[Value] * 0.5"}]'):
67
+ with self.subTest(body=body), self.assertLogs(self.logger, level='INFO') as captured:
68
+ result = self.retrieve(body)
69
+ self.assertFalse(any('Failed to parse' in entry for entry in captured.output))
70
+ self.assertEqual(result.empty, body in ('', '[]'))
71
+
72
+
73
+ if __name__ == '__main__':
74
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes