aioads 0.1.0.dev9__tar.gz → 0.1.0.dev10__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 (99) hide show
  1. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/PKG-INFO +1 -1
  2. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ads_client.py +25 -5
  3. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ads_symbol_cache.py +3 -0
  4. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_sum_read.py +33 -14
  5. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_sum_read_write.py +32 -16
  6. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_info_by_name_ex.py +30 -24
  7. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/pyproject.toml +1 -1
  8. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/test_memory.py +31 -9
  9. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_sum_read.py +47 -10
  10. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_sum_read_write.py +48 -10
  11. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py +1 -0
  12. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ads_client.py +2 -1
  13. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ads_notifications.py +1 -1
  14. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ads_symbol_cache.py +7 -2
  15. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/.github/dependabot.yml +0 -0
  16. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/.github/workflows/ci.yml +0 -0
  17. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/.github/workflows/publish.yml +0 -0
  18. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/.gitignore +0 -0
  19. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/.vscode/settings.json +0 -0
  20. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/LICENSE +0 -0
  21. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/README.md +0 -0
  22. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ads_error_codes.py +0 -0
  23. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ads_notifications.py +0 -0
  24. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ads_symbol_parser.py +0 -0
  25. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ams_address.py +0 -0
  26. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ams_header.py +0 -0
  27. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ams_service_port.py +0 -0
  28. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ams_tcp_header.py +0 -0
  29. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_add_notification.py +0 -0
  30. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_command.py +0 -0
  31. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_delete_notification.py +0 -0
  32. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_read.py +0 -0
  33. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_read_device_info.py +0 -0
  34. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_read_state.py +0 -0
  35. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_read_write.py +0 -0
  36. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_write.py +0 -0
  37. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_write_state.py +0 -0
  38. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/errors.py +0 -0
  39. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/errors.py +0 -0
  40. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_enable_route.py +0 -0
  41. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_function.py +0 -0
  42. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_datatype_by_name.py +0 -0
  43. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_datatype_upload.py +0 -0
  44. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_table_version.py +0 -0
  45. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_upload.py +0 -0
  46. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_upload_info.py +0 -0
  47. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/py.typed +0 -0
  48. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/stream.py +0 -0
  49. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/transport.py +0 -0
  50. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/utils/local_ip.py +0 -0
  51. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/docs/transmission_mode.md +0 -0
  52. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/docs/unittest_style_guide.html +0 -0
  53. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/examples/read_cmd_reuse_mqtt.py +0 -0
  54. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/examples/read_cycles.py +0 -0
  55. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/examples/read_cycles_mqtt.py +0 -0
  56. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/examples/read_multiple.py +0 -0
  57. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/examples/read_multiple_mqtt.py +0 -0
  58. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/examples/read_single.py +0 -0
  59. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/pdm.lock +0 -0
  60. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/__init__.py +0 -0
  61. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/builders.py +0 -0
  62. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/README.md +0 -0
  63. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/__init__.py +0 -0
  64. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/base.py +0 -0
  65. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/config.example.toml +0 -0
  66. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/config.py +0 -0
  67. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/test_connection.py +0 -0
  68. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/test_performance.py +0 -0
  69. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/test_read_symbols.py +0 -0
  70. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/__init__.py +0 -0
  71. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/__init__.py +0 -0
  72. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_add_notification.py +0 -0
  73. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_command.py +0 -0
  74. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_delete_notification.py +0 -0
  75. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_read.py +0 -0
  76. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_read_device_info.py +0 -0
  77. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_read_state.py +0 -0
  78. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_read_write.py +0 -0
  79. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_write.py +0 -0
  80. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_write_state.py +0 -0
  81. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_errors.py +0 -0
  82. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/__init__.py +0 -0
  83. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_enable_route.py +0 -0
  84. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_function.py +0 -0
  85. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_datatype_by_name.py +0 -0
  86. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_datatype_upload.py +0 -0
  87. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_table_version.py +0 -0
  88. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_upload.py +0 -0
  89. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_upload_info.py +0 -0
  90. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ads_error_codes.py +0 -0
  91. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ads_symbol_parser.py +0 -0
  92. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ams_address.py +0 -0
  93. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ams_header.py +0 -0
  94. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ams_tcp_header.py +0 -0
  95. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_errors.py +0 -0
  96. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_stream.py +0 -0
  97. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_transport.py +0 -0
  98. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/utils/__init__.py +0 -0
  99. {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/utils/test_local_ip.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aioads
3
- Version: 0.1.0.dev9
3
+ Version: 0.1.0.dev10
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
@@ -50,17 +50,24 @@ class AdsClient:
50
50
  parser: ISymbolParser,
51
51
  cache: AdsSymbolCache,
52
52
  notification: NotificationManager,
53
+ sum_batch_size: int,
53
54
  ) -> None:
54
55
  self.logger = logging.getLogger(f"{__name__}.'{dst_address.net_id}'")
55
56
  self.transport = transport
56
57
  self.dst_address = dst_address
57
58
  self.parser = parser
59
+ self.sum_batch_size = sum_batch_size
58
60
  self._cache = cache
59
61
  self._notification = notification
60
62
 
61
63
  @classmethod
