aioads 0.1.0.dev3__tar.gz → 0.1.0.dev4__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 (88) hide show
  1. aioads-0.1.0.dev4/.github/dependabot.yml +20 -0
  2. aioads-0.1.0.dev4/.github/workflows/ci.yml +51 -0
  3. aioads-0.1.0.dev4/.github/workflows/publish.yml +62 -0
  4. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/PKG-INFO +1 -1
  5. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/ads_client.py +43 -20
  6. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/commands/ads_add_notification.py +1 -1
  7. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/commands/errors.py +4 -1
  8. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/transport.py +22 -7
  9. aioads-0.1.0.dev4/docs/unittest_style_guide.html +1739 -0
  10. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/pyproject.toml +6 -1
  11. aioads-0.1.0.dev4/tests/builders.py +63 -0
  12. aioads-0.1.0.dev4/tests/unit/__init__.py +0 -0
  13. aioads-0.1.0.dev4/tests/unit/commands/__init__.py +0 -0
  14. aioads-0.1.0.dev4/tests/unit/commands/test_ads_add_notification.py +131 -0
  15. aioads-0.1.0.dev4/tests/unit/commands/test_ads_command.py +63 -0
  16. aioads-0.1.0.dev4/tests/unit/commands/test_ads_delete_notification.py +83 -0
  17. aioads-0.1.0.dev4/tests/unit/commands/test_ads_read.py +124 -0
  18. aioads-0.1.0.dev4/tests/unit/commands/test_ads_read_device_info.py +147 -0
  19. aioads-0.1.0.dev4/tests/unit/commands/test_ads_read_state.py +137 -0
  20. aioads-0.1.0.dev4/tests/unit/commands/test_ads_read_write.py +98 -0
  21. aioads-0.1.0.dev4/tests/unit/commands/test_ads_write.py +117 -0
  22. aioads-0.1.0.dev4/tests/unit/commands/test_ads_write_state.py +85 -0
  23. aioads-0.1.0.dev4/tests/unit/commands/test_errors.py +50 -0
  24. aioads-0.1.0.dev4/tests/unit/functions/__init__.py +0 -0
  25. aioads-0.1.0.dev4/tests/unit/functions/test_ads_enable_route.py +71 -0
  26. aioads-0.1.0.dev4/tests/unit/functions/test_ads_function.py +32 -0
  27. aioads-0.1.0.dev4/tests/unit/functions/test_ads_sum_read.py +102 -0
  28. aioads-0.1.0.dev4/tests/unit/functions/test_ads_sum_read_write.py +96 -0
  29. aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_datatype_by_name.py +137 -0
  30. aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_datatype_upload.py +82 -0
  31. aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_info_by_name_ex.py +150 -0
  32. aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_table_version.py +74 -0
  33. aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_upload.py +78 -0
  34. aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_upload_info.py +77 -0
  35. aioads-0.1.0.dev4/tests/unit/test_ads_client.py +184 -0
  36. aioads-0.1.0.dev4/tests/unit/test_ads_error_codes.py +77 -0
  37. aioads-0.1.0.dev4/tests/unit/test_ads_notifications.py +161 -0
  38. aioads-0.1.0.dev4/tests/unit/test_ads_symbol_cache.py +182 -0
  39. aioads-0.1.0.dev4/tests/unit/test_ads_symbol_parser.py +244 -0
  40. aioads-0.1.0.dev4/tests/unit/test_ams_address.py +61 -0
  41. aioads-0.1.0.dev4/tests/unit/test_ams_header.py +84 -0
  42. aioads-0.1.0.dev4/tests/unit/test_ams_tcp_header.py +62 -0
  43. aioads-0.1.0.dev4/tests/unit/test_errors.py +51 -0
  44. aioads-0.1.0.dev4/tests/unit/test_stream.py +133 -0
  45. aioads-0.1.0.dev4/tests/unit/test_transport.py +220 -0
  46. aioads-0.1.0.dev4/tests/unit/utils/__init__.py +0 -0
  47. aioads-0.1.0.dev4/tests/unit/utils/test_local_ip.py +60 -0
  48. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/.gitignore +0 -0
  49. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/.vscode/settings.json +0 -0
  50. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/LICENSE +0 -0
  51. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/README.md +0 -0
  52. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/ads_error_codes.py +0 -0
  53. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/ads_notifications.py +0 -0
  54. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/ads_symbol_cache.py +0 -0
  55. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/ads_symbol_parser.py +0 -0
  56. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/ams_address.py +0 -0
  57. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/ams_header.py +0 -0
  58. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/ams_tcp_header.py +0 -0
  59. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/commands/ads_command.py +0 -0
  60. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/commands/ads_delete_notification.py +0 -0
  61. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/commands/ads_read.py +0 -0
  62. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/commands/ads_read_device_info.py +0 -0
  63. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/commands/ads_read_state.py +0 -0
  64. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/commands/ads_read_write.py +0 -0
  65. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/commands/ads_write.py +0 -0
  66. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/commands/ads_write_state.py +0 -0
  67. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/errors.py +0 -0
  68. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/functions/ads_enable_route.py +0 -0
  69. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/functions/ads_function.py +0 -0
  70. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/functions/ads_sum_read.py +0 -0
  71. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/functions/ads_sum_read_write.py +0 -0
  72. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_datatype_by_name.py +0 -0
  73. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_datatype_upload.py +0 -0
  74. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_info_by_name_ex.py +0 -0
  75. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_table_version.py +0 -0
  76. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_upload.py +0 -0
  77. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_upload_info.py +0 -0
  78. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/stream.py +0 -0
  79. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/aioads/utils/local_ip.py +0 -0
  80. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/docs/transmission_mode.md +0 -0
  81. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/examples/read_cmd_reuse_mqtt.py +0 -0
  82. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/examples/read_cycles.py +0 -0
  83. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/examples/read_cycles_mqtt.py +0 -0
  84. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/examples/read_multiple.py +0 -0
  85. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/examples/read_multiple_mqtt.py +0 -0
  86. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/examples/read_single.py +0 -0
  87. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/pdm.lock +0 -0
  88. {aioads-0.1.0.dev3 → aioads-0.1.0.dev4}/tests/__init__.py +0 -0
