dtps-http 1.4.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. dtps_http-1.4.6/LICENSE.pdf +0 -0
  2. dtps_http-1.4.6/PKG-INFO +30 -0
  3. dtps_http-1.4.6/pyproject.toml +68 -0
  4. dtps_http-1.4.6/src/dtps/__init__.py +13 -0
  5. dtps_http-1.4.6/src/dtps/config.py +235 -0
  6. dtps_http-1.4.6/src/dtps/dtps_utils.py +121 -0
  7. dtps_http-1.4.6/src/dtps/ergo_create.py +454 -0
  8. dtps_http-1.4.6/src/dtps/ergo_ui.py +389 -0
  9. dtps_http-1.4.6/src/dtps/ergo_use.py +715 -0
  10. dtps_http-1.4.6/src/dtps/py.typed +0 -0
  11. dtps_http-1.4.6/src/dtps/structures.py +0 -0
  12. dtps_http-1.4.6/src/dtps_http/__init__.py +25 -0
  13. dtps_http-1.4.6/src/dtps_http/blob_manager.py +154 -0
  14. dtps_http-1.4.6/src/dtps_http/client.py +1726 -0
  15. dtps_http-1.4.6/src/dtps_http/constants.py +117 -0
  16. dtps_http-1.4.6/src/dtps_http/ergo_utils.py +41 -0
  17. dtps_http-1.4.6/src/dtps_http/exceptions.py +27 -0
  18. dtps_http-1.4.6/src/dtps_http/link_headers.py +62 -0
  19. dtps_http-1.4.6/src/dtps_http/object_queue.py +383 -0
  20. dtps_http-1.4.6/src/dtps_http/py.typed +0 -0
  21. dtps_http-1.4.6/src/dtps_http/server.py +2077 -0
  22. dtps_http-1.4.6/src/dtps_http/server_start.py +322 -0
  23. dtps_http-1.4.6/src/dtps_http/static/favicon.png +0 -0
  24. dtps_http-1.4.6/src/dtps_http/static/send.js +235 -0
  25. dtps_http-1.4.6/src/dtps_http/static/style.css +104 -0
  26. dtps_http-1.4.6/src/dtps_http/structures.py +823 -0
  27. dtps_http-1.4.6/src/dtps_http/types.py +131 -0
  28. dtps_http-1.4.6/src/dtps_http/types_of_source.py +615 -0
  29. dtps_http-1.4.6/src/dtps_http/urls.py +184 -0
  30. dtps_http-1.4.6/src/dtps_http/utils.py +252 -0
  31. dtps_http-1.4.6/src/dtps_http/utils_every_once_in_a_while.py +38 -0
  32. dtps_http-1.4.6/src/dtps_http_programs/__init__.py +13 -0
  33. dtps_http-1.4.6/src/dtps_http_programs/dtps_listen.py +112 -0
  34. dtps_http-1.4.6/src/dtps_http_programs/dtps_proxy.py +69 -0
  35. dtps_http-1.4.6/src/dtps_http_programs/dtps_send_continuous.py +38 -0
  36. dtps_http-1.4.6/src/dtps_http_programs/dtps_stats.py +104 -0
  37. dtps_http-1.4.6/src/dtps_http_programs/py.typed +0 -0
  38. dtps_http-1.4.6/src/dtps_http_programs/server_clock.py +59 -0
  39. dtps_http-1.4.6/src/dtps_http_programs/together.py +34 -0