62
64
  def create_tcp(
63
- cls, src: AmsAddress, dst: AmsAddress, ip: str, port: int = 48898
65
+ cls,
66
+ src: AmsAddress,
67
+ dst: AmsAddress,
68
+ ip: str,
69
+ port: int = 48898,
70
+ sum_batch_size: int = 500,
64
71
  ) -> "AdsClient":
65
72
  """
66
73
  Create a new ADS client with TCP transport.
@@ -68,11 +75,14 @@ class AdsClient:
68
75
  :param dst: The destination AMS address
69
76
  :param ip: The target IP address
70
77
  :param port: The target port
78
+ :param sum_batch_size: Maximum commands per ADS sum command (1..500)
71
79
  :return: An instance of AdsClient
72
80
  """
73
81
  parser = AdsSymbolParser([])
74
82
  transport = AdsTcpTransport(src_address=src, ip=ip, port=port)
75
- cache = AdsSymbolCache(transport=transport, dst_address=dst)
83
+ cache = AdsSymbolCache(
84
+ transport=transport, dst_address=dst, batch_size=sum_batch_size
85
+ )
76
86
  notification_manager = NotificationManager(
77
87
  transport=transport,
78
88
  dst_address=dst,
@@ -85,21 +95,28 @@ class AdsClient:
85
95
  dst_address=dst,
86
96
  cache=cache,
87
97
  notification=notification_manager,
98
+ sum_batch_size=sum_batch_size,
88
99
  )
89
100
 
90
101
  @classmethod
91
102
  def create_from_transport(
92
- cls, dst: AmsAddress, transport: ITransport
103
+ cls,
104
+ dst: AmsAddress,
105
+ transport: ITransport,
106
+ sum_batch_size: int = 500,
93
107
  ) -> "AdsClient":
94
108
  """
95
109
  Create a new ADS client with an existing transport instance.
96
110
  :param src: The source AMS address
97
111
  :param dst: The destination AMS address
98
112
  :param transport: The existing transport instance
113
+ :param sum_batch_size: Maximum commands per ADS sum command (1..500)
99
114
  :return: An instance of AdsClient
100
115
  """
101
116
  parser = AdsSymbolParser([])
102
- cache = AdsSymbolCache(transport=transport, dst_address=dst)
117
+ cache = AdsSymbolCache(
118
+ transport=transport, dst_address=dst, batch_size=sum_batch_size
119
+ )
103
120
  notification_manager = NotificationManager(
104
121
  transport=transport,
105
122
  dst_address=dst,
@@ -112,6 +129,7 @@ class AdsClient:
112
129
  dst_address=dst,
113
130
  cache=cache,
114
131
  notification=notification_manager,
132
+ sum_batch_size=sum_batch_size,
115
133
  )
116
134
 
117
135
  async def connect(self) -> None:
@@ -255,7 +273,8 @@ class AdsClient:
255
273
  exceptions.append(AdsCommandError(error_code, symbol_path))
256
274
 
257
275
  if exceptions:
258
- raise ExceptionGroup("One or more symbol read errors occurred", exceptions)
276
+ raise ExceptionGroup(
277
+ "One or more symbol read errors occurred", exceptions)
259
278
 
260
279
  async def read_symbols_by_names(
261
280
  self, symbol_names: set[str], raise_errors: bool = True
@@ -299,6 +318,7 @@ class AdsClient:
299
318
  )
300
319
  for _, info in readable
301
320
  ],
321
+ batch_size=self.sum_batch_size,
302
322
  )
303
323
  response = await sum_read.execute()
304
324
 
@@ -29,12 +29,14 @@ class AdsSymbolCache:
29
29
  self,
30
30
  transport: ITransport,
31
31
  dst_address: AmsAddress,
32
+ batch_size: int,
32
33
  ttl_seconds: int = 21600,
33
34
  ) -> None:
34
35
  self._logger = logging.getLogger(f"{__name__}.'{dst_address.net_id}'")
35
36
  self._transport = transport
36
37
  self._dst_address = dst_address
37
38
  self._ttl_seconds = ttl_seconds
39
+ self._batch_size = batch_size
38
40
  self._cache: dict[str, tuple[int, AdsErrorCode, SymbolInfo]] = {}
39
41
  self._cache_monitor_task: asyncio.Task[None] | None = None
40
42
 
@@ -175,6 +177,7 @@ class AdsSymbolCache:
175
177
  transport=self._transport,
176
178
  ams_address=self._dst_address,
177
179
  symbol_names=request_list,
180
+ batch_size=self._batch_size,
178
181
  )
179
182
  response = await function.execute()