@@ -0,0 +1,20 @@
1
+ version: 2
2
+ updates:
3
+ # Keep the SHA-pinned GitHub Actions current (Dependabot updates both the
4
+ # pinned SHA and the trailing version comment).
5
+ - package-ecosystem: github-actions
6
+ directory: "/"
7
+ schedule:
8
+ interval: weekly
9
+ groups:
10
+ actions:
11
+ patterns: ["*"]
12
+
13
+ # Python dev/runtime dependencies tracked in pdm.lock / pyproject.toml.
14
+ - package-ecosystem: pip
15
+ directory: "/"
16
+ schedule:
17
+ interval: weekly
18
+ groups:
19
+ python:
20
+ patterns: ["*"]
@@ -0,0 +1,51 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ concurrency:
10
+ group: ci-${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ test:
18
+ name: Test (Python ${{ matrix.python-version }})
19
+ runs-on: ubuntu-latest
20
+ strategy:
21
+ fail-fast: false
22
+ matrix:
23
+ python-version: ["3.12", "3.13"]
24
+
25
+ steps:
26
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
27
+ with:
28
+ persist-credentials: false
29
+
30
+ - name: Set up PDM
31
+ uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
32
+ with:
33
+ python-version: ${{ matrix.python-version }}
34
+ cache: true
35
+
36
+ - name: Install dependencies
37
+ run: pdm install
38
+
39
+ - name: Lint
40
+ run: pdm run lint
41
+
42
+ - name: Run tests with coverage
43
+ run: pdm run coverage
44
+
45
+ - name: Upload coverage artifact
46
+ if: matrix.python-version == '3.12'
47
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
48
+ with:
49
+ name: coverage-xml
50
+ path: coverage.xml
51
+ if-no-files-found: ignore
@@ -0,0 +1,62 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ # Avoid two releases racing to publish the same version.
12
+ concurrency:
13
+ group: publish-${{ github.workflow }}-${{ github.ref }}
14
+ cancel-in-progress: false
15
+
16
+ jobs:
17
+ build:
18
+ name: Build distribution
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
22
+ with:
23
+ persist-credentials: false
24
+
25
+ - name: Set up PDM
26
+ uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
27
+ with:
28
+ python-version: "3.12"
29
+
30
+ - name: Build sdist and wheel
31
+ run: pdm build
32
+
33
+ - name: Upload build artifacts
34
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+
39
+ publish:
40
+ name: Publish to PyPI
41
+ needs: build
42
+ runs-on: ubuntu-latest
43
+ # Only publish for real releases, not manual test runs.
44
+ if: github.event_name == 'release'
45
+ environment:
46
+ name: pypi
47
+ url: https://pypi.org/project/aioads/
48
+ permissions:
49
+ # IMPORTANT: required for OIDC trusted publishing.
50
+ id-token: write
51
+ steps:
52
+ - name: Download build artifacts
53
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
54
+ with:
55
+ name: dist
56
+ path: dist/
57
+
58
+ - name: Publish to PyPI
59
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
60
+ with:
61
+ # Sign the published distributions with PEP 740 build provenance.
62
+ attestations: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aioads
3
- Version: 0.1.0.dev3
3
+ Version: 0.1.0.dev4
4
4
  Summary: An asynchronous Python library for communicating with Beckhoff TwinCAT PLCs
5
5
  Author-email: MkKiefer <102972583+MkKiefer@users.noreply.github.com>
6
6
  License: MIT
@@ -266,36 +266,59 @@ class AdsClient:
266
266
  ) -> dict[str, SymbolReadResult]:
