ezmsg 3.6.0__tar.gz → 3.6.2__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. {ezmsg-3.6.0 → ezmsg-3.6.2}/PKG-INFO +6 -3
  2. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/source/other.rst +7 -1
  3. {ezmsg-3.6.0 → ezmsg-3.6.2}/pyproject.toml +8 -3
  4. ezmsg-3.6.2/src/ezmsg/core/command.py +178 -0
  5. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/graphserver.py +133 -0
  6. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/netprotocol.py +1 -0
  7. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/messagelogger.py +46 -16
  8. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/messages/axisarray.py +13 -6
  9. {ezmsg-3.6.0 → ezmsg-3.6.2}/uv.lock +5 -1
  10. ezmsg-3.6.0/.envrc +0 -2
  11. ezmsg-3.6.0/src/ezmsg/core/command.py +0 -319
  12. {ezmsg-3.6.0 → ezmsg-3.6.2}/.flake8 +0 -0
  13. {ezmsg-3.6.0 → ezmsg-3.6.2}/.git-blame-ignore-revs +0 -0
  14. {ezmsg-3.6.0 → ezmsg-3.6.2}/.github/workflows/ci.yml +0 -0
  15. {ezmsg-3.6.0 → ezmsg-3.6.2}/.github/workflows/publish.yml +0 -0
  16. {ezmsg-3.6.0 → ezmsg-3.6.2}/.gitignore +0 -0
  17. {ezmsg-3.6.0 → ezmsg-3.6.2}/.pre-commit-config.yaml +0 -0
  18. {ezmsg-3.6.0 → ezmsg-3.6.2}/.python-version +0 -0
  19. {ezmsg-3.6.0 → ezmsg-3.6.2}/.readthedocs.yaml +0 -0
  20. {ezmsg-3.6.0 → ezmsg-3.6.2}/LICENSE +0 -0
  21. {ezmsg-3.6.0 → ezmsg-3.6.2}/README.md +0 -0
  22. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/Makefile +0 -0
  23. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/make.bat +0 -0
  24. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/requirements.txt +0 -0
  25. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/source/about.rst +0 -0
  26. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/source/api.rst +0 -0
  27. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/source/conf.py +0 -0
  28. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/source/developer.rst +0 -0
  29. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/source/extensions/sigproc.rst +0 -0
  30. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/source/extensions.rst +0 -0
  31. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/source/getting-started.rst +0 -0
  32. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/source/index.rst +0 -0
  33. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/source/logo.png +0 -0
  34. {ezmsg-3.6.0 → ezmsg-3.6.2}/docs/source/utils.rst +0 -0
  35. {ezmsg-3.6.0 → ezmsg-3.6.2}/examples/.gitignore +0 -0
  36. {ezmsg-3.6.0 → ezmsg-3.6.2}/examples/ezmsg_attach.py +0 -0
  37. {ezmsg-3.6.0 → ezmsg-3.6.2}/examples/ezmsg_configs.py +0 -0
  38. {ezmsg-3.6.0 → ezmsg-3.6.2}/examples/ezmsg_count.py +0 -0
  39. {ezmsg-3.6.0 → ezmsg-3.6.2}/examples/ezmsg_generator.py +0 -0
  40. {ezmsg-3.6.0 → ezmsg-3.6.2}/examples/ezmsg_intro.py +0 -0
  41. {ezmsg-3.6.0 → ezmsg-3.6.2}/examples/ezmsg_log_to_file.py +0 -0
  42. {ezmsg-3.6.0 → ezmsg-3.6.2}/examples/ezmsg_normalterm.py +0 -0
  43. {ezmsg-3.6.0 → ezmsg-3.6.2}/examples/ezmsg_stop.py +0 -0
  44. {ezmsg-3.6.0 → ezmsg-3.6.2}/examples/ezmsg_toy.py +0 -0
  45. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/__init__.py +0 -0
  46. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/__main__.py +0 -0
  47. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/addressable.py +0 -0
  48. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/backend.py +0 -0
  49. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/backendprocess.py +0 -0
  50. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/backpressure.py +0 -0
  51. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/collection.py +0 -0
  52. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/component.py +0 -0
  53. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/dag.py +0 -0
  54. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/graphcontext.py +0 -0
  55. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/message.py +0 -0
  56. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/messagecache.py +0 -0
  57. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/messagemarshal.py +0 -0
  58. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/pubclient.py +0 -0
  59. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/server.py +0 -0
  60. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/settings.py +0 -0
  61. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/shmserver.py +0 -0
  62. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/state.py +0 -0
  63. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/stream.py +0 -0
  64. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/subclient.py +0 -0
  65. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/unit.py +0 -0
  66. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/core/util.py +0 -0
  67. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/__init__.py +0 -0
  68. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/debuglog.py +0 -0
  69. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/gen_to_unit.py +0 -0
  70. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/generator.py +0 -0
  71. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/messagecodec.py +0 -0
  72. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/messagegate.py +0 -0
  73. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/messagequeue.py +0 -0
  74. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/messagereplay.py +0 -0
  75. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/messages/__init__.py +0 -0
  76. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/messages/chunker.py +0 -0
  77. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/messages/key.py +0 -0
  78. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/messages/modify.py +0 -0
  79. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/messages/util.py +0 -0
  80. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/perf_test.py +0 -0
  81. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/rate.py +0 -0
  82. {ezmsg-3.6.0 → ezmsg-3.6.2}/src/ezmsg/util/terminate.py +0 -0
  83. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/__init__.py +0 -0
  84. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/ez_test_utils.py +0 -0
  85. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/messages/__init__.py +0 -0
  86. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/messages/test_axisarray.py +0 -0
  87. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/messages/test_chunker.py +0 -0
  88. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/messages/test_key.py +0 -0
  89. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/messages/test_modify.py +0 -0
  90. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/test_addressable.py +0 -0
  91. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/test_attach.py +0 -0
  92. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/test_connections.py +0 -0
  93. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/test_dag.py +0 -0
  94. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/test_generator.py +0 -0
  95. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/test_graph.py +0 -0
  96. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/test_run.py +0 -0
  97. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/test_shm.py +0 -0
  98. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/test_state.py +0 -0
  99. {ezmsg-3.6.0 → ezmsg-3.6.2}/tests/test_test.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: ezmsg