Binary file
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: dtps-http
3
+ Version: 1.4.6
4
+ Summary:
5
+ License-File: LICENSE.pdf
6
+ Author: Andrea Censi
7
+ Author-email: AndreaCensi@users.noreply.github.com
8
+ Requires-Python: >=3.8,<4.0
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.8
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Dist: aiohttp (>=3,<4)
18
+ Requires-Dist: aiopubsub (>=3,<4)
19
+ Requires-Dist: cbor2 (>=5,<6)
20
+ Requires-Dist: jsonpatch (>=1.33,<2.0)
21
+ Requires-Dist: methodtools (>=0.4,<0.5)
22
+ Requires-Dist: prettyprinter (>=0.18.0,<0.19.0)
23
+ Requires-Dist: psutil (>=7,<8)
24
+ Requires-Dist: pydantic (==2.10.6)
25
+ Requires-Dist: pymdown-extensions (>=10,<11)
26
+ Requires-Dist: tcp_latency (==0.0.12)
27
+ Requires-Dist: timeout-decorator (>=0.5.0,<0.6.0)
28
+ Requires-Dist: typing_extensions (>=4,<5)
29
+ Requires-Dist: urllib3 (>=2,<3)
30
+ Requires-Dist: xxhash (>=3.4.1,<4.0.0)
@@ -0,0 +1,68 @@
1
+ [build-system]
2
+ requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"]
3
+ build-backend = "poetry_dynamic_versioning.backend"
4
+
5
+
6
+ [tool]
7
+ [tool.poetry]
8
+ name = "dtps-http"
9
+ version = "1.4.6"
10
+ description = ""
11
+ authors = ["Andrea Censi <AndreaCensi@users.noreply.github.com>"]
12
+ packages = [
13
+ { from = "src", include = "dtps" },
14
+ { from = "src", include = "dtps_http" },
15
+ { from = "src", include = "dtps_http_programs" },
16
+ ]
17
+
18
+ include = [
19
+ { path = "src/dtps_http/static/**", format = ['sdist', 'wheel'] }
20
+ ]
21
+
22
+ [tool.poetry.dependencies]
23
+ python = "^3.8"
24
+ aiohttp = "^3"
25
+ cbor2 = "^5"
26
+ tcp_latency = "0.0.12"
27
+ typing_extensions = "^4"
28
+ pymdown-extensions = "^10"
29
+ aiopubsub = "^3"
30
+ methodtools = "^0.4"
31
+ pydantic = "2.10.6"
32
+ psutil = "^7"
33
+ urllib3 = "^2"
34
+ jsonpatch = "^1.33"
35
+
36
+
37
+ # other dependencies: aiohttp-devtools pre-commit mkdocs mkdocs-material mkdocstrings[python]
38
+ # pip install "mkdocstrings[python]" mkdocs-material
39
+ timeout-decorator = "^0.5.0"
40
+ prettyprinter = "^0.18.0"
41
+ xxhash = "^3.4.1"
42
+
43
+ [tool.poetry.scripts]
44
+
45
+ dtps-http-py-server-example-clock = 'dtps_http_programs:clock_main'
46
+ dtps-http-py-server = 'dtps_http_programs:server_main'
47
+ dtps-http-py-client-stats = 'dtps_http_programs:dtps_stats_main'
48
+ dtps-http-py-proxy = 'dtps_http_programs:dtps_proxy_main'
49
+ dtps-http-py-send-continuous = 'dtps_http_programs:dtps_send_continuous_main'
50
+ dtps-http-py-listen= 'dtps_http_programs:dtps_listen_main'
51
+ dtps-http-py= 'dtps_http_programs:dtps_main'
52
+
53
+
54
+ [tool.poetry-dynamic-versioning]
55
+ enable = false
56
+ dirty = false
57
+ metadata = false
58
+ tagged-metadata = false
59
+ vcs = "git"
60
+ style = "pep440"
61
+ strict = false
62
+
63
+
64
+
65
+ [tool.poetry-dynamic-versioning.substitution]
66
+ folders = [
67
+ { path = "src" }
68
+ ]
@@ -0,0 +1,13 @@
1
+ __version__ = "1.4.6"
2
+
3
+ from logging import DEBUG, getLogger
4
+
5
+ logger = getLogger(__name__)
6
+ logger.setLevel(DEBUG)
7
+
8
+ from .config import *
9
+ from .ergo_ui import *
10
+ from .dtps_utils import *
11
+ from dtps_http import RawData, TransformError
12
+
13
+ _ = RawData, TransformError
@@ -0,0 +1,235 @@
1
+ import os
2
+ from contextlib import asynccontextmanager
3
+ from dataclasses import dataclass
4
+ from typing import (
5
+ AsyncContextManager,
6
+ AsyncIterator,
7
+ cast,
8
+ ClassVar,
9
+ Dict,
10
+ List,
11
+ Mapping,
12
+ Optional,
13
+ Tuple,
14
+ TYPE_CHECKING,
15
+ )
16
+
17
+ from dtps_http import parse_url_unescape, ServerWrapped, URLString
18
+ from . import logger
19
+ from .ergo_ui import DTPSContext
20
+
21
+ __all__ = [
22
+ "context",
23
+ "context_cleanup",
24
+ ]
25
+
26
+
27
+ async def context(
28
+ base_name: str = "self", environment: Optional[Mapping[str, str]] = None, urls: Optional[List[str]] = None
29
+ ) -> "DTPSContext":
30
+ """
31
+ Initialize a DTPS interface from the environment from a given base name.
32
+
33
+ base_name is case-insensitive.
34
+
35
+ Environment variables of the form DTPS_BASE_<base_name> are used to get the info needed.
36
+
37
+ For example:
38
+
39
+ DTPS_BASE_SELF = "http://localhost:2120/" # use an existing server
40
+ DTPS_BASE_SELF = "http+unix://[socket]/" # use an existing unix socket
41
+
42
+ We can also use the special prefix "create:" to create a new server.
43
+ For example:
44
+
45
+ DTPS_BASE_SELF = "create:http://localhost:2120/" # create a new server
46
+
47
+ Moreover, we can use more than one base name, by adding a number at the end:
48
+
49
+ DTPS_BASE_SELF_0 = "create:http://localhost:2120/" #
50
+ DTPS_BASE_SELF_1 = "create:http+unix://[socket]/"
51
+
52
+
53
+ You need to call context.aclose() at the end to clean up resources.
54
+
55
+
56
+ c = context("mio", environment={'DTPS_BASE_MIO': url})
57
+
58
+ """
59
+ base_name = base_name.lower()
60
+
61
+ if environment is not None and urls is not None:
62
+ raise ValueError("You cannot create a context while passing both 'environment' and 'urls'")
63
+
64
+ if urls:
65
+ environment = environment_from_urls(base_name, urls)
66
+
67
+ if environment is None:
68
+ if base_name in ContextManager.instances:
69
+ return ContextManager.instances[base_name].get_context()
70
+ else:
71
+ context_manager = await create_context(base_name, environment)
72
+ ContextManager.instances[base_name] = context_manager
73
+ return context_manager.get_context()
74
+ else:
75
+ context_manager = await create_context(base_name, environment)
76
+ return context_manager.get_context()
77
+
78
+
79
+ if TYPE_CHECKING:
80
+
81
+ def context_cleanup(
82
+ base_name: str = "self", environment: Optional[Mapping[str, str]] = None
83
+ ) -> AsyncContextManager[DTPSContext]: ...
84
+
85
+ else:
86
+
87
+ @asynccontextmanager
88
+ async def context_cleanup(
89
+ base_name: str = "self", environment: Optional[Mapping[str, str]] = None
90
+ ) -> AsyncIterator[DTPSContext]:
91
+ """Context manager to open a context and clean-up later."""
92
+ c = await context(base_name, environment)
93
+ try:
94
+ yield c
95
+ finally:
96
+ await c.aclose()
97
+
98
+
99
+ async def create_context(base_name: str, environment: Optional[Mapping[str, str]]) -> "ContextManager":
100
+ contexts = get_context_info(environment)
101
+ if base_name not in contexts.contexts:
102
+ msg = f'Cannot find context "{base_name}" among {list(contexts.contexts)}'
103
+ raise KeyError(msg)
104
+
105
+ context_info = contexts.contexts[base_name]
106
+ logger.debug(f'Creating context "{base_name}" with {context_info} for environment {environment}')
107
+ return await ContextManager.create(base_name, context_info)
108
+
109
+
110
+ class ContextManager:
111
+ instances: ClassVar[Dict[str, "ContextManager"]] = {}
112
+
113
+ context_info: "ContextInfo"
114
+
115
+ dtps_server_wrap: Optional[ServerWrapped]
116
+
117
+ @classmethod
118
+ async def create(cls, base_name: str, context_info: "ContextInfo") -> "ContextManager":
119
+ # if base_name in cls.instances:
120
+ # msg = f'Context "{base_name}" already exists'
121
+ # raise KeyError(msg)
122
+
123
+ # logger.info(f'Creating context "{base_name}" with {context_info}')
124
+
125
+ if context_info.is_create():
126
+ from .ergo_create import ContextManagerCreate
127
+
128
+ cm = ContextManagerCreate(base_name, context_info)
129
+ else:
130
+ from .ergo_use import ContextManagerUse
131
+
132
+ cm = ContextManagerUse(base_name, context_info)
133
+
134
+ # cls.instances[base_name] = cm
135
+ await cm.init()
136
+ return cm
137
+
138
+ def get_context(self) -> "DTPSContext":
139
+ raise NotImplementedError()
140
+
141
+
142
+ @dataclass
143
+ class ContextUrl:
144
+ url: URLString
145
+ create: bool
146
+
147
+ def __post_init__(self) -> None:
148
+ parse_url_unescape(self.url)
149
+
150
+
151
+ @dataclass
152
+ class ContextInfo:
153
+ urls: List[ContextUrl]
154
+
155
+ def is_create(self) -> bool:
156
+ return all(x.create for x in self.urls)
157
+
158
+ def get_tcp_and_unix(self) -> Tuple[List[Tuple[str, int]], List[str]]:
159
+ tcp: List[Tuple[str, int]] = []
160
+ unix: List[str] = []
161
+ for u in self.urls:
162
+ url_ = parse_url_unescape(u.url)
163
+ if url_.scheme == "http+unix":
164
+ host = url_.host
165
+ unix.append(host)
166
+
167
+ elif url_.scheme == "http" or url_.scheme == "https":
168
+ # rest = url.url[len("http://") :]
169
+ # host, _, rest = rest.partition("/")
170
+ # host, _, port = host.partition(":")
171
+ # port = int(port)
172
+ host = url_.host or "localhost"
173
+ if not url_.port:
174
+ port = 0
175
+ elif isinstance(url_.port, str):
176
+ port = int(url_.port)
177
+ else:
178
+ port = url_.port
179
+
180
+ tcp.append((host, port))
181
+ else:
182
+ msg = f'Invalid url "{url_}". Must start with "http://" or "http+unix://".'
183
+ raise ValueError(msg)
184
+ return tcp, unix
185
+
186
+
187
+ @dataclass
188
+ class ContextsInfo:
189
+ contexts: Dict[str, ContextInfo]
190
+
191
+
192
+ BASE = "DTPS_BASE_"
193
+
194
+
195
+ def get_context_info(environment: Optional[Mapping[str, str]]) -> ContextsInfo:
196
+ if environment is None:
197
+ environment = dict(os.environ)
198
+
199
+ contexts: Dict[str, ContextInfo] = {}
200
+ for k, v in environment.items():
201
+ if not k.startswith(BASE):
202
+ continue
203
+ rest = k[len(BASE) :]
204
+
205
+ name, _, rest = rest.partition("_")
206
+
207
+ name = name.lower()
208
+ if name not in contexts:
209
+ contexts[name] = ContextInfo(urls=[])
210
+
211
+ if v.startswith("create:"):
212
+ x = cast(URLString, v[len("create:") :])
213
+ create = True
214
+
215
+ else:
216
+ x = cast(URLString, v)
217
+ create = False
218
+ try:
219
+ parse_url_unescape(x)
220
+ except ValueError as e:
221
+ msg = f"Invalid url given by environment:\n{k} = {v}\nExtracted url: {x}"
222
+ raise ValueError(msg) from e
223
+ contexts[name].urls.append(ContextUrl(url=x, create=create))
224
+
225
+ for name, info in contexts.items():
226
+ all_create = all(x.create for x in info.urls)
227
+ all_not_create = all(not x.create for x in info.urls)
228
+ if not all_create and not all_not_create:
229
+ msg = f'Invalid context "{name}". All urls must be either "create:" or not.'
230
+ raise ValueError(msg)
231
+ return ContextsInfo(contexts=contexts)
232
+
233
+
234
+ def environment_from_urls(name: str, urls: List[str]):
235
+ return {f"{BASE}{name}_{i}": url for i, url in enumerate(urls)}
@@ -0,0 +1,121 @@
1
+ import asyncio
2
+ from asyncio import CancelledError, Queue
3
+ from typing import Any, Awaitable, Callable, List, Optional, TypeVar
4
+
5
+ from dtps_http import RawData
6
+ from . import logger
7
+ from .ergo_ui import DTPSContext, SubscriptionInterface
8
+
9
+ __all__ = [
10
+ "process_lowdatasize_last_recent",
11
+ ]
12
+
13
+
14
+ async def process_lowdatasize_last_recent(
15
+ context: DTPSContext,
16
+ expensive_callback: Callable[[RawData], Awaitable[None]],
17
+ ) -> "ExpensiveCallbackSubscription":
18
+ """
19
+ Make sure we are not a slow reader even for an expensive callback.
20
+
21
+ Suitable for this case:
22
+
23
+ 0) only the last message is important (e.g. joystick)
24
+ 1) there are a lot of small messages
25
+ 2) low-latency is important
26
+ 2) the callback is expensive (e.g. write to hardware)
27
+
28
+
29
+ Note! If the callback uses *blocking IO* we need to do it
30
+ in a different process! (or thread)
31
+
32
+ """
33
+ s = ExpensiveCallbackSubscription(expensive_callback)
34
+ await s.init(context)
35
+ return s
36
+
37
+
38
+ X = TypeVar("X")
39
+
40
+
41
+ async def queue_get_multiple(q: "Queue[X]") -> List[X]:
42
+ """Gets a packet of ready messages from the queue (at least one)."""
43
+ first = await q.get()
44
+ msgs = [first]
45
+ while True:
46
+ try:
47
+ msgs.append(q.get_nowait())
48
+ except asyncio.QueueEmpty:
49
+ break
50
+ return msgs
51
+
52
+
53
+ class ExpensiveCallbackSubscription(SubscriptionInterface):
54
+ q: "Queue[RawData]"
55
+ sub: Optional[SubscriptionInterface]
56
+ task: "Optional[asyncio.Task[Any]]"
57
+
58
+ def __init__(self, expensive_callback: Callable[[RawData], Awaitable[None]]):
59
+ self.expensive_callback = expensive_callback
60
+ self.q = Queue()
61
+ self.sub = None
62
+ self.nreceived = 0
63
+ self.nprocessed = 0
64
+ self.nskipped = 0
65
+
66
+ async def init(self, context: DTPSContext) -> None:
67
+ self.sub = await context.subscribe(self.raw_callback)
68
+ self.task = asyncio.create_task(self.process_task())
69
+
70
+ async def process_task(self) -> None:
71
+ while True:
72
+ rd = await queue_get_multiple(self.q)
73
+ # only process the last one
74
+ last = rd[-1]
75
+ nskipped_now = len(rd) - 1
76
+ self.nprocessed += 1
77
+ self.nskipped += nskipped_now
78
+
79
+ if nskipped_now > 0:
80
+ percentage_skipped = self.nskipped / self.nreceived
81
+ logger.debug(
82
+ f"Skipped {nskipped_now} messages now in this iteration.\n"
83
+ f"Total received: {self.nreceived}. "
84
+ f"Total skipped: {self.nskipped} ({percentage_skipped:.2%})\n"
85
+ )
86
+ try:
87
+ await self.expensive_callback(last)
88
+ except CancelledError:
89
+ break
90
+ except Exception as e:
91
+ logger.error(f"Error in expensive callback", exc_info=e)
92
+ continue
93
+
94
+ async def raw_callback(self, r: RawData) -> None:
95
+ self.nreceived += 1
96
+ self.q.put_nowait(r)
97
+
98
+ async def aclose(self) -> None:
99
+ if self.nreceived == 0:
100
+ logger.debug("No messages received")
101
+ else:
102
+
103
+ percentage_skipped = self.nskipped / self.nreceived
104
+ percentage_processed = self.nprocessed / self.nreceived
105
+
106
+ logger.debug(
107
+ f"Final statistics:\n"
108
+ f" Total received: {self.nreceived:5} \n"
109
+ f" Total processed: {self.nprocessed:5} ({percentage_processed:.2%})\n"
110
+ f" Total skipped: {self.nskipped:5} ({percentage_skipped:.2%})\n"
111
+ )
112
+
113
+ if self.sub is not None:
114
+ await self.sub.unsubscribe()
115
+ self.sub = None
116
+ if self.task is not None:
117
+ self.task.cancel()
118
+ self.task = None
119
+
120
+ async def unsubscribe(self) -> None:
121
+ await self.aclose()