sovereign 0.25.3__tar.gz → 0.26.0__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.

Potentially problematic release.


This version of sovereign might be problematic. Click here for more details.

Files changed (61) hide show
  1. {sovereign-0.25.3 → sovereign-0.26.0}/PKG-INFO +2 -2
  2. {sovereign-0.25.3 → sovereign-0.26.0}/pyproject.toml +6 -3
  3. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/app.py +4 -2
  4. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/config_loader.py +45 -46
  5. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/context.py +32 -26
  6. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/schemas.py +3 -3
  7. sovereign-0.26.0/src/sovereign/testing/loaders.py +9 -0
  8. {sovereign-0.25.3 → sovereign-0.26.0}/LICENSE.txt +0 -0
  9. {sovereign-0.25.3 → sovereign-0.26.0}/README.md +0 -0
  10. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/__init__.py +0 -0
  11. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/configuration.py +0 -0
  12. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/constants.py +0 -0
  13. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/discovery.py +0 -0
  14. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/error_info.py +0 -0
  15. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/logging/access_logger.py +0 -0
  16. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/logging/application_logger.py +0 -0
  17. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/logging/base_logger.py +0 -0
  18. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/logging/bootstrapper.py +0 -0
  19. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/logging/types.py +0 -0
  20. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/middlewares.py +0 -0
  21. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/modifiers/__init__.py +0 -0
  22. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/modifiers/lib.py +0 -0
  23. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/response_class.py +0 -0
  24. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/server.py +0 -0
  25. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/sources/__init__.py +0 -0
  26. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/sources/file.py +0 -0
  27. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/sources/inline.py +0 -0
  28. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/sources/lib.py +0 -0
  29. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/sources/poller.py +0 -0
  30. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/static/sass/style.scss +0 -0
  31. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/static/style.css +0 -0
  32. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/statistics.py +0 -0
  33. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/templates/base.html +0 -0
  34. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/templates/err.html +0 -0
  35. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/templates/resources.html +0 -0
  36. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/templates/ul_filter.html +0 -0
  37. /sovereign-0.25.3/src/sovereign/modifiers/test.py → /sovereign-0.26.0/src/sovereign/testing/modifiers.py +0 -0
  38. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/__init__.py +0 -0
  39. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/auth.py +0 -0
  40. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/crypto/__init__.py +0 -0
  41. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/crypto/crypto.py +0 -0
  42. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/crypto/suites/__init__.py +0 -0
  43. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/crypto/suites/aes_gcm_cipher.py +0 -0
  44. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/crypto/suites/base_cipher.py +0 -0
  45. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/crypto/suites/disabled_cipher.py +0 -0
  46. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/crypto/suites/fernet_cipher.py +0 -0
  47. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/dictupdate.py +0 -0
  48. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/eds.py +0 -0
  49. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/entry_point_loader.py +0 -0
  50. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/mock.py +0 -0
  51. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/resources.py +0 -0
  52. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/templates.py +0 -0
  53. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/timer.py +0 -0
  54. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/version_info.py +0 -0
  55. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/utils/weighted_clusters.py +0 -0
  56. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/views/__init__.py +0 -0
  57. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/views/admin.py +0 -0
  58. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/views/crypto.py +0 -0
  59. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/views/discovery.py +0 -0
  60. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/views/healthchecks.py +0 -0
  61. {sovereign-0.25.3 → sovereign-0.26.0}/src/sovereign/views/interface.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sovereign
3
- Version: 0.25.3
3
+ Version: 0.26.0
4
4
  Summary: Envoy Proxy control-plane written in Python
5
5
  Home-page: https://pypi.org/project/sovereign/
6
6
  License: Apache-2.0
@@ -41,7 +41,7 @@ Requires-Dist: cryptography (>=42.0.5,<43.0.0)
41
41
  Requires-Dist: datadog (>=0.47.0,<0.48.0) ; extra == "statsd"
42
42
  Requires-Dist: fastapi (>=0.110.0,<0.111.0)
43
43
  Requires-Dist: glom (>=23.3.0,<24.0.0)
44
- Requires-Dist: gunicorn (>=21.2.0,<22.0.0)
44
+ Requires-Dist: gunicorn (>=22.0.0,<23.0.0)
45
45
  Requires-Dist: httptools (>=0.6.0,<0.7.0) ; extra == "httptools"