267
267
  """
268
268
  Read multiple symbols by their names from the ADS device.
269
+
270
+ The returned dict is keyed by the symbol names exactly as requested,
271
+ preserving their original casing.
269
272
  """
270
273
  symbol_infos = await self._cache.read_symbol_infos_by_names(symbol_names)
271
274
  if raise_errors:
272
275
  self._raise_if_error(symbol_infos)
273
276
 
274
- read_commands = [
275
- AdsReadCommand(
276
- transport=self.transport,
277
- ams_address=self.dst_address,
278
- idx_group=symbol_info.idx_group,
279
- idx_offset=symbol_info.idx_offset,
280
- length=symbol_info.idx_length,
281
- )
282
- for _, symbol_info in symbol_infos.values()
283
- ]
277
+ output: dict[str, SymbolReadResult] = {}
278
+
279
+ # Only symbols whose info resolved successfully can be read. Symbols
280
+ # with a lookup error are reported as-is; building a read command from
281
+ # their (invalid) index group/offset would misalign the bulk response.
282
+ readable: list[tuple[str, SymbolInfo]] = []
283
+ for requested_name, (error_code, symbol_info) in symbol_infos.items():
284
+ if not error_code.ok:
285
+ output[requested_name] = SymbolReadResult(error_code, None)
286
+ else:
287
+ readable.append((requested_name, symbol_info))
288
+
289
+ if not readable:
290
+ return output
291
+
284
292
  function = AdsSumRead(
285
293
  transport=self.transport,
286
294
  ams_address=self.dst_address,
287
- commands=read_commands,
295
+ commands=[
296
+ AdsReadCommand(
297
+ transport=self.transport,
298
+ ams_address=self.dst_address,
299
+ idx_group=symbol_info.idx_group,
300
+ idx_offset=symbol_info.idx_offset,
301
+ length=symbol_info.idx_length,
302
+ )
303
+ for _, symbol_info in readable
304
+ ],
288
305
  )
289
-
290
306
  response = await function.execute()
291
- output: dict[str, SymbolReadResult] = {}
292
307
 
308
+ loop = asyncio.get_running_loop()
309
+ parse_names: list[str] = []
293
310
  tasks = []