3
- Version: 3.6.0
3
+ Version: 3.6.2
4
4
  Summary: A simple DAG-based computation model
5
5
  Author-email: Griffin Milsap <griffin.milsap@gmail.com>, Preston Peranich <pperanich@gmail.com>, Chadwick Boulay <chadwick.boulay@gmail.com>
6
6
  License: MIT License
@@ -12,17 +12,20 @@ License: MIT License
12
12
  The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
13
13
 
14
14
  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
15
+ License-File: LICENSE
15
16
  Classifier: Operating System :: OS Independent
16
17
  Classifier: Programming Language :: Python :: 3
17
18
  Requires-Python: >=3.8
18
19
  Requires-Dist: typing-extensions>=4.9.0
20
+ Provides-Extra: axisarray
21
+ Requires-Dist: numpy>=1.24.4; extra == 'axisarray'
19
22
  Provides-Extra: docs
20
23
  Requires-Dist: ezmsg-sigproc>=1.2.3; extra == 'docs'
21
24
  Requires-Dist: sphinx-rtd-theme==2.0.0; extra == 'docs'
22
25
  Requires-Dist: sphinx<=7.2; extra == 'docs'
23
26
  Provides-Extra: test
24
27
  Requires-Dist: flake8>=5.0.4; extra == 'test'
25
- Requires-Dist: numpy>=1.24.2; extra == 'test'
28
+ Requires-Dist: numpy>=1.24.4; extra == 'test'
26
29
  Requires-Dist: pytest-asyncio>=0.23.8; extra == 'test'
27
30
  Requires-Dist: pytest-cov>=5.0.0; extra == 'test'
28
31
  Requires-Dist: pytest>=7.0.0; extra == 'test'
@@ -14,4 +14,10 @@ Still under construction!
14
14
  Notebooks
15
15
  ---------
16
16
 
17
- An ezmsg pipeline can be run in a notebook environment with an extra parameter. When using ``run()``, include the optional kwarg ``force_single_process=True``.
17
+ An ezmsg pipeline can be run in a notebook environment with an extra parameter. When using ``run()``, include the optional kwarg ``force_single_process=True``.
18
+
19
+ Media
20
+ -----
21
+
22
+ * `BCI Society 2025 Poster <https://github.com/ezmsg-org/ezmsg/releases/download/V3.6.1/ezmsg_poster_BCI2025.pdf>`_
23
+ * `Society For Neuroscience 2024 Poster <https://github.com/ezmsg-org/ezmsg/releases/download/V3.6.1/ezmsg_poster_SfN2024.pdf>`_
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ezmsg"
3
- version = "3.6.0"
3
+ version = "3.6.2"
4
4
  description = "A simple DAG-based computation model"