46
46
  Requires-Dist: orjson (>=3.9.15,<4.0.0) ; extra == "orjson"
47
47
  Requires-Dist: redis (<=5.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "sovereign"
3
- version = "0.25.3"
3
+ version = "0.26.0"
4
4
  description = "Envoy Proxy control-plane written in Python"
5
5
  license = "Apache-2.0"
6
6
  packages = [
@@ -37,12 +37,15 @@ sovereign = 'sovereign.server:main'
37
37
  "inline" = "sovereign.sources.inline:Inline"
38
38
 
39
39
  [tool.poetry.plugins."sovereign.modifiers"]
40
- "sovereign_3rd_party_test" = "sovereign.modifiers.test:Test"
40
+ "sovereign_3rd_party_test" = "sovereign.testing.modifiers:Test"
41
+
42
+ [tool.poetry.plugins."sovereign.loaders"]
43
+ "example" = "sovereign.testing.loaders:Multiply"
41
44
 
42
45
  [tool.poetry.dependencies]
43
46
  python = "^3.11"
44
47
  uvicorn = "^0.23.2"
45
- gunicorn = "^21.2.0"
48
+ gunicorn = "^22.0.0"
46
49
  aiofiles = "^23.2.1"
47
50
  requests = "^2.31.0"
48
51
  PyYAML = "^6.0.1"
@@ -1,9 +1,11 @@
1
1
  import asyncio
2
2
  import traceback
3
- import uvicorn
4
3
  from collections import namedtuple
4
+
5
+ import uvicorn
5
6
  from fastapi import FastAPI, Request
6
7
  from fastapi.responses import RedirectResponse, FileResponse, Response, JSONResponse
8
+
7
9
  from sovereign import (
8
10
  __version__,
9
11
  config,
@@ -14,12 +16,12 @@ from sovereign import (
14
16
  logs,
15
17
  )
16
18
  from sovereign.error_info import ErrorInfo
19
+ from sovereign.utils.resources import get_package_file
17
20
  from sovereign.views import crypto, discovery, healthchecks, admin, interface
18
21
  from sovereign.middlewares import (
19
22
  RequestContextLogMiddleware,
20
23
  LoggingMiddleware,
21
24
  )
22
- from sovereign.utils.resources import get_package_file
23
25
 
24
26
  Router = namedtuple("Router", "module tags prefix")
25
27
 
@@ -1,7 +1,7 @@
1
1
  import os
2
2
  import json
3
3
  from enum import Enum
4
- from typing import Any, Dict, Callable, Union
4
+ from typing import Any, Dict, Callable, Union, Protocol
5
5
  from types import ModuleType
6
6
  import yaml
7
7
  import jinja2
@@ -14,6 +14,11 @@ from sovereign.utils.resources import get_package_file_bytes
14
14
 
15
15
 
16
16
  class Serialization(Enum):
17
+ """
18
+ Types of deserialization available in Sovereign
19
+ for loading configuration field values.
20
+ """
21
+
17
22
  yaml = "yaml"
18
23
  json = "json"
19
24
  orjson = "orjson"
@@ -22,18 +27,7 @@ class Serialization(Enum):
22
27
  jinja2 = "jinja2"
23
28
  string = "string"
24
29
  raw = "raw"
25
-
26
-
27
- class Protocol(Enum):
28
- file = "file"
29
- http = "http"
30
- https = "https"
31
- pkgdata = "pkgdata"
32
- env = "env"
33
- module = "module"
34
- s3 = "s3"
35
- python = "python"
36
- inline = "inline"
30
+ skip = "skip"
37
31
 
38
32
 
39
33
  jinja_env = jinja2.Environment(autoescape=True)
@@ -56,6 +50,7 @@ serializers: Dict[Serialization, Callable[[Any], Any]] = {
56
50
  Serialization.raw: passthrough,
57
51
  }
58
52
 
53
+
59
54
  try:
60
55
  import ujson
61
56
 
@@ -99,8 +94,13 @@ except ImportError:
99
94
  boto3 = None
100
95
 
101
96
 
97
+ class CustomLoader(Protocol):
98
+ def load(self, path: str, ser: Serialization) -> Any:
99
+ ...
100
+
101
+
102
102
  class Loadable(BaseModel):
103
- protocol: Protocol = Protocol.http
103
+ protocol: str = "http"
104
104
  serialization: Serialization = Serialization.yaml
105
105
  path: str
106
106
 
@@ -113,26 +113,25 @@ class Loadable(BaseModel):
113
113
  raise
114
114
 
115
115
  @staticmethod
116
- def from_legacy_fmt(s: str) -> "Loadable":
117
- if "://" not in s:
116
+ def from_legacy_fmt(fmt_string: str) -> "Loadable":
117
+ if "://" not in fmt_string:
118
118
  return Loadable(
119
- protocol=Protocol.inline, serialization=Serialization.string, path=s
119
+ protocol="inline", serialization=Serialization.string, path=fmt_string
120
120
  )
121
121
  try:
122
- scheme, path = s.split("://")
122
+ scheme, path = fmt_string.split("://")
123
123
  except ValueError:
124
- raise ValueError(s)
124
+ raise ValueError(fmt_string)
125
125
  try:
126
- p, s = scheme.split("+")
126
+ proto, ser = scheme.split("+")
127
127
  except ValueError:
128
- p, s = scheme, "yaml"
128
+ proto, ser = scheme, "yaml"
129
129
 
130
- proto: Protocol = Protocol(p)
131
- serialization: Serialization = Serialization(s)
132
- if proto in (Protocol.python, Protocol.module):
130
+ serialization: Serialization = Serialization(ser)
131
+ if proto in ("python", "module"):
133
132
  serialization = Serialization.raw
134
- if proto in (Protocol.http, Protocol.https):
135
- path = "://".join([proto.value, path])
133
+ if proto in ("http", "https"):
134
+ path = "://".join([proto, path])
136
135
 
137
136
  return Loadable(
138
137
  protocol=proto,
@@ -145,32 +144,32 @@ def raise_(e: Exception) -> Exception:
145
144
  raise e
146
145
 
147
146
 
148
- def load_file(path: str, loader: Serialization) -> Any:
147
+ def load_file(path: str, ser: Serialization) -> Any:
149
148
  with open(path) as f:
150
149
  contents = f.read()
151
150
  try:
152
- return serializers[loader](contents)
151
+ return serializers[ser](contents)
153
152
  except FileNotFoundError:
154
153
  raise FileNotFoundError(f"Unable to load {path}")
155
154
 
156
155
 
157
- def load_package_data(path: str, loader: Serialization) -> Any:
156
+ def load_package_data(path: str, ser: Serialization) -> Any:
158
157
  pkg, pkg_file = path.split(":")
159
158
  data = get_package_file_bytes(pkg, pkg_file)
160
- return serializers[loader](data)
159
+ return serializers[ser](data)
161
160
 
162
161
 
163
- def load_http(path: str, loader: Serialization) -> Any:
162
+ def load_http(path: str, ser: Serialization) -> Any:
164
163
  response = requests.get(path)
165
164
  response.raise_for_status()
166
165
  data = response.text
167
- return serializers[loader](data)
166
+ return serializers[ser](data)
168
167
 
169
168
 
170
- def load_env(variable: str, loader: Serialization = Serialization.raw) -> Any:
169
+ def load_env(variable: str, ser: Serialization = Serialization.raw) -> Any:
171
170
  data = os.getenv(variable)
172
171
  try:
173
- return serializers[loader](data)
172
+ return serializers[ser](data)
174
173
  except AttributeError as e:
175
174
  raise AttributeError(
176
175
  f"Unable to read environment variable {variable}: {repr(e)}"
@@ -188,7 +187,7 @@ def load_module(name: str, _: Serialization = Serialization.raw) -> Any:
188
187
  return imported
189
188
 
190
189
 
191
- def load_s3(path: str, loader: Serialization = Serialization.raw) -> Any:
190
+ def load_s3(path: str, ser: Serialization = Serialization.raw) -> Any:
192
191
  if isinstance(boto3, type(None)):
193
192
  raise ImportError(
194
193
  "boto3 must be installed to load S3 paths. Use ``pip install sovereign[boto]``"
@@ -197,7 +196,7 @@ def load_s3(path: str, loader: Serialization = Serialization.raw) -> Any:
197
196
  s3 = boto3.client("s3")
198
197
  response = s3.get_object(Bucket=bucket, Key=key)
199
198
  data = "".join([chunk.decode() for chunk in response["Body"]])
200
- return serializers[loader](data)
199
+ return serializers[ser](data)
201
200
 
202
201
 
203
202
  def load_python(path: str, _: Serialization = Serialization.raw) -> ModuleType:
@@ -210,14 +209,14 @@ def load_inline(path: str, _: Serialization = Serialization.raw) -> Any:
210
209
  return str(path)
211
210
 
212
211
 
213
- loaders: Dict[Protocol, Callable[[str, Serialization], Union[str, Any]]] = {
214
- Protocol.file: load_file,
215
- Protocol.pkgdata: load_package_data,
216
- Protocol.http: load_http,
217
- Protocol.https: load_http,
218
- Protocol.env: load_env,
219
- Protocol.module: load_module,
220
- Protocol.s3: load_s3,
221
- Protocol.python: load_python,
222
- Protocol.inline: load_inline,
212
+ loaders: Dict[str, Callable[[str, Serialization], Union[str, Any]]] = {
213
+ "file": load_file,
214
+ "pkgdata": load_package_data,
215
+ "http": load_http,
216
+ "https": load_http,
217
+ "env": load_env,
218
+ "module": load_module,
219
+ "s3": load_s3,
220
+ "python": load_python,
221
+ "inline": load_inline,
223
222
  }
@@ -13,6 +13,8 @@ from typing import (
13
13
  )
14
14
 
15
15
  from fastapi import HTTPException
16
+ from sovereign import config_loader
17
+ from sovereign.utils.entry_point_loader import EntryPointLoader
16
18
  from structlog.stdlib import BoundLogger
17
19
 
18
20
  from sovereign.config_loader import Loadable
@@ -53,6 +55,14 @@ class TemplateContext:
53
55
  self.stats = stats
54
56
  # initial load
55
57
  self.context: Dict[str, Any] = {}
58
+ entry_points = EntryPointLoader("loaders")
59
+ for entry_point in entry_points.groups["loaders"]:
60
+ custom_loader = entry_point.load()
61
+ try:
62
+ func = custom_loader.load
63
+ except AttributeError:
64
+ raise AttributeError("Custom loader does not implement .load()")
65
+ config_loader.loaders[entry_point.name] = func
56
66
  asyncio.run(self.load_context_variables())
57
67
 
58
68
  async def start_refresh_context(self) -> NoReturn:
@@ -60,7 +70,6 @@ class TemplateContext:
60
70
  await poll_forever_cron(self.refresh_cron, self.refresh_context)
61
71
  elif self.refresh_rate is not None:
62
72
  await poll_forever(self.refresh_rate, self.refresh_context)
63
-
64
73
  raise RuntimeError("Failed to start refresh_context, this should never happen")
65
74
 
66
75
  async def refresh_context(self) -> None:
@@ -68,25 +77,25 @@ class TemplateContext:
68
77
 
69
78
  async def _load_context(
70
79
  self,
71
- context_name: str,
72
- context_config: Loadable | str,
80
+ name: str,
81
+ loader: Loadable | str,
73
82
  refresh_num_retries: int,
74
83
  refresh_retry_interval_secs: int,
75
84
  ) -> LoadContextResponse:
76
85
  retries_left = refresh_num_retries
77
- context_response = {}
86
+ response = {}
78
87
 
79
88
  while True:
80
89
  try:
81
- if isinstance(context_config, Loadable):
82
- context_response = context_config.load()
83
- elif isinstance(context_config, str):
84
- context_response = Loadable.from_legacy_fmt(context_config).load()
90
+ if isinstance(loader, Loadable):
91
+ response = loader.load()
92
+ elif isinstance(loader, str):
93
+ response = Loadable.from_legacy_fmt(loader).load()
85
94
  self.stats.increment(
86
95
  "context.refresh.success",
87
- tags=[f"context:{context_name}"],
96
+ tags=[f"context:{name}"],
88
97
  )
89
- return LoadContextResponse(context_name, context_response)
98
+ return LoadContextResponse(name, response)
90
99
  # pylint: disable=broad-except
91
100
  except Exception as e:
92
101
  retries_left -= 1
@@ -95,34 +104,29 @@ class TemplateContext:
95
104
  self.logger.error(str(e), traceback=tb)
96
105
  self.stats.increment(
97
106
  "context.refresh.error",
98
- tags=[f"context:{context_name}"],
107
+ tags=[f"context:{name}"],
99
108
  )
100
- return LoadContextResponse(context_name, context_response, False)
109
+ return LoadContextResponse(name, response, False)
101
110
  else:
102
111
  await asyncio.sleep(refresh_retry_interval_secs)
103
112
 
104
113
  async def load_context_variables(self) -> None:
105
- context_coroutines: list[Awaitable[LoadContextResponse]] = []
106
- for context_name, context_config in self.configured_context.items():
107
- context_coroutines.append(
114
+ coroutines: list[Awaitable[LoadContextResponse]] = []
115
+ for name, conf in self.configured_context.items():
116
+ coroutines.append(
108
117
  self._load_context(
109
- context_name,
110
- context_config,
118
+ name,
119
+ conf,
111
120
  self.refresh_num_retries,
112
121
  self.refresh_retry_interval_secs,
113
122
  )
114
123
  )
115
124
 
116
- context_results: list[LoadContextResponse] = await asyncio.gather(
117
- *context_coroutines
118
- )
125
+ results: list[LoadContextResponse] = await asyncio.gather(*coroutines)
119
126
 
120
- for context_result in context_results:
121
- if (
122
- context_result.success
123
- or context_result.context_name not in self.context
124
- ):
125
- self.context[context_result.context_name] = context_result.context
127
+ for res in results:
128
+ if res.success or res.context_name not in self.context:
129
+ self.context[res.context_name] = res.context
126
130
 
127
131
  if "crypto" not in self.context and self.crypto:
128
132
  self.context["crypto"] = self.crypto
@@ -162,7 +166,9 @@ class TemplateContext:
162
166
  ret = self.build_new_context_from_instances(
163
167
  node_value=self.poller.extract_node_key(request.node),
164
168
  )
169
+ ret["__hide_from_ui"] = lambda v: v
165
170
  if request.hide_private_keys:
171
+ ret["__hide_from_ui"] = lambda _: "(value hidden)"
166
172
  ret["crypto"] = CipherContainer.from_encryption_configs(
167
173
  encryption_configs=[EncryptionConfig("", EncryptionType.DISABLED)],
168
174
  logger=self.logger,
@@ -19,7 +19,7 @@ from pydantic import (
19
19
  validator,
20
20
  )
21
21
 
22
- from sovereign.config_loader import Loadable, Protocol, Serialization, jinja_env
22
+ from sovereign.config_loader import Loadable, Serialization, jinja_env
23
23
  from sovereign.utils.crypto.suites import EncryptionType
24
24
  from sovereign.utils.version_info import compute_hash
25
25
 
@@ -132,7 +132,7 @@ class XdsTemplate:
132
132
  self.loadable: Loadable = Loadable.from_legacy_fmt(path)
133
133
  elif isinstance(path, Loadable):
134
134
  self.loadable = path
135
- self.is_python_source = self.loadable.protocol == Protocol.python
135
+ self.is_python_source = self.loadable.protocol == "python"
136
136
  self.source = self.load_source()
137
137
  template_ast = jinja_env.parse(self.source)
138
138
  self.jinja_variables = meta.find_undeclared_variables(template_ast)
@@ -177,7 +177,7 @@ class XdsTemplate:
177
177
  # we can simply read and return the source of it.
178
178
  old_protocol = self.loadable.protocol
179
179
  old_serialization = self.loadable.serialization
180
- self.loadable.protocol = Protocol("inline")
180
+ self.loadable.protocol = "inline"
181
181
  self.loadable.serialization = Serialization("string")
182
182
  ret = self.loadable.load()
183
183
  self.loadable.protocol = old_protocol
@@ -0,0 +1,9 @@
1
+ from typing import Any
2
+ from sovereign.config_loader import CustomLoader, Serialization
3
+
4
+
5
+ class Multiply(CustomLoader):
6
+ @staticmethod
7
+ def load(path: str, ser: Serialization) -> Any:
8
+ result = path * 2
9
+ return f"{ser}:{result}"
File without changes
File without changes