180
183
  for (error, symbol_info), symbol_name in zip(
@@ -12,14 +12,19 @@ from aioads.ams_address import AmsAddress
12
12
  from aioads.commands.ads_read import AdsReadCommand, AdsReadResponse
13
13
  from aioads.commands.ads_read_write import AdsReadWriteCommand
14
14
  from aioads.commands.errors import AdsCommandError
15
- from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
15
+ from aioads.functions.ads_function import (
16
+ AdsFunctionSymbolGroup,
17
+ IAdsFunction,
18
+ )
16
19
  from aioads.stream import AdsStream
17
20
  from aioads.transport import ITransport
18
21
 
19
22
 
20
23
  class AdsSumRead(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
21
24
  """
22
- Ads sum read command that allows batching up to 500 `AdsReadCommand` in a single call
25
+ Ads sum read command that batches `AdsReadCommand` calls.
26
+ The PLC accepts at most 500 commands per sum command; larger command lists
27
+ are transparently split into multiple sum commands of at most `batch_size`.
23
28
  """
24
29
 
25
30
  ERROR_CODE_STRUCT_DEF: Final[Struct] = Struct("<I")
@@ -29,46 +34,60 @@ class AdsSumRead(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
29
34
  transport: ITransport,
30
35
  ams_address: AmsAddress,
31
36
  commands: list[AdsReadCommand],
37
+ batch_size: int,
32
38
  ) -> None:
33
39
  self.transport = transport
34
40
  self.ams_address = ams_address
35
41
  self.commands = commands
42
+ self.batch_size = batch_size
36
43
 
37
- def serialize(self) -> bytes:
44
+ def serialize(self, commands: list[AdsReadCommand]) -> bytes:
38
45
  """
39
- serialize all commands to bytes
46
+ serialize the given commands to bytes
40
47
  """
41
- return b"".join(command.serialize() for command in self.commands)
48
+ return b"".join(command.serialize() for command in commands)
42
49
 
43
50
  async def execute(self) -> list[tuple[AdsReadResponse, AdsStream]]:
44
51
  if len(self.commands) == 0:
45
- raise ValueError("At least one command is required for ADS Sum Read")
46
- if len(self.commands) > 500:
47
- raise ValueError("Too many commands for ADS Sum Read, maximum is 500")
52
+ raise ValueError(
53
+ "At least one command is required for ADS Sum Read")
48
54
 
49
- payload = self.serialize()
55
+ if self.batch_size <= 0 or self.batch_size > 500:
56
+ raise ValueError("Batch size must be between 1 and 500")
57
+
58
+ response: list[tuple[AdsReadResponse, AdsStream]] = []
59
+ for batch_start in range(0, len(self.commands), self.batch_size):
60
+ batch = self.commands[batch_start: batch_start + self.batch_size]
61
+ response.extend(await self._execute_batch(batch))
62
+ return response
63
+
64
+ async def _execute_batch(
65
+ self, commands: list[AdsReadCommand]
66
+ ) -> list[tuple[AdsReadResponse, AdsStream]]:
67
+ payload = self.serialize(commands)
50
68
  command = AdsReadWriteCommand(
51
69
  transport=self.transport,
52
70
  ams_address=self.ams_address,
53
71
  idx_group=AdsFunctionSymbolGroup.ADSIGRP_SUM_READ,
54
- idx_offset=len(self.commands),
72
+ idx_offset=len(commands),
55
73
  read_length=sum(
56
- cmd.length + 4 for cmd in self.commands
74
+ cmd.length + 4 for cmd in commands
57
75
  ), # +4 for the error code per command
58
76
  write_length=len(payload),
59
77
  write_data=payload,
60
78
  )
61
79
  header, read_payload = await command.request()
62
80
  if not header.error_code.ok:
63
- raise AdsCommandError(header.error_code, "Failed to execute ADS Sum Read")
81
+ raise AdsCommandError(
82
+ header.error_code, "Failed to execute ADS Sum Read")
64
83
  error_stream = read_payload.sub_stream(
65
84
  # 4 bytes per error code
66
- len(self.commands)
85
+ len(commands)
67
86
  * self.ERROR_CODE_STRUCT_DEF.size
68
87
  )
69
88
 
70
89
  response: list[tuple[AdsReadResponse, AdsStream]] = []
71
- for cmd in self.commands:
90
+ for cmd in commands:
72
91
  error_code = AdsErrorCode(
73
92
  error_stream.read_struct(self.ERROR_CODE_STRUCT_DEF)[0]
74
93
  )
@@ -54,7 +54,10 @@ from aioads.ams_address import AmsAddress
54
54
  from aioads.commands.ads_read import AdsReadResponse
55
55
  from aioads.commands.ads_read_write import AdsReadWriteCommand
56
56
  from aioads.commands.errors import AdsCommandError
57
- from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
57
+ from aioads.functions.ads_function import (
58
+ AdsFunctionSymbolGroup,
59
+ IAdsFunction,
60
+ )
58
61
  from aioads.stream import AdsStream
59
62
  from aioads.transport import ITransport
60
63
 
@@ -64,6 +67,8 @@ class AdsSumReadWrite(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
64
67
  ADS Sum Read/Write function to send a READ/WRITE Command in batch.
65
68
  This function allows multiple read/write commands to be sent in a single request,
66
69
  reducing communication overhead.
70
+ The PLC accepts at most 500 commands per sum command; larger command lists
71
+ are transparently split into multiple sum commands of at most `batch_size`.
67
72
  """
68
73
 
69
74
  def __init__(
@@ -71,37 +76,48 @@ class AdsSumReadWrite(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
71
76
  transport: ITransport,
72
77
  ams_address: AmsAddress,
73
78
  commands: list[AdsReadWriteCommand],
79
+ batch_size: int,
74
80
  ) -> None:
75
81
  self.transport = transport
76
82
  self.ams_address = ams_address
77
83
  self.commands = commands
84
+ self.batch_size = batch_size
78
85
 
79
- def serialize(self) -> bytes:
86
+ def serialize(self, commands: list[AdsReadWriteCommand]) -> bytes:
80
87
  """
81
- Serialize the ADS Sum Read/Write commands into bytes.
88
+ Serialize the given ADS Sum Read/Write commands into bytes.
82
89
  :return: Serialized bytes of the sum read/write command.
83
90
  """
84
- data = b"".join(cmd.serialize_header() for cmd in self.commands)
85
- data += b"".join(cmd.payload for cmd in self.commands)
91
+ data = b"".join(cmd.serialize_header() for cmd in commands)
92
+ data += b"".join(cmd.payload for cmd in commands)
86
93
  return data
87
94
 
88
95
  async def execute(self) -> list[tuple[AdsReadResponse, AdsStream]]:
89
96
  """
90
97
  Request execution of the ADS Sum Read/Write function.
91
- Returns an async generator yielding the responses for each command in the batch.
92
- :return: An async generator yielding tuples of (AdsReadResponse, AdsStream) for each command.
98
+ :return: A list of tuples of (AdsReadResponse, AdsStream) for each command.
93
99
  """
94
100
  if len(self.commands) == 0:
95
- raise ValueError("At least one command is required for ADS Sum Read/Write")
96
- if len(self.commands) > 500:
97
- raise ValueError("Too many commands for ADS Sum Read/Write, maximum is 500")
101
+ raise ValueError(
102
+ "At least one command is required for ADS Sum Read/Write")
103
+ if self.batch_size <= 0 or self.batch_size > 500:
104
+ raise ValueError("Batch size must be between 1 and 500")
98
105
 
99
- payload = self.serialize()
106
+ response: list[tuple[AdsReadResponse, AdsStream]] = []
107
+ for batch_start in range(0, len(self.commands), self.batch_size):
108
+ batch = self.commands[batch_start: batch_start + self.batch_size]
109
+ response.extend(await self._execute_batch(batch))
110
+ return response
111
+
112
+ async def _execute_batch(
113
+ self, commands: list[AdsReadWriteCommand]
114
+ ) -> list[tuple[AdsReadResponse, AdsStream]]:
115
+ payload = self.serialize(commands)
100
116
  total_read_length = sum(
101
- cmd.read_length + 8 for cmd in self.commands
117
+ cmd.read_length + 8 for cmd in commands
102
118
  ) # 8 bytes for each error code
103
119
  total_write_length = sum(
104
- cmd.write_length + (4 * 4) for cmd in self.commands
120
+ cmd.write_length + (4 * 4) for cmd in commands
105
121
  ) # 8 Bytes for the fixed header
106
122
  assert total_write_length == len(
107
123
  payload
@@ -110,7 +126,7 @@ class AdsSumReadWrite(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
110
126
  transport=self.transport,
111
127
  ams_address=self.ams_address,
112
128
  idx_group=AdsFunctionSymbolGroup.ADSIGRP_SUM_READ_WRITE,
113
- idx_offset=len(self.commands),
129
+ idx_offset=len(commands),
114
130
  read_length=total_read_length,
115
131
  write_length=total_write_length,
116
132
  write_data=payload,
@@ -121,11 +137,11 @@ class AdsSumReadWrite(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
121
137
  header.error_code, "Failed to execute ADS Sum Read/Write"
122
138
  )
123
139
  read_response_stream = read_payload.sub_stream(
124
- len(self.commands) * AdsReadResponse.STRUCT_DEF.size
140
+ len(commands) * AdsReadResponse.STRUCT_DEF.size
125
141
  )
126
142
 
127
143
  response: list[tuple[AdsReadResponse, AdsStream]] = []
128
- for _ in self.commands:
144
+ for _ in commands:
129
145
  read_response = AdsReadResponse.deserialize(read_response_stream)
130
146
  response.append(
131
147
  (read_response, read_payload.sub_stream(read_response.length))
@@ -10,7 +10,10 @@ from aioads.ads_error_codes import AdsErrorCode
10
10
  from aioads.ams_address import AmsAddress
11
11
  from aioads.commands.ads_read_write import AdsReadWriteCommand
12
12
  from aioads.commands.errors import AdsCommandError
13
- from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
13
+ from aioads.functions.ads_function import (
14
+ AdsFunctionSymbolGroup,
15
+ IAdsFunction,
16
+ )
14
17
  from aioads.functions.ads_sum_read_write import AdsSumReadWrite
15
18
  from aioads.stream import AdsStream
16
19
  from aioads.transport import ITransport
@@ -114,9 +117,12 @@ class SymbolInfo:
114
117
  type_name_length,
115
118
  comment_length,
116
119
  ) = data.read_struct(SymbolInfo.FIXED_STRUCT)
117
- symbol_name = data.read(symbol_name_length + 1).rstrip(b"\x00").decode("cp1252")
118
- type_name = data.read(type_name_length + 1).rstrip(b"\x00").decode("cp1252")
119
- comment = data.read(comment_length + 1).rstrip(b"\x00").decode("cp1252")
120
+ symbol_name = data.read(symbol_name_length +
121
+ 1).rstrip(b"\x00").decode("cp1252")
122
+ type_name = data.read(
123
+ type_name_length + 1).rstrip(b"\x00").decode("cp1252")
124
+ comment = data.read(
125
+ comment_length + 1).rstrip(b"\x00").decode("cp1252")
120
126
 
121
127
  # Move the stream position to the end of the entry
122
128
  data.seek(start_pos + entry_length)
@@ -180,7 +186,8 @@ class SymbolInfoByNameEx(IAdsFunction[SymbolInfo]):
180
186
  )
181
187
  header, read_payload = await command.request()
182
188
  if not header.error_code.ok:
183
- raise AdsCommandError(header.error_code, "Failed to read symbol info")
189
+ raise AdsCommandError(
190
+ header.error_code, "Failed to read symbol info")
184
191
  symbol_info = SymbolInfo.deserialize(read_payload)
185
192
  return symbol_info
186
193
 
@@ -189,21 +196,21 @@ class SymbolInfoByNameExSumRead(IAdsFunction[list[tuple[AdsErrorCode, SymbolInfo
189
196
  """
190
197
  This function utilizes the `AdsSumReadWrite` function to
191
198
  send multiple `SymbolInfoByNameEx` function calls in a batch.
192
- Requests for more symbols than the PLC-side batch limit are
193
- transparently split into multiple sum read/write calls.
199
+ Requests for more symbols than `batch_size` are transparently
200
+ split into multiple sum read/write calls by `AdsSumReadWrite`.
194
201
  """
195
202
 
196
- BATCH_SIZE = 500
197
-
198
203
  def __init__(
199
204
  self,
200
205
  transport: ITransport,
201
206
  ams_address: AmsAddress,
202
207
  symbol_names: list[str],
208
+ batch_size: int,
203
209
  ) -> None:
204
210
  self.transport = transport
205
211
  self.ams_address = ams_address
206
212
  self.symbol_names = symbol_names
213
+ self.batch_size = batch_size
207
214
 
208
215
  def create_sub_commands(self) -> list[AdsReadWriteCommand]:
209
216
  """
@@ -226,20 +233,19 @@ class SymbolInfoByNameExSumRead(IAdsFunction[list[tuple[AdsErrorCode, SymbolInfo
226
233
  return commands
227
234
 
228
235
  async def execute(self):
229
- commands = self.create_sub_commands()
236
+ command = AdsSumReadWrite(
237
+ transport=self.transport,
238
+ ams_address=self.ams_address,
239
+ commands=self.create_sub_commands(),
240
+ batch_size=self.batch_size,
241
+ )
230
242
  response = list[tuple[AdsErrorCode, SymbolInfo]]()
231
- for batch_start in range(0, len(commands), self.BATCH_SIZE):
232
- command = AdsSumReadWrite(
233
- transport=self.transport,
234
- ams_address=self.ams_address,
235
- commands=commands[batch_start : batch_start + self.BATCH_SIZE],
236
- )
237
- for header, read_payload in await command.execute():
238
- # If we have an error and no payload we return a void symbol info
239
- # we raise no exception to let the client handle the error for each symbol individually
240
- if not header.error_code.ok and read_payload.length == 0:
241
- response.append((header.error_code, SymbolInfo.void()))
242
- continue
243
- symbol_info = SymbolInfo.deserialize(read_payload)
244
- response.append((header.error_code, symbol_info))
243
+ for header, read_payload in await command.execute():
244
+ # If we have an error and no payload we return a void symbol info
245
+ # we raise no exception to let the client handle the error for each symbol individually
246
+ if not header.error_code.ok and read_payload.length == 0:
247
+ response.append((header.error_code, SymbolInfo.void()))
248
+ continue
249
+ symbol_info = SymbolInfo.deserialize(read_payload)
250
+ response.append((header.error_code, symbol_info))
245
251
  return response
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aioads"
3
- version = "0.1.0.dev9"
3
+ version = "0.1.0.dev10"
4
4
  description = "An asynchronous Python library for communicating with Beckhoff TwinCAT PLCs"
5
5
  authors = [
6
6
  { name = "MkKiefer", email = "102972583+MkKiefer@users.noreply.github.com" },
@@ -22,6 +22,7 @@ Like the performance tests, the budgets below are generous upper bounds, not
22
22
  benchmarks. A failure means a leak or a pathological regression, not a small
23
23
  change. Adjust the constants when they no longer match the test environment.
24
24
  """
25
+ import asyncio
25
26
  import gc
26
27
  import tracemalloc
27
28
 
@@ -60,8 +61,29 @@ MEASURED_CYCLES = 5
60
61
  CYCLE_GROWTH_MAX_BYTES = 64 * 1024
61
62
 
62
63
 
63
- def _heap_bytes() -> int:
64
- """Collect garbage and return the currently traced heap size."""
64
+ async def _drain_cancelled_timers() -> None:
65
+ """
66
+ Force asyncio to evict cancelled timer handles from its schedule.
67
+
68
+ Every transport request arms a `wait_for` timeout; the response arrives
69
+ long before it fires, so the timer is cancelled — but cancelled
70
+ `TimerHandle`s stay reachable in the loop's schedule until asyncio's lazy
71
+ cleanup runs (only once cancelled handles exceed 100 and half the heap).
72
+ Heap snapshots would otherwise measure a random phase of that saw-tooth
73
+ (tens of KiB) instead of actual growth. Scheduling and cancelling enough
74
+ dummy timers pushes the cancelled ratio over the cleanup threshold, so
75
+ the next loop iteration compacts the schedule deterministically.
76
+ """
77
+ loop = asyncio.get_running_loop()
78
+ handles = [loop.call_later(3600, lambda: None) for _ in range(256)]
79
+ for handle in handles:
80
+ handle.cancel()
81
+ await asyncio.sleep(0)
82
+
83
+
84
+ async def _heap_bytes() -> int:
85
+ """Drain cancelled timers, collect garbage and return the traced heap size."""
86
+ await _drain_cancelled_timers()
65
87
  gc.collect()
66
88
  return tracemalloc.get_traced_memory()[0]
67
89
 
@@ -102,28 +124,28 @@ class TestMemory(unittest.IsolatedAsyncioTestCase):
102
124
 
103
125
  async def test_read_symbols_memory_lifecycle(self) -> None:
104
126
  """Measure startup cost, runtime growth and cleanup residue."""
105
- baseline = _heap_bytes()
127
+ baseline = await _heap_bytes()
106
128
 
107
129
  # Part 1: startup - connect (datatype upload) + first cache fill.
108
130
  client = create_client(self.config)
109
131
  await client.connect()
110
132
  await self._read_once(client)
111
- startup_cost = _heap_bytes() - baseline
133
+ startup_cost = await _heap_bytes() - baseline
112
134
 
113
135
  # Part 2: runtime - steady-state growth and transient peak.
114
136
  for _ in range(WARMUP_READS):
115
137
  await self._read_once(client)
116
- before_reads = _heap_bytes()
138
+ before_reads = await _heap_bytes()
117
139
  tracemalloc.reset_peak()
118
140
  for _ in range(MEASURED_READS):
119
141
  await self._read_once(client)
120
142
  runtime_peak = tracemalloc.get_traced_memory()[1] - before_reads
121
- runtime_growth = _heap_bytes() - before_reads
143
+ runtime_growth = await _heap_bytes() - before_reads
122
144
 
123
145
  # Part 3: cleanup - disconnect, drop the client, measure the residue.
124
146
  await client.disconnect()
125
147
  del client
126
- residue = _heap_bytes() - baseline
148
+ residue = await _heap_bytes() - baseline
127
149
 
128
150
  print(
129
151
  f"\nMemory lifecycle report"
@@ -173,11 +195,11 @@ class TestMemory(unittest.IsolatedAsyncioTestCase):
173
195
  # The first cycle absorbs one-time allocations that live for the
174
196
  # rest of the interpreter (loggers, enum caches, interned strings).
175
197
  await one_cycle()
176
- baseline = _heap_bytes()
198
+ baseline = await _heap_bytes()
177
199
 
178
200
  for _ in range(MEASURED_CYCLES):
179
201
  await one_cycle()
180
- growth = _heap_bytes() - baseline
202
+ growth = await _heap_bytes() - baseline
181
203
 
182
204
  print(
183
205
  f"\nConnect cycle report: {MEASURED_CYCLES} cycles grew the heap "
@@ -3,6 +3,8 @@
3
3
  import unittest
4
4
  from unittest.mock import AsyncMock
5
5
 
6
+ from parameterized import parameterized
7
+
6
8
  from aioads.commands.ads_read import AdsReadCommand
7
9
  from aioads.functions.ads_sum_read import AdsSumRead
8
10
  from tests.builders import (
@@ -24,9 +26,12 @@ class TestAdsSumRead(unittest.IsolatedAsyncioTestCase):
24
26
  AdsReadCommand(self.transport, self.address, 0x4020, 8, 2),
25
27
  ]
26
28
 
27
- def _function(self, commands) -> AdsSumRead:
29
+ def _function(self, commands, batch_size: int = 500) -> AdsSumRead:
28
30
  return AdsSumRead(
29
- transport=self.transport, ams_address=self.address, commands=commands
31
+ transport=self.transport,
32
+ ams_address=self.address,
33
+ commands=commands,
34
+ batch_size=batch_size,
30
35
  )
31
36
 
32
37
  def test_serialize_concatenates_each_command_payload(self) -> None:
@@ -34,33 +39,65 @@ class TestAdsSumRead(unittest.IsolatedAsyncioTestCase):
34
39
  function = self._function(self.commands)
35
40
 
36
41
  # Act
37
- result = function.serialize()
42
+ result = function.serialize(self.commands)
38
43
 
39
44
  # Assert
40
45
  self.assertEqual(
41
46
  result, self.commands[0].serialize() + self.commands[1].serialize()
42
47
  )
43
48
 
44
- async def test_execute_empty_commands_raises_value_error(self) -> None:
49
+ @parameterized.expand([
50
+ ("zero", 0),
51
+ ("above_maximum", 501),
52
+ ])
53
+ async def test_execute_batch_size_out_of_range_raises_value_error(
54
+ self, _name: str, batch_size: int
55
+ ) -> None:
45
56
  # Arrange
46
- function = self._function([])
57
+ function = self._function(self.commands, batch_size=batch_size)
47
58
 
48
59
  # Act / Assert
49
60
  with self.assertRaises(ValueError) as ctx:
50
61
  await function.execute()
51
62
 
52
- self.assertIn("At least one command", str(ctx.exception))
63
+ self.assertIn("between 1 and 500", str(ctx.exception))
53
64
 
54
- async def test_execute_too_many_commands_raises_value_error(self) -> None:
65
+ async def test_execute_empty_commands_raises_value_error(self) -> None:
55
66
  # Arrange
56
- many = [AdsReadCommand(self.transport, self.address, 0, 0, 1) for _ in range(501)]
57
- function = self._function(many)
67
+ function = self._function([])
58
68
 
59
69
  # Act / Assert
60
70
  with self.assertRaises(ValueError) as ctx:
61
71
  await function.execute()
62
72
 
63
- self.assertIn("maximum is 500", str(ctx.exception))
73
+ self.assertIn("At least one command", str(ctx.exception))
74
+
75
+ async def test_execute_splits_commands_into_batches(self) -> None:
76
+ # Arrange: 3 commands with batch_size 2 -> one request with 2 commands,
77
+ # one request with the remaining command
78
+ commands = self.commands + [
79
+ AdsReadCommand(self.transport, self.address, 0x4020, 16, 1)
80
+ ]
81
+ first_batch_body = (
82
+ (0).to_bytes(4, "little") + (0).to_bytes(4, "little")
83
+ + b"\x01\x02\x03\x04" + b"\xaa\xbb"
84
+ )
85
+ second_batch_body = (0).to_bytes(4, "little") + b"\xcc"
86
+ self.transport.request = AsyncMock(
87
+ side_effect=[
88
+ (make_ams_header(), make_stream(make_read_payload(first_batch_body))),
89
+ (make_ams_header(), make_stream(make_read_payload(second_batch_body))),
90
+ ]
91
+ )
92
+ function = self._function(commands, batch_size=2)
93
+
94
+ # Act
95
+ response = await function.execute()
96
+
97
+ # Assert
98
+ self.assertEqual(self.transport.request.await_count, 2)
99
+ self.assertEqual(len(response), 3)
100
+ self.assertEqual(response[2][1].read(1), b"\xcc")
64
101
 
65
102
  async def test_execute_returns_response_and_data_per_command(self) -> None:
66
103
  # Arrange: 2 error codes (4 bytes each) followed by 4 + 2 data bytes
@@ -3,6 +3,8 @@
3
3
  import unittest
4
4
  from unittest.mock import AsyncMock
5
5
 
6
+ from parameterized import parameterized
7
+
6
8
  from aioads.commands.ads_read import AdsReadResponse
7
9
  from aioads.commands.ads_read_write import AdsReadWriteCommand
8
10
  from aioads.ads_error_codes import AdsErrorCode
@@ -26,9 +28,12 @@ class TestAdsSumReadWrite(unittest.IsolatedAsyncioTestCase):
26
28
  AdsReadWriteCommand(self.transport, self.address, 0xF009, 0, 2, 3, b"xyz"),
27
29
  ]
28
30
 
29
- def _function(self, commands) -> AdsSumReadWrite:
31
+ def _function(self, commands, batch_size: int = 500) -> AdsSumReadWrite:
30
32
  return AdsSumReadWrite(
31
- transport=self.transport, ams_address=self.address, commands=commands
33
+ transport=self.transport,
34
+ ams_address=self.address,
35
+ commands=commands,
36
+ batch_size=batch_size,
32
37
  )
33
38
 
34
39
  def test_serialize_places_all_headers_before_all_payloads(self) -> None:
@@ -36,7 +41,7 @@ class TestAdsSumReadWrite(unittest.IsolatedAsyncioTestCase):
36
41
  function = self._function(self.commands)
37
42
 
38
43
  # Act
39
- result = function.serialize()
44
+ result = function.serialize(self.commands)
40
45
 
41
46
  # Assert
42
47
  expected = (
@@ -57,19 +62,52 @@ class TestAdsSumReadWrite(unittest.IsolatedAsyncioTestCase):
57
62
 
58
63
  self.assertIn("At least one command", str(ctx.exception))
59
64
 
60
- async def test_execute_too_many_commands_raises_value_error(self) -> None:
65
+ @parameterized.expand([
66
+ ("zero", 0),
67
+ ("above_maximum", 501),
68
+ ])
69
+ async def test_execute_batch_size_out_of_range_raises_value_error(
70
+ self, _name: str, batch_size: int
71
+ ) -> None:
61
72
  # Arrange
62
- many = [
63
- AdsReadWriteCommand(self.transport, self.address, 0, 0, 1, 0, b"")
64
- for _ in range(501)
65
- ]
66
- function = self._function(many)
73
+ function = self._function(self.commands, batch_size=batch_size)
67
74
 
68
75
  # Act / Assert
69
76
  with self.assertRaises(ValueError) as ctx:
70
77
  await function.execute()
71
78
 
72
- self.assertIn("maximum is 500", str(ctx.exception))
79
+ self.assertIn("between 1 and 500", str(ctx.exception))
80
+
81
+ async def test_execute_splits_commands_into_batches(self) -> None:
82
+ # Arrange: 3 commands with batch_size 2 -> one request with 2 commands,
83
+ # one request with the remaining command
84
+ commands = self.commands + [
85
+ AdsReadWriteCommand(self.transport, self.address, 0xF009, 0, 1, 1, b"z")
86
+ ]
87
+ first_batch_body = (
88
+ AdsReadResponse(AdsErrorCode(0), length=4).serialize()
89
+ + AdsReadResponse(AdsErrorCode(0), length=2).serialize()
90
+ + b"\x01\x02\x03\x04"
91
+ + b"\xaa\xbb"
92
+ )
93
+ second_batch_body = (
94
+ AdsReadResponse(AdsErrorCode(0), length=1).serialize() + b"\xcc"
95
+ )
96
+ self.transport.request = AsyncMock(
97
+ side_effect=[
98
+ (make_ams_header(), make_stream(make_read_payload(first_batch_body))),
99
+ (make_ams_header(), make_stream(make_read_payload(second_batch_body))),
100
+ ]
101
+ )
102
+ function = self._function(commands, batch_size=2)
103
+
104
+ # Act
105
+ response = await function.execute()
106
+
107
+ # Assert
108
+ self.assertEqual(self.transport.request.await_count, 2)
109
+ self.assertEqual(len(response), 3)
110
+ self.assertEqual(response[2][1].read(1), b"\xcc")
73
111
 
74
112
  async def test_execute_returns_read_response_and_data_per_command(self) -> None:
75
113
  # Arrange: per-command (error, length) headers, then per-command payloads
@@ -107,6 +107,7 @@ class TestSymbolInfoByNameExSumRead(unittest.IsolatedAsyncioTestCase):
107
107
  transport=self.transport,
108
108
  ams_address=make_ams_address(),
109
109
  symbol_names=["main.a", "main.b"],
110
+ batch_size=500,
110
111
  )
111
112
 
112
113
  def test_create_sub_commands_one_per_symbol_name(self) -> None:
@@ -72,7 +72,7 @@ class TestAdsClient(unittest.IsolatedAsyncioTestCase):
72
72
  self.transport = make_transport()
73
73
  self.address = make_ams_address()
74
74
  self.parser = AdsSymbolParser([])
75
- self.cache = AdsSymbolCache(self.transport, self.address)
75
+ self.cache = AdsSymbolCache(self.transport, self.address, batch_size=500)
76
76
  self.notification = NotificationManager(
77
77
  self.transport, self.address, self.cache, self.parser
78
78
  )
@@ -82,6 +82,7 @@ class TestAdsClient(unittest.IsolatedAsyncioTestCase):
82
82
  parser=self.parser,
83
83
  cache=self.cache,
84
84
  notification=self.notification,
85
+ sum_batch_size=500,
85
86
  )
86
87
 
87
88
  async def test_read_symbol_info_by_name_delegates_to_cache(self) -> None:
@@ -64,7 +64,7 @@ class TestNotificationManager(unittest.IsolatedAsyncioTestCase):
64
64
  self.transport = make_transport()
65
65
  self.address = make_ams_address()
66
66
  # Real typed collaborators; only the methods under control are replaced.
67
- self.cache = AdsSymbolCache(self.transport, self.address)
67
+ self.cache = AdsSymbolCache(self.transport, self.address, batch_size=500)
68
68
  self.parser = AdsSymbolParser([])
69
69
  self.manager = NotificationManager(
70
70
  transport=self.transport,
@@ -60,6 +60,7 @@ class TestAdsSymbolCache(unittest.IsolatedAsyncioTestCase):
60
60
  transport=self.transport,
61
61
  dst_address=self.address,
62
62
  ttl_seconds=3600,
63
+ batch_size=500,
63
64
  )
64
65
  self.info = make_symbol_info("MAIN.VALUE")
65
66
 
@@ -98,7 +99,9 @@ class TestAdsSymbolCache(unittest.IsolatedAsyncioTestCase):
98
99
  async def test_read_symbol_info_by_name_expired_entry_refetches(self) -> None:
99
100
  # Arrange — zero TTL means an entry is never valid on the next lookup.
100
101
  # A fresh stream is built per call so the second fetch reads from the start.
101
- cache = AdsSymbolCache(self.transport, self.address, ttl_seconds=0)
102
+ cache = AdsSymbolCache(
103
+ self.transport, self.address, ttl_seconds=0, batch_size=500
104
+ )
102
105
  self.transport.request = AsyncMock(
103
106
  side_effect=lambda **kwargs: single_read_response(self.info)
104
107
  )
@@ -149,7 +152,9 @@ class TestAdsSymbolCacheMonitor(unittest.IsolatedAsyncioTestCase):
149
152
 
150
153
  def setUp(self) -> None:
151
154
  self.transport = make_transport()
152
- self.cache = AdsSymbolCache(self.transport, make_ams_address())
155
+ self.cache = AdsSymbolCache(
156
+ self.transport, make_ams_address(), batch_size=500
157
+ )
153
158
  # Symbol-table-version read keeps the monitor loop body from erroring.
154
159
  self.transport.request = AsyncMock(
155
160
  return_value=(make_ams_header(), make_stream(make_read_payload(b"\x01")))
File without changes
File without changes
File without changes
File without changes