Pyjoyment 0.1.0__py3-none-any.whl

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 (200) hide show
  1. Pyjo/App/ASGI.py +735 -0
  2. Pyjo/App/CGI.py +773 -0
  3. Pyjo/App/FastCGI.py +631 -0
  4. Pyjo/App/Lambda.py +987 -0
  5. Pyjo/App/WSGI.py +427 -0
  6. Pyjo/App/__init__.py +409 -0
  7. Pyjo/Asset/File.py +377 -0
  8. Pyjo/Asset/Memory.py +235 -0
  9. Pyjo/Asset/__init__.py +200 -0
  10. Pyjo/Base.py +215 -0
  11. Pyjo/Cache.py +119 -0
  12. Pyjo/Collection.py +570 -0
  13. Pyjo/Content/MultiPart.py +325 -0
  14. Pyjo/Content/Single.py +193 -0
  15. Pyjo/Content/__init__.py +806 -0
  16. Pyjo/Cookie/Request.py +90 -0
  17. Pyjo/Cookie/Response.py +294 -0
  18. Pyjo/Cookie/__init__.py +143 -0
  19. Pyjo/DOM/CSS.py +1019 -0
  20. Pyjo/DOM/HTML.py +550 -0
  21. Pyjo/DOM/__init__.py +1329 -0
  22. Pyjo/Date.py +294 -0
  23. Pyjo/EventEmitter.py +522 -0
  24. Pyjo/Exception.py +494 -0
  25. Pyjo/File.py +959 -0
  26. Pyjo/GetOpt.py +605 -0
  27. Pyjo/Headers.py +760 -0
  28. Pyjo/HelloWorld.py +77 -0
  29. Pyjo/Home.py +109 -0
  30. Pyjo/IOLoop/ChildProcess.py +591 -0
  31. Pyjo/IOLoop/Client.py +736 -0
  32. Pyjo/IOLoop/Datagram.py +982 -0
  33. Pyjo/IOLoop/Messages.py +664 -0
  34. Pyjo/IOLoop/Server.py +458 -0
  35. Pyjo/IOLoop/Stdio.py +446 -0
  36. Pyjo/IOLoop/Stream.py +644 -0
  37. Pyjo/IOLoop/Subprocess.py +419 -0
  38. Pyjo/IOLoop/TLS.py +360 -0
  39. Pyjo/IOLoop/__init__.py +1036 -0
  40. Pyjo/IOLoop/certs/README.md +41 -0
  41. Pyjo/IOLoop/certs/ca.crt +20 -0
  42. Pyjo/IOLoop/certs/server.crt +21 -0
  43. Pyjo/IOLoop/certs/server.key +28 -0
  44. Pyjo/JSON/Pointer.py +135 -0
  45. Pyjo/JSON/RPC.py +772 -0
  46. Pyjo/JSON/Schema.py +2506 -0
  47. Pyjo/JSON/__init__.py +573 -0
  48. Pyjo/Loader.py +342 -0
  49. Pyjo/Log.py +723 -0
  50. Pyjo/Message/Request.py +725 -0
  51. Pyjo/Message/Response.py +476 -0
  52. Pyjo/Message/__init__.py +830 -0
  53. Pyjo/Parameters.py +472 -0
  54. Pyjo/Path.py +419 -0
  55. Pyjo/Promise.py +894 -0
  56. Pyjo/Prove.py +1305 -0
  57. Pyjo/Reactor/Asyncio.py +854 -0
  58. Pyjo/Reactor/Poll.py +439 -0
  59. Pyjo/Reactor/__init__.py +525 -0
  60. Pyjo/SSE.py +119 -0
  61. Pyjo/Server/ASGI.py +486 -0
  62. Pyjo/Server/CGI.py +259 -0
  63. Pyjo/Server/Daemon.py +1007 -0
  64. Pyjo/Server/FastCGI.py +517 -0
  65. Pyjo/Server/Hot/__init__.py +687 -0
  66. Pyjo/Server/Hot/__main__.py +5 -0
  67. Pyjo/Server/Lambda.py +641 -0
  68. Pyjo/Server/Prefork.py +917 -0
  69. Pyjo/Server/Prethread.py +394 -0
  70. Pyjo/Server/Reload/Backend/Poll.py +114 -0
  71. Pyjo/Server/Reload/Backend/__init__.py +126 -0
  72. Pyjo/Server/Reload/__init__.py +482 -0
  73. Pyjo/Server/Reload/__main__.py +5 -0
  74. Pyjo/Server/Stdio.py +228 -0
  75. Pyjo/Server/WSGI.py +349 -0
  76. Pyjo/Server/__init__.py +396 -0
  77. Pyjo/String/Bytes.py +532 -0
  78. Pyjo/String/HTML.py +210 -0
  79. Pyjo/String/Mixin.py +333 -0
  80. Pyjo/String/Template.py +470 -0
  81. Pyjo/String/Unicode.py +553 -0
  82. Pyjo/String/__init__.py +0 -0
  83. Pyjo/TOML.py +232 -0
  84. Pyjo/Template.py +1290 -0
  85. Pyjo/Test/Pyjo.py +1454 -0
  86. Pyjo/Test/__init__.py +1233 -0
  87. Pyjo/Test/pytest_plugin.py +305 -0
  88. Pyjo/Transaction/HTTP.py +374 -0
  89. Pyjo/Transaction/WebSocket.py +709 -0
  90. Pyjo/Transaction/__init__.py +429 -0
  91. Pyjo/URL/Template.py +459 -0
  92. Pyjo/URL/__init__.py +651 -0
  93. Pyjo/Upload.py +139 -0
  94. Pyjo/UserAgent/CookieJar.py +391 -0
  95. Pyjo/UserAgent/Proxy.py +138 -0
  96. Pyjo/UserAgent/Server.py +239 -0
  97. Pyjo/UserAgent/Transactor.py +766 -0
  98. Pyjo/UserAgent/__init__.py +1243 -0
  99. Pyjo/Util.py +3567 -0
  100. Pyjo/WebSocket.py +332 -0
  101. Pyjo/YAML.py +1167 -0
  102. Pyjo/__init__.py +441 -0
  103. Pyjo/__main__.py +35 -0
  104. Pyjo/py.typed +0 -0
  105. Pyjoyment/Command/Author/__init__.py +20 -0
  106. Pyjoyment/Command/Author/generate/__init__.py +106 -0
  107. Pyjoyment/Command/Author/generate/app.py +299 -0
  108. Pyjoyment/Command/Author/generate/dockerfile.py +281 -0
  109. Pyjoyment/Command/Author/generate/kubernetes.py +514 -0
  110. Pyjoyment/Command/Author/generate/lambda_zip.py +372 -0
  111. Pyjoyment/Command/Author/generate/lite_app.py +113 -0
  112. Pyjoyment/Command/Author/generate/plugin.py +267 -0
  113. Pyjoyment/Command/Author/generate/pyproject.py +123 -0
  114. Pyjoyment/Command/Author/inflate.py +101 -0
  115. Pyjoyment/Command/__init__.py +332 -0
  116. Pyjoyment/Command/asgi.py +92 -0
  117. Pyjoyment/Command/cgi.py +90 -0
  118. Pyjoyment/Command/daemon.py +160 -0
  119. Pyjoyment/Command/eval.py +128 -0
  120. Pyjoyment/Command/fastcgi.py +99 -0
  121. Pyjoyment/Command/get.py +305 -0
  122. Pyjoyment/Command/lambda.py +117 -0
  123. Pyjoyment/Command/prefork.py +181 -0
  124. Pyjoyment/Command/prethread.py +126 -0
  125. Pyjoyment/Command/routes.py +133 -0
  126. Pyjoyment/Command/stdio.py +92 -0
  127. Pyjoyment/Command/version.py +141 -0
  128. Pyjoyment/Command/wsgi.py +93 -0
  129. Pyjoyment/Commands.py +546 -0
  130. Pyjoyment/Controller.py +1307 -0
  131. Pyjoyment/HelloWorld.py +56 -0
  132. Pyjoyment/Lite.py +494 -0
  133. Pyjoyment/Plugin/AllowedHosts.py +205 -0
  134. Pyjoyment/Plugin/Config.py +248 -0
  135. Pyjoyment/Plugin/DefaultHelpers.py +1202 -0
  136. Pyjoyment/Plugin/EPLRenderer.py +134 -0
  137. Pyjoyment/Plugin/EPRenderer.py +212 -0
  138. Pyjoyment/Plugin/HeaderCondition.py +98 -0
  139. Pyjoyment/Plugin/Health.py +325 -0
  140. Pyjoyment/Plugin/JSONConfig.py +172 -0
  141. Pyjoyment/Plugin/Mount.py +223 -0
  142. Pyjoyment/Plugin/PyRenderer.py +209 -0
  143. Pyjoyment/Plugin/TOMLConfig.py +141 -0
  144. Pyjoyment/Plugin/TagHelpers.py +959 -0
  145. Pyjoyment/Plugin/YAMLConfig.py +178 -0
  146. Pyjoyment/Plugin/__init__.py +80 -0
  147. Pyjoyment/Plugins.py +215 -0
  148. Pyjoyment/Renderer.py +673 -0
  149. Pyjoyment/Routes/Match.py +251 -0
  150. Pyjoyment/Routes/Pattern.py +443 -0
  151. Pyjoyment/Routes/Route.py +827 -0
  152. Pyjoyment/Routes/__init__.py +419 -0
  153. Pyjoyment/Sessions.py +255 -0
  154. Pyjoyment/Static.py +421 -0
  155. Pyjoyment/Types.py +238 -0
  156. Pyjoyment/Validator/Validation.py +395 -0
  157. Pyjoyment/Validator/__init__.py +304 -0
  158. Pyjoyment/__init__.py +1083 -0
  159. Pyjoyment/__main__.py +5 -0
  160. Pyjoyment/py.typed +0 -0
  161. Pyjoyment/resources/public/favicon.ico +0 -0
  162. Pyjoyment/resources/public/favicon.svg +345 -0
  163. Pyjoyment/resources/public/pyjo/bootstrap/LICENSE +21 -0
  164. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.bundle.min.js.map +1 -0
  165. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.css +6 -0
  166. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.js +7 -0
  167. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.min.css.map +1 -0
  168. Pyjoyment/resources/public/pyjo/failunicat.png +0 -0
  169. Pyjoyment/resources/public/pyjo/failunicat.svg +50 -0
  170. Pyjoyment/resources/public/pyjo/highlight.js/LICENSE +29 -0
  171. Pyjoyment/resources/public/pyjo/highlight.js/highlight-mojo-dark.css +56 -0
  172. Pyjoyment/resources/public/pyjo/highlight.js/highlight.min.js +1262 -0
  173. Pyjoyment/resources/public/pyjo/highlight.js/pyjoyment.min.js +1 -0
  174. Pyjoyment/resources/public/pyjo/logo-black-4x.png +0 -0
  175. Pyjoyment/resources/public/pyjo/logo-black-4x.svg +346 -0
  176. Pyjoyment/resources/public/pyjo/logo-white-2x.png +0 -0
  177. Pyjoyment/resources/public/pyjo/logo-white-2x.svg +346 -0
  178. Pyjoyment/resources/public/pyjo/logo-white-4x.png +0 -0
  179. Pyjoyment/resources/public/pyjo/logo-white-4x.svg +346 -0
  180. Pyjoyment/resources/public/pyjo/logo-white.png +0 -0
  181. Pyjoyment/resources/public/pyjo/logo-white.svg +346 -0
  182. Pyjoyment/resources/public/pyjo/logo.png +0 -0
  183. Pyjoyment/resources/public/pyjo/logo.svg +346 -0
  184. Pyjoyment/resources/public/pyjo/notfound.png +0 -0
  185. Pyjoyment/resources/public/pyjo/notfound.svg +3 -0
  186. Pyjoyment/resources/public/pyjo/nounicat.png +0 -0
  187. Pyjoyment/resources/public/pyjo/nounicat.svg +5 -0
  188. Pyjoyment/resources/public/pyjo/pinstripe-dark.png +0 -0
  189. Pyjoyment/resources/public/pyjo/pinstripe-dark.svg +4 -0
  190. Pyjoyment/resources/public/pyjo/pinstripe-light.png +0 -0
  191. Pyjoyment/resources/public/pyjo/pinstripe-light.svg +4 -0
  192. Pyjoyment/resources/public/pyjo/pyjo.css +204 -0
  193. Pyjoyment/resources/templates/pyjo/debug.html.ep +346 -0
  194. Pyjoyment/resources/templates/pyjo/exception.html.ep +32 -0
  195. Pyjoyment/resources/templates/pyjo/not_found.html.ep +45 -0
  196. pyjoyment-0.1.0.dist-info/METADATA +376 -0
  197. pyjoyment-0.1.0.dist-info/RECORD +200 -0
  198. pyjoyment-0.1.0.dist-info/WHEEL +4 -0
  199. pyjoyment-0.1.0.dist-info/entry_points.txt +8 -0
  200. pyjoyment-0.1.0.dist-info/licenses/LICENSE +201 -0