5
5
  authors = [
6
6
  { name = "Griffin Milsap", email = "griffin.milsap@gmail.com" },
@@ -14,18 +14,23 @@ classifiers = [
14
14
  "Operating System :: OS Independent",
15
15
  ]
16
16
  requires-python = ">=3.8"
17
- dependencies = ["typing-extensions >= 4.9.0"]
17
+ dependencies = [
18
+ "typing-extensions >= 4.9.0",
19
+ ]
18
20
 
19
21
  [project.optional-dependencies]
20
22
  test = [
21
23
  "pytest>=7.0.0",
22
- "numpy>=1.24.2",
23
24
  "pytest-asyncio>=0.23.8",
24
25
  "pytest-cov>=5.0.0",
25
26
  "flake8>=5.0.4",
26
27
  "xarray>=2023.1.0;python_version<'3.13'",
28
+ "numpy>=1.24.4",
27
29
  ]
28
30
  docs = ["sphinx<=7.2", "sphinx-rtd-theme==2.0.0", "ezmsg-sigproc>=1.2.3"]
31
+ axisarray = [
32
+ "numpy>=1.24.4",
33
+ ]
29
34
 
30
35
 
31
36
  [project.scripts]
@@ -0,0 +1,178 @@
1
+ import argparse
2
+ import asyncio
3
+ import base64
4
+ import json
5
+ import logging
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import typing
10
+ import webbrowser
11
+ import zlib
12
+
13
+ from .graphserver import GraphService
14
+ from .shmserver import SHMService
15
+ from .netprotocol import (
16
+ Address,
17
+ GRAPHSERVER_ADDR_ENV,
18
+ GRAPHSERVER_PORT_DEFAULT,
19
+ SHMSERVER_ADDR_ENV,
20
+ SHMSERVER_PORT_DEFAULT,
21
+ PUBLISHER_START_PORT_ENV,
22
+ PUBLISHER_START_PORT_DEFAULT,
23
+ close_stream_writer,
24
+ )
25
+
26
+ logger = logging.getLogger("ezmsg")
27
+
28
+
29
+ def cmdline() -> None:
30
+ parser = argparse.ArgumentParser(
31
+ "ezmsg.core",
32
+ description="start and stop core ezmsg server processes",
33
+ epilog=f"""
34
+ You can also change server configuration with environment variables.
35
+ GraphServer will be hosted on ${GRAPHSERVER_ADDR_ENV} (default port: {GRAPHSERVER_PORT_DEFAULT}).
36
+ SHMServer will be hosted on ${SHMSERVER_ADDR_ENV} (default port: {SHMSERVER_PORT_DEFAULT}).
37
+ Publishers will be assigned available ports starting from {PUBLISHER_START_PORT_DEFAULT}. (Change with ${PUBLISHER_START_PORT_ENV})
38
+ """,
39
+ )
40
+
41
+ parser.add_argument(
42
+ "command",
43
+ help="command for ezmsg",
44
+ choices=["serve", "start", "shutdown", "graphviz", "mermaid"],
45
+ )
46
+
47
+ parser.add_argument("--address", help="Address for GraphServer", default=None)
48
+
49
+ parser.add_argument(
50
+ "--target",
51
+ help="Target for mermaid output. Options are 'ink', 'live', and 'play'.",
52
+ default="live",
53
+ )
54
+
55
+ class Args:
56
+ command: str
57
+ address: typing.Optional[str]
58
+ target: str
59
+
60
+ args = parser.parse_args(namespace=Args)
61
+
62
+ graph_address = Address("127.0.0.1", GRAPHSERVER_PORT_DEFAULT)
63
+ if args.address is not None:
64
+ graph_address = Address.from_string(args.address)
65
+ shm_address_str = os.environ.get(
66
+ SHMSERVER_ADDR_ENV, f"127.0.0.1:{SHMSERVER_PORT_DEFAULT}"
67
+ )
68
+ shm_address = Address.from_string(shm_address_str)
69
+
70
+ loop = asyncio.new_event_loop()
71
+ asyncio.set_event_loop(loop)
72
+
73
+ loop.run_until_complete(
74
+ run_command(args.command, graph_address, shm_address, args.target)
75
+ )
76
+
77
+
78
+ async def run_command(
79
+ cmd: str, graph_address: Address, shm_address: Address, target: str = "live"
80
+ ) -> None:
81
+ shm_service = SHMService(shm_address)
82
+ graph_service = GraphService(graph_address)
83
+
84
+ if cmd == "serve":
85
+ logger.info(f"GraphServer Address: {graph_address}")
86
+ logger.info(f"SHMServer Address: {shm_address}")
87
+
88
+ shm_server = shm_service.create_server()
89
+ graph_server = graph_service.create_server()
90
+
91
+ try:
92
+ logger.info("Servers running...")
93
+ graph_server.join()
94
+
95
+ except KeyboardInterrupt:
96
+ logger.info("Interrupt detected; shutting down servers")
97
+
98
+ finally:
99
+ if graph_server is not None:
100
+ graph_server.stop()
101
+
102
+ if shm_server is not None:
103
+ shm_server.stop()
104
+
105
+ elif cmd == "start":
106
+ popen = subprocess.Popen(
107
+ [sys.executable, "-m", "ezmsg.core", "serve", f"--address={graph_address}"]
108
+ )
109
+
110
+ while True:
111
+ try:
112
+ _, writer = await graph_service.open_connection()
113
+ await close_stream_writer(writer)
114
+ _, writer = await shm_service.open_connection()
115
+ await close_stream_writer(writer)
116
+ break
117
+ except ConnectionRefusedError:
118
+ await asyncio.sleep(0.1)
119
+
120
+ logger.info(f"Forked ezmsg servers in PID: {popen.pid}")
121
+
122
+ elif cmd == "shutdown":
123
+ try:
124
+ await graph_service.shutdown()
125
+ logger.info(
126
+ f"Issued shutdown command to GraphServer @ {graph_service.address}"
127
+ )
128
+
129
+ except ConnectionRefusedError:
130
+ logger.warning(
131
+ f"Could not issue shutdown command to GraphServer @ {graph_service.address}; server not running?"
132
+ )
133
+
134
+ elif cmd in ["graphviz", "mermaid"]:
135
+ graph_out = await graph_service.get_formatted_graph(cmd)
136
+ print(graph_out)
137
+ if cmd == "mermaid" and target == "live":
138
+ print(
139
+ "%% If the graph does not render immediately, try toggling the 'Pan & Zoom' button."
140
+ )
141
+
142
+ if cmd == "mermaid":
143
+ webbrowser.open(mm(graph_out, target=target))
144
+
145
+
146
+ def mm(graph: str, target="live") -> str:
147
+ if target != "ink":
148
+ jdict = {
149
+ "code": graph,
150
+ "mermaid": {"theme": "default"},
151
+ "updateDiagram": True,
152
+ "autoSync": True,
153
+ "rough": False,
154
+ }
155
+ graph = json.dumps(jdict)
156
+ graphbytes: bytes = graph.encode("utf8")
157
+
158
+ if target != "ink":
159
+ compress = zlib.compressobj(9, zlib.DEFLATED, 15, 8, zlib.Z_DEFAULT_STRATEGY)
160
+ graphbytes = compress.compress(graphbytes)
161
+ graphbytes += compress.flush()
162
+
163
+ base64_bytes = base64.b64encode(graphbytes)
164
+ base64_string = base64_bytes.decode("ascii")
165
+
166
+ if target == "ink":
167
+ prefix = "https://mermaid.ink/img/"
168
+ elif target in ["live", "play"]:
169
+ type_str = "pako" # or "base64" if we skip compression above.
170
+ if target == "live":
171
+ prefix = f"https://mermaid.live/edit#{type_str}:"
172
+ else: # "play"
173
+ prefix = f"https://www.mermaidchart.com/play#{type_str}:"
174
+ else:
175
+ raise ValueError(
176
+ f"Unknown mermaid target '{target}'. Available options are 'ink', 'live', or 'play'."
177
+ )
178
+ return prefix + base64_string
@@ -1,7 +1,10 @@
1
1
  import asyncio
2
+ from collections import defaultdict
2
3
  import logging
3
4
  import pickle
5
+ from textwrap import indent
4
6
  import typing
7
+ from uuid import uuid4
5
8
 
6
9
  from contextlib import suppress
7
10
 
@@ -28,6 +31,8 @@ from .netprotocol import (
28
31
 
29
32
  logger = logging.getLogger("ezmsg")
30
33
 
34
+ IND = " "
35
+
31
36
 
32
37
  class GraphServer(ThreadedAsyncServer):
33
38
  """
@@ -309,3 +314,131 @@ class GraphService(ServiceManager[GraphServer]):
309
314
  await asyncio.wait_for(reader.read(1), timeout=timeout) # Complete
310
315
  await close_stream_writer(writer)
311
316
  return dag
317
+
318
+ async def get_pruned_graph(self) -> typing.Optional[defaultdict[str, set[str]]]:
319
+ try:
320
+ dag: DAG = await self.dag()
321
+ except (ConnectionRefusedError, ConnectionResetError):
322
+ logger.info(
323
+ f"GraphServer not running @{self.address}, or host is refusing connections"
324
+ )
325
+ return None
326
+
327
+ graph_connections = dag.graph.copy()
328
+ # Let's eliminate proxy topics, i.e. connections with inputs and outputs.
329
+ source_nodes = []
330
+ for node, conns in graph_connections.items():
331
+ if len(conns) > 0:
332
+ source_nodes += [node]
333
+ proxy_topics = []
334
+ for conns in graph_connections.values():
335
+ for conn in conns:
336
+ if conn in source_nodes and conn not in proxy_topics:
337
+ proxy_topics += [conn]
338
+ # Replace Proxy Topics with actual source and downstream
339
+ for proxy_topic in proxy_topics:
340
+ downstreams = graph_connections.pop(proxy_topic)
341
+ logger.info(f"{proxy_topic} downstream connections: {downstreams}")
342
+ for node, conns in graph_connections.items():
343
+ for conn in conns:
344
+ if conn == proxy_topic:
345
+ new_conns = conns.copy()
346
+ new_conns.remove(proxy_topic)
347
+ new_conns.union(downstreams)
348
+ logger.info(
349
+ f"Updating connections for {node} from {conns} to {new_conns}"
350
+ )
351
+ graph_connections[node] = new_conns
352
+
353
+ return dag.graph.copy()
354
+
355
+ async def get_formatted_graph(self, fmt: str, direction: str = "LR") -> str:
356
+ if fmt not in ["mermaid", "graphviz"]:
357
+ raise ValueError(
358
+ f"Invalid format '{fmt}'. Options are 'mermaid' or 'graphviz'"
359
+ )
360
+ graph_connections = await self.get_pruned_graph()
361
+
362
+ if graph_connections is None or not graph_connections:
363
+ return ""
364
+
365
+ # Let's come up with UUID node names
366
+ nodes = set(graph_connections.keys())
367
+ if fmt == "mermaid":
368
+ node_map = {name: f"{str(uuid4())}" for name in nodes}
369
+ else:
370
+ node_map = {name: f'"{str(uuid4())}"' for name in nodes} # graphviz
371
+
372
+ # Construct the graph
373
+ def tree():
374
+ return defaultdict(tree)
375
+
376
+ graph: defaultdict = tree()
377
+
378
+ connections = ""
379
+ for node, conns in graph_connections.items():
380
+ subgraph = graph
381
+ path = node.split("/")
382
+ route = path[:-1]
383
+ stream = path[-1]
384
+ for seg in route:
385
+ subgraph = subgraph[seg]
386
+ subgraph[stream] = node
387
+
388
+ for sub in conns:
389
+ if fmt == "mermaid":
390
+ connections += f"{node_map[node]} --> {node_map[sub]}" + "\n"
391
+ else:
392
+ connections += f"{node_map[node]} -> {node_map[sub]};" + "\n"
393
+
394
+ if fmt == "graphviz":
395
+ header = [
396
+ "digraph EZ {",
397
+ indent(f'rankdir="{direction}"', IND),
398
+ ]
399
+ footer = ["}"]
400
+
401
+ def per_leaf(g, leaf):
402
+ out = None
403
+ if isinstance(g[leaf], defaultdict):
404
+ out = [
405
+ f"subgraph {leaf.lower()} {{",
406
+ indent("cluster = true;", IND),
407
+ indent(f'label = "{leaf}";', IND),
408
+ f"{recurse_graph(g[leaf])}}};",
409
+ ]
410
+ elif isinstance(g[leaf], str):
411
+ out = [f"{node_map[g[leaf]]} [label={leaf}];"]
412
+ return out
413
+
414
+ else: # fmt == mermaid
415
+ header = [f"flowchart {direction}"]
416
+ footer = []
417
+
418
+ def per_leaf(g, leaf):
419
+ out = None
420
+ if isinstance(g[leaf], defaultdict):
421
+ out = [
422
+ f"subgraph {leaf.lower()} [{leaf}]",
423
+ # "direction LR",
424
+ f"{recurse_graph(g[leaf])}",
425
+ "end",
426
+ ]
427
+ elif isinstance(g[leaf], str):
428
+ out = [f"{node_map[g[leaf]]}[{leaf}]"]
429
+ return out
430
+
431
+ def recurse_graph(g: defaultdict):
432
+ out = ""
433
+ for leaf in g:
434
+ leaf_list = per_leaf(g, leaf)
435
+ if leaf_list is not None:
436
+ leaf_list += [""] # Append a newline
437
+ out += indent("\n".join(leaf_list), IND)
438
+ return out[:-1]
439
+
440
+ graph_out = "\n".join(
441
+ header + [recurse_graph(graph), indent(connections, IND)] + footer
442
+ )
443
+
444
+ return graph_out
@@ -177,6 +177,7 @@ def create_socket(
177
177
  ignore_ports: typing.List[int] = RESERVED_PORTS,
178
178
  ) -> socket.socket:
179
179
  sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
180
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
180
181
 
181
182
  if host is None:
182
183
  host = DEFAULT_HOST
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import time
3
+ import typing
3
4
 
4
5
  from io import TextIOWrapper
5
6
  from dataclasses import field
@@ -8,11 +9,10 @@ from pathlib import Path
8
9
  import ezmsg.core as ez
9
10
 
10
11
  from .messagecodec import MessageEncoder, LogStart
12
+ from .rate import Rate
11
13
 
12
- from typing import Optional, Any, Dict, AsyncGenerator
13
14
 
14
-
15
- def log_object(obj: Any) -> str:
15
+ def log_object(obj: typing.Any) -> str:
16
16
  return json.dumps({"ts": time.time(), "obj": obj}, cls=MessageEncoder)
17
17
 
18
18
 
@@ -23,13 +23,18 @@ class MessageLoggerSettings(ez.Settings):
23
23
  Args:
24
24
  output: :py:class:`pathlib.Path` for a file where the messages will be logged.
25
25
  If the file path already exists, the existing file will be truncated to 0 length.
26
+ write_period: Period in seconds for performing a write to disk.
27
+ If <=0, write incoming messages to disk immediately, applying backpressure as needed.
28
+ If >0, messages are buffered and periodically written to disk in a separate task
26
29
  """
27
30
 
28
- output: Optional[Path] = None
31
+ output: typing.Optional[Path] = None
32
+ write_period: float = 0.0 # sec
29
33
 
30
34
 
31
35
  class MessageLoggerState(ez.State):
32
- output_files: Dict[Path, TextIOWrapper] = field(default_factory=dict)
36
+ output_files: typing.Dict[Path, TextIOWrapper] = field(default_factory=dict)
37
+ write_buffer: typing.List[str] = field(default_factory=list)
33
38
 
34
39
 
35
40
  class MessageLogger(ez.Unit):
@@ -55,10 +60,10 @@ class MessageLogger(ez.Unit):
55
60
  to stop logging messages to that path.
56
61
  """
57
62
 
58
- INPUT_MESSAGE = ez.InputStream(Any)
63
+ INPUT_MESSAGE = ez.InputStream(typing.Any)
59
64
  """Pass a piece of data to log it to every open file which the ``MessageLogger`` is using."""
60
65
 
61
- OUTPUT_MESSAGE = ez.OutputStream(Any)
66
+ OUTPUT_MESSAGE = ez.OutputStream(typing.Any)
62
67
  """Messages which are sent to ``INPUT_MESSAGE`` will pass through and be published on ``OUTPUT_MESSAGE``."""
63
68
 
64
69
  OUTPUT_START = ez.OutputStream(Path)
@@ -69,7 +74,7 @@ class MessageLogger(ez.Unit):
69
74
  """If a file passed to ``INPUT_STOP`` is successfully closed, its path will be published to
70
75
  ``OUTPUT_STOP``, otherwise ``None``."""
71
76
 
72
- def open_file(self, filepath: Path) -> Optional[Path]:
77
+ def open_file(self, filepath: Path) -> typing.Optional[Path]:
73
78
  """Returns file path if file successfully opened, otherwise None"""
74
79
  if filepath in self.STATE.output_files:
75
80
  # If the file is already open, we return None
@@ -85,7 +90,7 @@ class MessageLogger(ez.Unit):
85
90
 
86
91
  return filepath
87
92
 
88
- def close_file(self, filepath: Path) -> Optional[Path]:
93
+ def close_file(self, filepath: Path) -> typing.Optional[Path]:
89
94
  """Returns file path if file successfully closed, otherwise None"""
90
95
  if filepath not in self.STATE.output_files:
91
96
  # We haven't opened this file
@@ -103,28 +108,53 @@ class MessageLogger(ez.Unit):
103
108
 
104
109
  @ez.subscriber(INPUT_START)
105
110
  @ez.publisher(OUTPUT_START)
106
- async def start_file(self, message: Path) -> AsyncGenerator:
111
+ async def start_file(self, message: Path) -> typing.AsyncGenerator:
107
112
  out = self.open_file(message)
108
113
  if out is not None:
109
114
  yield (self.OUTPUT_START, out)
110
115
 
111
116
  @ez.subscriber(INPUT_STOP)
112
117
  @ez.publisher(OUTPUT_STOP)
113
- async def stop_file(self, message: Path) -> AsyncGenerator:
118
+ async def stop_file(self, message: Path) -> typing.AsyncGenerator:
114
119
  out = self.close_file(message)
115
120
  if out is not None:
116
121
  yield (self.OUTPUT_STOP, out)
117
122
 
118
123
  @ez.subscriber(INPUT_MESSAGE)
119
124
  @ez.publisher(OUTPUT_MESSAGE)
120
- async def on_message(self, message: Any) -> AsyncGenerator:
121
- strmessage = log_object(message)
122
- for output_f in self.STATE.output_files.values():
123
- output_f.write(f"{strmessage}\n")
124
- output_f.flush()
125
+ async def on_message(self, message: typing.Any) -> typing.AsyncGenerator:
126
+ strmessage = f"{log_object(message)}\n"
127
+
128
+ if self.SETTINGS.write_period <= 0:
129
+ for output_f in self.STATE.output_files.values():
130
+ output_f.write(strmessage)
131
+ output_f.flush()
132
+ else:
133
+ self.STATE.write_buffer.append(strmessage)
134
+
125
135
  yield (self.OUTPUT_MESSAGE, message)
126
136
 
137
+ @ez.task
138
+ async def buffered_write(self) -> None:
139
+ if self.SETTINGS.write_period <= 0:
140
+ return
141
+
142
+ rate = Rate(1.0 / self.SETTINGS.write_period)
143
+ while True:
144
+ for output_f in self.STATE.output_files.values():
145
+ output_f.writelines(self.STATE.write_buffer)
146
+ output_f.flush()
147
+ self.STATE.write_buffer.clear()
148
+ await rate.sleep()
149
+
127
150
  async def shutdown(self) -> None:
128
151
  """Note that files that are closed at shutdown don't publish messages"""
152
+
153
+ if len(self.STATE.write_buffer):
154
+ for output_f in self.STATE.output_files.values():
155
+ output_f.writelines(self.STATE.write_buffer)
156
+ output_f.flush()
157
+ self.STATE.write_buffer.clear()
158
+
129
159
  for filepath in list(self.STATE.output_files):
130
160
  self.close_file(filepath)
@@ -1,18 +1,25 @@
1
+ from abc import abstractmethod, ABC
2
+ from contextlib import contextmanager
3
+ from dataclasses import field, dataclass
1
4
  import math
2
5
  import typing
3
6
  import warnings
4
7
 
5
- from abc import abstractmethod, ABC
6
- from contextlib import contextmanager
7
- from dataclasses import field, dataclass
8
+ import ezmsg.core as ez
8
9
 
9
- import numpy as np
10
- import numpy.typing as npt
11
- import numpy.lib.stride_tricks as nps
10
+ try:
11
+ import numpy as np
12
+ import numpy.typing as npt
13
+ import numpy.lib.stride_tricks as nps
14
+ except ModuleNotFoundError:
15
+ ez.logger.error("Install ezmsg with the AxisArray extra:"
16
+ 'pip install "ezmsg[AxisArray]"')
17
+ raise
12
18
 
13
19
  from ezmsg.core.util import either_dict_or_kwargs
14
20
  from .util import replace
15
21
 
22
+
16
23
  if typing.TYPE_CHECKING:
17
24
  try:
18
25
  from xarray import DataArray
@@ -240,6 +240,9 @@ dependencies = [
240
240
  ]
241
241
 
242
242
  [package.optional-dependencies]
243
+ axisarray = [
244
+ { name = "numpy" },
245
+ ]
243
246
  docs = [
244
247
  { name = "ezmsg-sigproc" },
245
248
  { name = "sphinx" },
@@ -258,7 +261,8 @@ test = [
258
261
  requires-dist = [
259
262
  { name = "ezmsg-sigproc", marker = "extra == 'docs'", specifier = ">=1.2.3" },
260
263
  { name = "flake8", marker = "extra == 'test'", specifier = ">=5.0.4" },
261
- { name = "numpy", marker = "extra == 'test'", specifier = ">=1.24.2" },
264
+ { name = "numpy", marker = "extra == 'axisarray'", specifier = ">=1.24.4" },
265
+ { name = "numpy", marker = "extra == 'test'", specifier = ">=1.24.4" },
262
266
  { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" },
263
267
  { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23.8" },
264
268
  { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=5.0.0" },
ezmsg-3.6.0/.envrc DELETED
@@ -1,2 +0,0 @@
1
- export VIRTUAL_ENV=.venv
2
- layout python
@@ -1,319 +0,0 @@
1
- import os
2
- import sys
3
- import base64
4
- import asyncio
5
- import argparse
6
- import logging
7
- import subprocess
8
- import typing
9
- import webbrowser
10
-
11
- from collections import defaultdict
12
- from uuid import uuid4
13
- from textwrap import indent
14
-
15
- from .graphserver import GraphService
16
- from .shmserver import SHMService
17
- from .netprotocol import (
18
- Address,
19
- GRAPHSERVER_ADDR_ENV,
20
- GRAPHSERVER_PORT_DEFAULT,
21
- SHMSERVER_ADDR_ENV,
22
- SHMSERVER_PORT_DEFAULT,
23
- PUBLISHER_START_PORT_ENV,
24
- PUBLISHER_START_PORT_DEFAULT,
25
- close_stream_writer,
26
- )
27
- from .dag import DAG
28
-
29
- logger = logging.getLogger("ezmsg")
30
-
31
- IND = " "
32
-
33
-
34
- def cmdline() -> None:
35
- parser = argparse.ArgumentParser(
36
- "ezmsg.core",
37
- description="start and stop core ezmsg server processes",
38
- epilog=f"""
39
- You can also change server configuration with environment variables.
40
- GraphServer will be hosted on ${GRAPHSERVER_ADDR_ENV} (default port: {GRAPHSERVER_PORT_DEFAULT}).
41
- SHMServer will be hosted on ${SHMSERVER_ADDR_ENV} (default port: {SHMSERVER_PORT_DEFAULT}).
42
- Publishers will be assigned available ports starting from {PUBLISHER_START_PORT_DEFAULT}. (Change with ${PUBLISHER_START_PORT_ENV})
43
- """,
44
- )
45
-
46
- parser.add_argument(
47
- "command",
48
- help="command for ezmsg",
49
- choices=["serve", "start", "shutdown", "graphviz", "mermaid"],
50
- )
51
-
52
- parser.add_argument("--address", help="Address for GraphServer", default=None)
53
-
54
- class Args:
55
- command: str
56
- address: typing.Optional[str]
57
-
58
- args = parser.parse_args(namespace=Args)
59
-
60
- graph_address = Address("127.0.0.1", GRAPHSERVER_PORT_DEFAULT)
61
- if args.address is not None:
62
- graph_address = Address.from_string(args.address)
63
- shm_address_str = os.environ.get(
64
- SHMSERVER_ADDR_ENV, f"127.0.0.1:{SHMSERVER_PORT_DEFAULT}"
65
- )
66
- shm_address = Address.from_string(shm_address_str)
67
-
68
- loop = asyncio.new_event_loop()
69
- asyncio.set_event_loop(loop)
70
-
71
- loop.run_until_complete(run_command(args.command, graph_address, shm_address))
72
-
73
-
74
- async def run_command(cmd: str, graph_address: Address, shm_address: Address) -> None:
75
- shm_service = SHMService(shm_address)
76
- graph_service = GraphService(graph_address)
77
-
78
- if cmd == "serve":
79
- logger.info(f"GraphServer Address: {graph_address}")
80
- logger.info(f"SHMServer Address: {shm_address}")
81
-
82
- shm_server = shm_service.create_server()
83
- graph_server = graph_service.create_server()
84
-
85
- try:
86
- logger.info("Servers running...")
87
- graph_server.join()
88
-
89
- except KeyboardInterrupt:
90
- logger.info("Interrupt detected; shutting down servers")
91
-
92
- finally:
93
- if graph_server is not None:
94
- graph_server.stop()
95
-
96
- if shm_server is not None:
97
- shm_server.stop()
98
-
99
- elif cmd == "start":
100
- popen = subprocess.Popen(
101
- [sys.executable, "-m", "ezmsg.core", "serve", f"--address={graph_address}"]
102
- )
103
-
104
- while True:
105
- try:
106
- _, writer = await graph_service.open_connection()
107
- await close_stream_writer(writer)
108
- _, writer = await shm_service.open_connection()
109
- await close_stream_writer(writer)
110
- break
111
- except ConnectionRefusedError:
112
- await asyncio.sleep(0.1)
113
-
114
- logger.info(f"Forked ezmsg servers in PID: {popen.pid}")
115
-
116
- elif cmd == "shutdown":
117
- try:
118
- await graph_service.shutdown()
119
- logger.info(
120
- f"Issued shutdown command to GraphServer @ {graph_service.address}"
121
- )
122
-
123
- except ConnectionRefusedError:
124
- logger.warning(
125
- f"Could not issue shutdown command to GraphServer @ {graph_service.address}; server not running?"
126
- )
127
-
128
- elif cmd == "graphviz":
129
- try:
130
- dag: DAG = await graph_service.dag()
131
- except (ConnectionRefusedError, ConnectionResetError):
132
- logger.info(
133
- f"GraphServer not running @{graph_address}, or host is refusing connections"
134
- )
135
- return
136
-
137
- graph_connections = dag.graph.copy()
138
- # Let's eliminate proxy topics, i.e. connections with inputs and outputs.
139
- source_nodes = []
140
- for node, conns in graph_connections.items():
141
- if len(conns) > 0:
142
- source_nodes += [node]
143
- proxy_topics = []
144
- for conns in graph_connections.values():
145
- for conn in conns:
146
- if conn in source_nodes and conn not in proxy_topics:
147
- proxy_topics += [conn]
148
- # Replace Proxy Topics with actual source and downstream
149
- for proxy_topic in proxy_topics:
150
- downstreams = graph_connections.pop(proxy_topic)
151
- logger.info(f"{proxy_topic} downstream connetions: {downstreams}")
152
- for node, conns in graph_connections.items():
153
- for conn in conns:
154
- if conn == proxy_topic:
155
- new_conns = conns.copy()
156
- new_conns.remove(proxy_topic)
157
- new_conns.union(downstreams)
158
- logger.info(
159
- f"Updating connections for {node} from {conns} to {new_conns}"
160
- )
161
- graph_connections[node] = new_conns
162
-
163
- graph_connections = dag.graph.copy()
164
- # Let's come up with UUID node names
165
- nodes = set(graph_connections.keys())
166
- node_map = {name: f'"{str(uuid4())}"' for name in nodes}
167
-
168
- # Construct the graph
169
- def tree():
170
- return defaultdict(tree)
171
-
172
- graph: defaultdict = tree()
173
-
174
- connections = ""
175
- for node, conns in graph_connections.items():
176
- subgraph = graph
177
- path = node.split("/")
178
- route = path[:-1]
179
- stream = path[-1]
180
- for seg in route:
181
- subgraph = subgraph[seg]
182
- subgraph[stream] = node
183
-
184
- for sub in conns:
185
- connections += f"{node_map[node]} -> {node_map[sub]};" + "\n"
186
-
187
- # Now convert to dot syntax
188
- def recurse_graph(g: defaultdict):
189
- out = ""
190
- for leaf in g:
191
- if isinstance(g[leaf], defaultdict):
192
- out += indent(
193
- "\n".join(
194
- [
195
- f"subgraph {leaf.lower()} {{",
196
- indent("cluster = true;", IND),
197
- indent(f'label = "{leaf}";', IND),
198
- f"{recurse_graph(g[leaf])}}};",
199
- "",
200
- ]
201
- ),
202
- IND,
203
- )
204
- elif isinstance(g[leaf], str):
205
- out += indent(f"{node_map[g[leaf]]} [label={leaf}];" + "\n", IND)
206
- return out
207
-
208
- subgraph_tree = recurse_graph(graph)
209
-
210
- graphviz_out = "\n".join(
211
- [
212
- "digraph EZ {",
213
- indent('rankdir="LR"', IND),
214
- subgraph_tree,
215
- indent(connections, IND),
216
- "}",
217
- ]
218
- )
219
-
220
- print(graphviz_out)
221
- elif cmd == "mermaid":
222
- try:
223
- dag: DAG = await graph_service.dag()
224
- except (ConnectionRefusedError, ConnectionResetError):
225
- logger.info(
226
- f"GraphServer not running @{graph_address}, or host is refusing connections"
227
- )
228
- return
229
-
230
- graph_connections = dag.graph.copy()
231
- # Let's eliminate proxy topics, i.e. connections with inputs and outputs.
232
- source_nodes = []
233
- for node, conns in graph_connections.items():
234
- if len(conns) > 0:
235
- source_nodes += [node]
236
- proxy_topics = []
237
- for conns in graph_connections.values():
238
- for conn in conns:
239
- if conn in source_nodes and conn not in proxy_topics:
240
- proxy_topics += [conn]
241
- # Replace Proxy Topics with actual source and downstream
242
- for proxy_topic in proxy_topics:
243
- downstreams = graph_connections.pop(proxy_topic)
244
- logger.info(f"{proxy_topic} downstream connetions: {downstreams}")
245
- for node, conns in graph_connections.items():
246
- for conn in conns:
247
- if conn == proxy_topic:
248
- new_conns = conns.copy()
249
- new_conns.remove(proxy_topic)
250
- new_conns.union(downstreams)
251
- logger.info(
252
- f"Updating connections for {node} from {conns} to {new_conns}"
253
- )
254
- graph_connections[node] = new_conns
255
-
256
- graph_connections = dag.graph.copy()
257
- # Let's come up with UUID node names
258
- nodes = set(graph_connections.keys())
259
- node_map = {name: f"{str(uuid4())}" for name in nodes}
260
-
261
- # Construct the graph
262
- def tree():
263
- return defaultdict(tree)
264
-
265
- graph: defaultdict = tree()
266
-
267
- connections = ""
268
- for node, conns in graph_connections.items():
269
- subgraph = graph
270
- path = node.split("/")
271
- route = path[:-1]
272
- stream = path[-1]
273
- for seg in route:
274
- subgraph = subgraph[seg]
275
- subgraph[stream] = node
276
-
277
- for sub in conns:
278
- connections += f"{node_map[node]} --> {node_map[sub]}" + "\n"
279
-
280
- # Now convert to dot syntax
281
- def recurse_graph(g: defaultdict):
282
- out = ""
283
- for leaf in g:
284
- if isinstance(g[leaf], defaultdict):
285
- out += indent(
286
- "\n".join(
287
- [
288
- f"subgraph {leaf.lower()} [{leaf}]",
289
- # "direction LR",
290
- f"{recurse_graph(g[leaf])}",
291
- "end",
292
- "",
293
- ]
294
- ),
295
- IND,
296
- )
297
- elif isinstance(g[leaf], str):
298
- out += indent(f"{node_map[g[leaf]]}[{leaf}]" + "\n", IND)
299
- return out
300
-
301
- subgraph_tree = recurse_graph(graph)
302
-
303
- mermaid = "\n".join(
304
- [
305
- "flowchart LR",
306
- subgraph_tree,
307
- indent(connections, IND),
308
- ]
309
- )
310
-
311
- # print(mermaid)
312
- webbrowser.open(mm(mermaid))
313
-
314
-
315
- def mm(graph):
316
- graphbytes = graph.encode("utf8")
317
- base64_bytes = base64.b64encode(graphbytes)
318
- base64_string = base64_bytes.decode("ascii")
319
- return f"https://mermaid.ink/img/{base64_string}"
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
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
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
File without changes
File without changes
File without changes