294
- for (_, resp_payload), (error_code, symbol_info) in zip(
295
- response, symbol_infos.values()
311
+ for (requested_name, symbol_info), (read_response, resp_payload) in zip(
312
+ readable, response
296
313
  ):
314
+ # A failed read yields no usable payload, so skip parsing it.
315
+ if read_response.error_code != 0:
316
+ output[requested_name] = SymbolReadResult(
317
+ read_response.error_code, None)
318
+ continue
319
+ parse_names.append(requested_name)
297
320
  tasks.append(
298
- asyncio.get_running_loop().run_in_executor(
321
+ loop.run_in_executor(
299
322
  self.parser_pool,
300
323
  self.parser.parse,
301
324
  symbol_info.data_type,
@@ -303,11 +326,11 @@ class AdsClient:
303
326
  resp_payload,
304
327
  )
305
328
  )
306
- parsed = await asyncio.gather(*tasks)
307
329
 
308
- for symbol_data, (error_code, symbol_info) in zip(parsed, symbol_infos.values()):
309
- output[symbol_info.symbol_name] = SymbolReadResult(
310
- error_code, symbol_data)
330
+ parsed = await asyncio.gather(*tasks)
331
+ for requested_name, symbol_data in zip(parse_names, parsed):
332
+ output[requested_name] = SymbolReadResult(
333
+ AdsErrorCode(0), symbol_data)
311
334
 
312
335
  return output
313
336
 
@@ -77,7 +77,7 @@ class AdsAddNotificationCommand(ICommand[AdsAddNotificationResponse]):
77
77
  Ads command to create a ads notification (subscription to a remote resource)
78
78
  """
79
79
 
80
- SERIALIZE_STRUCT_DEF: Final[Struct] = Struct("<IIIIIII16s")
80
+ SERIALIZE_STRUCT_DEF: Final[Struct] = Struct("<IIIIII16s")
81
81
 
82
82
  def __init__(
83
83
  self,
@@ -27,4 +27,7 @@ class AdsCommandError(Exception):
27
27
 
28
28
  def __repr__(self) -> str:
29
29
  """Returns a detailed representation of the error for debugging."""
30
- return f"AdsCommandError(error_code={self.error_code!r}, error_message:{self.error_code.description} args={self.args!r})"
30
+ return (
31
+ f"AdsCommandError(error_code={self.error_code!r}, "
32
+ f"error_message:{self.error_code.description} args={self.args!r})"
33
+ )
@@ -364,7 +364,8 @@ class AdsTcpTransport(BaseTransport, ITransport):
364
364
  while self._state is not ConnectionState.CLOSED:
365
365
  try:
366
366
  await self._read_loop()
367
- except asyncio.CancelledError:
367
+ except asyncio.CancelledError: #pylint: disable=try-except-raise
368
+ # Except everything... except a cancellation event
368
369
  raise
369
370
  except Exception as e:
370
371
  self.logger.info("Connection lost: %s", e)
@@ -381,7 +382,7 @@ class AdsTcpTransport(BaseTransport, ITransport):
381
382
  self.logger.info("Reconnected to the remote")
382
383
  backoff = self.RECONNECT_INITIAL_BACKOFF
383
384
  break
384
- except asyncio.CancelledError:
385
+ except asyncio.CancelledError: #pylint: disable=try-except-raise
385
386
  raise
386
387
  except Exception as e: # pylint: disable=broad-exception-caught
387
388
  self.logger.info(
@@ -401,10 +402,23 @@ class AdsTcpTransport(BaseTransport, ITransport):
401
402
 
402
403
  reader, _ = stream
403
404
  while True:
404
- # Read AmsTcpHeader
405
- tcp_header_bytes = await reader.readexactly(AmsTcpHeader.FIXED_SIZE)
406
- tcp_header = AmsTcpHeader.deserialize(tcp_header_bytes)
407
- payload_bytes = await reader.readexactly(tcp_header.length)
405
+ try:
406
+ # Read AmsTcpHeader
407
+ tcp_header_bytes = await reader.readexactly(AmsTcpHeader.FIXED_SIZE)
408
+ tcp_header = AmsTcpHeader.deserialize(tcp_header_bytes)
409
+ payload_bytes = await reader.readexactly(tcp_header.length)
410
+ except asyncio.IncompleteReadError as e:
411
+ # readexactly hit EOF: the peer's recv() returned 0 bytes,
412
+ # i.e. the remote closed the connection. An empty partial is an
413
+ # orderly close on a message boundary; a non-empty partial means
414
+ # we were cut off mid-message. Either way the stream is dead, so
415
+ # raise a clear ConnectionError for the supervisor to reconnect.
416
+ if e.partial:
417
+ raise ConnectionError(
418
+ f"Connection closed mid-message: received "
419
+ f"{len(e.partial)} of {e.expected} expected bytes"
420
+ ) from e
421
+ raise ConnectionError("Connection closed by remote") from e
408
422
 
409
423
  ams_message_stream = AdsStream(memoryview(payload_bytes))
410
424
  ams_header = AmsHeader.deserialize(ams_message_stream)
@@ -459,6 +473,7 @@ class AdsOverMqttTopics:
459
473
 
460
474
 
461
475
  class MqttBaseTransport(BaseTransport):
476
+ """Base transport that tunnels AMS/ADS frames over an MQTT broker."""
462
477
 
463
478
  REQUEST_TIMEOUT = 120.0
464
479
 
@@ -771,7 +786,7 @@ class AdsGMqttTransport(MqttBaseTransport, ITransport):
771
786
  response_future, timeout=self.REQUEST_TIMEOUT
772
787
  )
773
788
 
774
- async def _on_response(self, _client: gmqtt.Client, topic: str, payload: bytes, qos: int, properties: dict):
789
+ async def _on_response(self, _client: gmqtt.Client, topic: str, payload: bytes, _qos: int, _properties: dict):
775
790
  if self.topics.matches(self.topics.sub_response, topic):
776
791
  ams_message_stream = AdsStream(memoryview(payload))
777
792
  ams_header = AmsHeader.deserialize(ams_message_stream)