Pyjo/URL/Template.py ADDED
@@ -0,0 +1,459 @@
1
+ """
2
+ Pyjo.URL.Template - URI Templates
3
+ =================================
4
+ ::
5
+
6
+ from Pyjo.URL.Template import Template
7
+
8
+ template = Template('https://api.github.com/repos/{owner}/{repo}/issues{?state,labels}')
9
+ print(template.expand(owner='dex4er', repo='Pyjoyment', state='open'))
10
+ print(template.match('https://api.github.com/repos/dex4er/Pyjoyment/issues?state=open'))
11
+
12
+ # Or import the module, as in Google Python Style Guide
13
+
14
+ import Pyjo.URL.Template
15
+
16
+ template = Pyjo.URL.Template.new('file:///project/{+path}')
17
+ print(template.expand({'path': 'src/main.py'}))
18
+ print(template.match('file:///project/src/main.py')['path'])
19
+
20
+ :mod:`Pyjo.URL.Template` is an implementation of the URI Templates of
21
+ :rfc:`6570`, of all its levels: a template is a URI with expressions in braces,
22
+ which :meth:`~Pyjo_URL_Template.expand` replaces with the values of their
23
+ variables, encoded as the operator of each expression says. ::
24
+
25
+ # "/search?q=Hello%20World%21&lang=en"
26
+ Pyjo.URL.Template.new('/search{?q,lang}').expand(q='Hello World!', lang='en')
27
+
28
+ :meth:`~Pyjo_URL_Template.match` is the opposite, which the RFC leaves to its
29
+ implementations: the values of the variables of a URI that the template
30
+ expands to, as a server that routes a URI by its template needs them. The URI
31
+ is matched from both of its ends, in a time linear in its length, so a URI of
32
+ a client cannot make it slow. One expression takes what the others leave: the
33
+ one of the reserved expansion ``{+var}``, of a fragment ``{#var}`` or with the
34
+ explode modifier ``{var*}``, of which a template has at most one, or else the
35
+ last expression of the template. Every other expression ends at the first
36
+ character of what follows it in the template, as ``{name}`` ends at the
37
+ ``/`` of ``{name}/raw``. ::
38
+
39
+ # {'owner': 'dex4er', 'path': 'src/main.py'}
40
+ Pyjo.URL.Template.new('repo://{owner}/blob/{+path}').match('repo://dex4er/blob/src/main.py')
41
+
42
+ A template is for the URIs that a protocol gives as templates, as the resource
43
+ templates of the Model Context Protocol. A request of an application is
44
+ dispatched by its routes, whose patterns of :mod:`Pyjoyment.Routes.Pattern`
45
+ have placeholders with types, defaults and formats, which a template does not
46
+ have.
47
+
48
+ See also
49
+ --------
50
+
51
+ :mod:`Pyjo.URL`, :mod:`Pyjoyment.Routes.Pattern`, https://mojolicious.org.
52
+
53
+ It has no counterpart in Mojolicious, and is written from :rfc:`6570`.
54
+
55
+ Classes
56
+ -------
57
+ """
58
+
59
+ import Pyjo.Base
60
+ import Pyjo.Exception
61
+ import Pyjo.String.Unicode
62
+
63
+ from Pyjo.Util import decode, url_escape, url_unescape
64
+
65
+ import builtins
66
+ import re
67
+
68
+ from collections.abc import Mapping
69
+ from typing import Any, cast
70
+
71
+
72
+ type Anything = builtins.object
73
+
74
+ # The first character, the separator, whether the variables are named, the value of an empty named one, and whether
75
+ # the reserved characters stay, of each operator, as the table of the appendix A of RFC 6570 has them
76
+ _OPERATORS: dict[str, tuple[str, str, bool, str, bool]] = {
77
+ '': ('', ',', False, '', False),
78
+ '+': ('', ',', False, '', True),
79
+ '#': ('#', ',', False, '', True),
80
+ '.': ('.', '.', False, '', False),
81
+ '/': ('/', '/', False, '', False),
82
+ ';': (';', ';', True, '', False),
83
+ '?': ('?', '&', True, '=', False),
84
+ '&': ('&', '&', True, '=', False),
85
+ }
86
+
87
+ # The operators that RFC 6570 reserves for its future extensions
88
+ _FUTURE = '=,!@|'
89
+
90
+ # The tokens of a template, the variables of an expression, and the percent-encoded triplets of a value
91
+ _TOKEN = re.compile(r'\{([^{}]*)\}|([^{}]+)|(.)', re.S)
92
+ _VARSPEC = re.compile(
93
+ r'((?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2})(?:\.?(?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2}))*)(?::([1-9][0-9]{0,3})|(\*))?'
94
+ )
95
+ _TRIPLET = re.compile(r'(%[0-9A-Fa-f]{2})')
96
+
97
+ # The characters that the unreserved expansion keeps, and the ones that the reserved expansion keeps too
98
+ _UNRESERVED = r'A-Za-z0-9\-._~'
99
+ _RESERVED = r":/?#\[\]@!$&'()*+,;="
100
+
101
+
102
+ class _Expression:
103
+ # An expression of a template, with its operator and its variables, the names with their prefixes and explodes
104
+ __slots__ = ('content', 'explode', 'first', 'ifemp', 'named', 'op', 'reserved', 'sep', 'variables')
105
+
106
+ def __init__(self, op: str, variables: list[tuple[str, int | None, bool]]) -> None:
107
+ self.op = op
108
+ self.first, self.sep, self.named, self.ifemp, self.reserved = _OPERATORS[op]
109
+ self.variables = variables
110
+ self.explode = any(explode for _, _, explode in variables)
111
+
112
+ # The characters of its content, after its first character, which match takes
113
+ extra = ',' + _RESERVED if self.reserved else ','
114
+ if self.named:
115
+ extra += '=' + self.sep
116
+ elif len(variables) > 1 or self.explode:
117
+ extra += self.sep
118
+ self.content = re.compile(rf'(?:[{_UNRESERVED}{re.escape(extra)}]|%[0-9A-Fa-f]{{2}})*')
119
+
120
+ @property
121
+ def wide(self) -> bool:
122
+ # An expression that takes what the others leave, whose value has the separators
123
+ return self.reserved or self.explode
124
+
125
+ def expand(self, values: Mapping[str, Any]) -> str:
126
+ items = [
127
+ self._varspec(name, prefix, explode, value)
128
+ for name, prefix, explode in self.variables
129
+ if (value := values.get(name)) is not None and not (isinstance(value, list | tuple | dict) and not value)
130
+ ]
131
+ return self.first + self.sep.join(items) if items else ''
132
+
133
+ def values(self, content: str | None) -> dict[str, Any] | None:
134
+ # The values of the variables of the content of the expression, or None for a content that it does not expand to
135
+ if content is None:
136
+ return {}
137
+ if not self.content.fullmatch(content):
138
+ return None
139
+ if self.named:
140
+ return self._named(content)
141
+
142
+ # An empty expression without a first character is one without values, as an undefined variable
143
+ if not content and not self.first:
144
+ return {}
145
+ if len(self.variables) == 1:
146
+ name, _, explode = self.variables[0]
147
+ return {name: [_decode(part) for part in content.split(self.sep)] if explode else _decode(content)}
148
+ parts = content.split(self.sep)
149
+ if len(parts) > len(self.variables):
150
+ return None
151
+ return {name: _decode(part) for (name, _, _), part in zip(self.variables, parts, strict=False)}
152
+
153
+ def _escape(self, value: str) -> str:
154
+ return _escape(value, self.reserved)
155
+
156
+ def _named(self, content: str) -> dict[str, Any] | None:
157
+ names = {name: explode for name, _, explode in self.variables}
158
+ values: dict[str, Any] = {}
159
+ exploded: dict[str, str] = {}
160
+ for item in content.split(self.sep) if content else []:
161
+ name, _, value = item.partition('=')
162
+ value = _decode(value)
163
+ if (explode := names.get(name)) is None:
164
+ if not self.explode:
165
+ return None
166
+ exploded[name] = value
167
+ elif explode:
168
+ values.setdefault(name, []).append(value)
169
+ else:
170
+ values[name] = value
171
+
172
+ # The pairs of an exploded variable whose names are not its own, as the keys of a dict
173
+ if exploded:
174
+ name = next(name for name, _, explode in self.variables if explode)
175
+ if name in values:
176
+ return None
177
+ values[name] = exploded
178
+ return values
179
+
180
+ def _varspec(self, name: str, prefix: int | None, explode: bool, value: Any) -> str:
181
+ # A variable of the expression, as the appendix A of RFC 6570 expands it
182
+ if not isinstance(value, list | tuple | dict):
183
+ string = _str(value)
184
+ if prefix is not None:
185
+ string = string[:prefix]
186
+ if self.named:
187
+ return name + ('=' + self._escape(string) if string else self.ifemp)
188
+ return self._escape(string)
189
+
190
+ if prefix is not None:
191
+ raise Pyjo.Exception.new(f'Prefix modifier of a variable with a list or a dict: {name}')
192
+ if isinstance(value, dict):
193
+ pairs = [(_str(key), _str(item)) for key, item in cast(dict[Any, Any], value).items()]
194
+ else:
195
+ pairs = [(name, _str(item)) for item in cast(list[Any], value)]
196
+
197
+ if not explode:
198
+ if isinstance(value, dict):
199
+ joined = ','.join(f'{self._escape(key)},{self._escape(item)}' for key, item in pairs)
200
+ else:
201
+ joined = ','.join(self._escape(item) for _, item in pairs)
202
+ return f'{name}={joined}' if self.named else joined
203
+
204
+ if self.named:
205
+ return self.sep.join(
206
+ self._escape(key) + ('=' + self._escape(item) if item else self.ifemp) for key, item in pairs
207
+ )
208
+ if isinstance(value, dict):
209
+ return self.sep.join(f'{self._escape(key)}={self._escape(item)}' for key, item in pairs)
210
+ return self.sep.join(self._escape(item) for _, item in pairs)
211
+
212
+
213
+ class Pyjo_URL_Template(Pyjo.Base.object):
214
+ """
215
+ :mod:`Pyjo.URL.Template` inherits all methods from :mod:`Pyjo.Base` and
216
+ implements the following new ones.
217
+ """
218
+
219
+ def __init__(self, template: str = '') -> None:
220
+ """::
221
+
222
+ template = Pyjo.URL.Template.new('/search{?q,lang}')
223
+
224
+ Construct a new :mod:`Pyjo.URL.Template` object from a template, or
225
+ raise a :mod:`Pyjo.Exception` for one that is not valid.
226
+ """
227
+ self.template = template
228
+ """::
229
+
230
+ template = tpl.template
231
+
232
+ The template, as a string.
233
+ """
234
+
235
+ self._tokens: list[str | _Expression] = []
236
+ for expression, literal, other in _TOKEN.findall(template):
237
+ if other:
238
+ raise Pyjo.Exception.new(f'Unmatched brace in URI template: {template}')
239
+ if literal:
240
+ # A literal of the URI as it is, with the characters that a URI does not have encoded
241
+ self._tokens.append(_escape(literal, True))
242
+ continue
243
+ self._tokens.append(_expression(expression, template))
244
+ self._matcher: tuple[int, list[str | _Expression]] | None = None
245
+
246
+ def __str__(self) -> str:
247
+ """::
248
+
249
+ string = str(tpl)
250
+
251
+ The template.
252
+ """
253
+ return self.template
254
+
255
+ @property
256
+ def variables(self) -> list[str]:
257
+ """::
258
+
259
+ names = tpl.variables
260
+
261
+ The names of the variables of the template, in the order of the
262
+ template, each once. ::
263
+
264
+ # ['owner', 'repo', 'state']
265
+ Pyjo.URL.Template.new('/repos/{owner}/{repo}/issues{?state}').variables
266
+ """
267
+ names: dict[str, None] = {}
268
+ for token in self._tokens:
269
+ if isinstance(token, _Expression):
270
+ names.update((name, None) for name, _, _ in token.variables)
271
+ return list(names)
272
+
273
+ def expand(self, variables: Mapping[str, Any] | None = None, /, **kwargs: Any) -> Pyjo.String.Unicode.object:
274
+ """::
275
+
276
+ url = tpl.expand(q='Hello World!')
277
+ url = tpl.expand({'q': 'Hello World!', 'lang': 'en'})
278
+
279
+ Expand the template with the values of its variables, as a
280
+ :class:`dict` or as keyword arguments, and return the URI as a
281
+ :mod:`Pyjo.String.Unicode` object, with the encoding that the RFC
282
+ gives it, which :mod:`Pyjo.URL` would normalize. A value is a string, a number, a list or a
283
+ tuple of them, or a :class:`dict` of them, and ``None``, an empty list
284
+ and an empty dict are undefined, which their expressions leave out. A
285
+ prefix modifier of a list or a dict raises a :mod:`Pyjo.Exception`. ::
286
+
287
+ # "/repos/dex4er/Pyjoyment/issues?labels=bug,docs"
288
+ template = Pyjo.URL.Template.new('/repos/{owner}/{repo}/issues{?labels}')
289
+ template.expand(owner='dex4er', repo='Pyjoyment', labels=['bug', 'docs'])
290
+ """
291
+ values = {**(variables or {}), **kwargs}
292
+ return Pyjo.String.Unicode.new(
293
+ ''.join(token if isinstance(token, str) else token.expand(values) for token in self._tokens)
294
+ )
295
+
296
+ def match(self, uri: Anything) -> dict[str, Any] | None:
297
+ """::
298
+
299
+ variables = tpl.match('/search?q=Hello%20World%21')
300
+
301
+ Match a URI, a string or a :mod:`Pyjo.URL` object, with the template,
302
+ and return the values of the variables that it has, decoded, or
303
+ ``None`` for a URI that does not match. The value of a variable with
304
+ the explode modifier is a list, or a :class:`dict` for the pairs of a
305
+ named one, and a list without it is a string with the commas, as the
306
+ template cannot tell it from a string. A template whose expressions
307
+ cannot be told apart, two of them that take what the others leave, or
308
+ two that follow each other without a literal or the first character
309
+ of an operator between them, raises a :mod:`Pyjo.Exception`. ::
310
+
311
+ # {'q': 'Hello World!'}
312
+ Pyjo.URL.Template.new('/search{?q,lang}').match('/search?q=Hello%20World%21')
313
+
314
+ # {'path': ['src', 'main.py']}
315
+ Pyjo.URL.Template.new('file:///project{/path*}').match('file:///project/src/main.py')
316
+ """
317
+ wide, tokens = self._compile()
318
+ string = str(uri)
319
+
320
+ # The tokens before the expression that takes what the others leave, from the beginning of the URI
321
+ contents: list[tuple[_Expression, str | None]] = []
322
+ start = 0
323
+ for i in range(wide):
324
+ token = tokens[i]
325
+ if isinstance(token, str):
326
+ if not string.startswith(token, start):
327
+ return None
328
+ start += len(token)
329
+ continue
330
+ if token.first:
331
+ if not string.startswith(token.first, start):
332
+ contents.append((token, None))
333
+ continue
334
+ start += 1
335
+ end = string.find(stop, start) if (stop := _first(tokens, i + 1)) else -1
336
+ end = len(string) if end < 0 else end
337
+ contents.append((token, string[start:end]))
338
+ start = end
339
+
340
+ # The tokens after it, from the end of the URI
341
+ end = len(string)
342
+ for i in range(len(tokens) - 1, wide, -1):
343
+ token = tokens[i]
344
+ if isinstance(token, str):
345
+ if end - len(token) < start or not string.startswith(token, end - len(token)):
346
+ return None
347
+ end -= len(token)
348
+ continue
349
+ delimiter = token.first or _last(tokens, i - 1)
350
+ at = string.rfind(delimiter, start, end) if delimiter else -1
351
+ at = start - 1 if at < 0 and not token.first else at
352
+
353
+ # The first character of an operator that separates the variables too, once for each of them
354
+ for _ in range(len(token.variables) - 1 if token.first == token.sep else 0):
355
+ if (before := string.rfind(delimiter, start, at)) < 0:
356
+ break
357
+ at = before
358
+ if token.first:
359
+ # An expression of a first character that is not there, or not its own, is one that is not there
360
+ if at < 0 or token.values(string[at + 1 : end]) is None:
361
+ contents.append((token, None))
362
+ continue
363
+ contents.append((token, string[at + 1 : end]))
364
+ end = at
365
+ continue
366
+ contents.append((token, string[at + 1 : end]))
367
+ end = at + 1
368
+
369
+ # The expression in between, of what is left
370
+ if wide < len(tokens):
371
+ token = cast(_Expression, tokens[wide])
372
+ middle = string[start:end]
373
+ if token.first:
374
+ if middle and not middle.startswith(token.first):
375
+ return None
376
+ contents.append((token, middle[1:] if middle else None))
377
+ else:
378
+ contents.append((token, middle))
379
+ elif start != end:
380
+ return None
381
+
382
+ values: dict[str, Any] = {}
383
+ for token, content in contents:
384
+ if (found := token.values(content)) is None:
385
+ return None
386
+ values.update(found)
387
+ return values
388
+
389
+ def _compile(self) -> tuple[int, list[str | _Expression]]:
390
+ # The tokens of the template, and the index of the expression that takes what the others leave, once
391
+ if (matcher := self._matcher) is not None:
392
+ return matcher
393
+ tokens = self._tokens
394
+ expressions = [i for i, token in enumerate(tokens) if isinstance(token, _Expression)]
395
+ wides = [i for i in expressions if cast(_Expression, tokens[i]).wide]
396
+ if len(wides) > 1:
397
+ raise Pyjo.Exception.new(f'More than one expression of a reserved or exploded value: {self.template}')
398
+ for i in expressions:
399
+ if i + 1 < len(tokens) and isinstance(after := tokens[i + 1], _Expression) and not after.first:
400
+ raise Pyjo.Exception.new(f'Expressions without a literal between them: {self.template}')
401
+ wide = wides[0] if wides else expressions[-1] if expressions else len(tokens)
402
+ self._matcher = (wide, tokens)
403
+ return self._matcher
404
+
405
+
406
+ def _decode(value: str) -> str:
407
+ # A percent-encoded value of UTF-8, or of its bytes as characters where they are not UTF-8, as Pyjo.URL decodes
408
+ data = url_unescape(value.encode())
409
+ decoded = decode('UTF-8', data)
410
+ return decoded if decoded is not None else data.decode('latin-1')
411
+
412
+
413
+ def _escape(value: str, reserved: bool) -> str:
414
+ # A value percent-encoded as UTF-8, with the reserved characters and the percent-encoded triplets of a reserved one
415
+ if not reserved:
416
+ return url_escape(value.encode(), f'^{_UNRESERVED}'.encode()).decode()
417
+ return ''.join(
418
+ part if _TRIPLET.fullmatch(part) else url_escape(part.encode(), f'^{_UNRESERVED}{_RESERVED}'.encode()).decode()
419
+ for part in _TRIPLET.split(value)
420
+ )
421
+
422
+
423
+ def _expression(expression: str, template: str) -> _Expression:
424
+ # An expression of a template, its operator and the list of its variables
425
+ op = expression[:1] if expression[:1] in _OPERATORS else ''
426
+ if not expression or expression[:1] in _FUTURE:
427
+ raise Pyjo.Exception.new(f'Invalid expression in URI template: {{{expression}}} of {template}')
428
+ variables: list[tuple[str, int | None, bool]] = []
429
+ for varspec in expression[len(op) :].split(','):
430
+ if (spec := _VARSPEC.fullmatch(varspec)) is None:
431
+ raise Pyjo.Exception.new(f'Invalid expression in URI template: {{{expression}}} of {template}')
432
+ variables.append((spec[1], int(spec[2]) if spec[2] else None, bool(spec[3])))
433
+ return _Expression(op, variables)
434
+
435
+
436
+ def _first(tokens: list[str | _Expression], i: int) -> str:
437
+ # The first character of a token, which ends the expression before it, or nothing at the end of the template
438
+ if i >= len(tokens):
439
+ return ''
440
+ token = tokens[i]
441
+ return token[0] if isinstance(token, str) else token.first
442
+
443
+
444
+ def _last(tokens: list[str | _Expression], i: int) -> str:
445
+ # The last character of the literal before an expression without a first character
446
+ token = tokens[i]
447
+ return token[-1] if isinstance(token, str) else ''
448
+
449
+
450
+ def _str(value: Any) -> str:
451
+ # A value as a string, a boolean as in JSON
452
+ if isinstance(value, bool):
453
+ return 'true' if value else 'false'
454
+ return str(value)
455
+
456
+
457
+ new = Pyjo_URL_Template.new
458
+ object = Pyjo_URL_Template
459
+ Template = Pyjo_URL_Template