sovereign 1.0.0b101__py3-none-any.whl → 1.0.0b102__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.

Potentially problematic release.


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

sovereign/context.py CHANGED
@@ -75,6 +75,7 @@ class TemplateContext:
75
75
  new = hash(result)
76
76
 
77
77
  if old != new:
78
+ stats.increment("context.updated", tags=[f"context:{name}"])
78
79
  self.hashes[name] = new
79
80
  # Debounced event notification to the worker
80
81
  if self.notify_consumers:
sovereign/schemas.py CHANGED
@@ -410,7 +410,7 @@ class DiscoveryRequest(BaseModel):
410
410
  return f"version={self.envoy_version}, cluster={self.node.cluster}, resource={self.resource_type}, names={self.resources}"
411
411
 
412
412
  def __str__(self) -> str:
413
- return f"DiscoverRequest({self.debug()})"
413
+ return f"DiscoveryRequest({self.debug()})"
414
414
 
415
415
 
416
416
  class DiscoveryResponse(BaseModel):
@@ -645,6 +645,7 @@ class AccessLogConfiguration(BaseSettings):
645
645
  class LoggingConfiguration(BaseSettings):
646
646
  application_logs: ApplicationLogConfiguration = ApplicationLogConfiguration()
647
647
  access_logs: AccessLogConfiguration = AccessLogConfiguration()
648
+ log_source_diffs: bool = False
648
649
 
649
650
 
650
651
  class ContextFileCache(BaseSettings):
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import asyncio
2
3
  import traceback
3
4
  from copy import deepcopy
@@ -7,11 +8,7 @@ from typing import Iterable, Any, Dict, List, Union, Type, Optional
7
8
 
8
9
  from glom import glom, PathAccessError
9
10
 
10
- from sovereign.schemas import (
11
- ConfiguredSource,
12
- SourceData,
13
- Node,
14
- )
11
+ from sovereign.schemas import ConfiguredSource, SourceData, Node, config
15
12
  from sovereign.utils.entry_point_loader import EntryPointLoader
16
13
  from sovereign.sources.lib import Source
17
14
  from sovereign.modifiers.lib import Modifier, GlobalModifier
@@ -36,6 +33,78 @@ Mods = Dict[str, Type[Modifier]]
36
33
  GMods = Dict[str, Type[GlobalModifier]]
37
34
 
38
35
 
36
+ def item_comparison(old, new):
37
+ if not isinstance(old, dict) or not isinstance(new, dict):
38
+ return ["content_changed"]
39
+
40
+ changes = []
41
+ all_keys = set(old.keys()) | set(new.keys())
42
+
43
+ for key in sorted(all_keys):
44
+ old_val = old.get(key)
45
+ new_val = new.get(key)
46
+
47
+ if old_val != new_val:
48
+ if key not in old:
49
+ changes.append(f"+{key}:{new_val}")
50
+ elif key not in new:
51
+ changes.append(f"-{key}:{old_val}")
52
+ else:
53
+ changes.append(f"~{key}:{old_val}→{new_val}")
54
+
55
+ return changes
56
+
57
+
58
+ def per_field_diff(old, new):
59
+ changes = []
60
+ max_len = max(len(old), len(new))
61
+
62
+ for i in range(max_len):
63
+ old_inst = old[i] if i < len(old) else None
64
+ new_inst = new[i] if i < len(new) else None
65
+
66
+ if old_inst is None:
67
+ changes.append(f"added [index:{i}] {new_inst}")
68
+ elif new_inst is None:
69
+ changes.append(f"removed [index:{i}] {old_inst}")
70
+ elif old_inst != new_inst:
71
+ field_changes = item_comparison(old_inst, new_inst)
72
+ if field_changes:
73
+ changes.append(f"modified [index:{i}]: {', '.join(field_changes)}")
74
+
75
+ return changes
76
+
77
+
78
+ def source_diff_summary(prev, curr):
79
+ if prev is None:
80
+ return [
81
+ f"scope:{scope} added:{len(instances)} instances"
82
+ for scope, instances in curr.scopes.items()
83
+ if instances
84
+ ]
85
+
86
+ summary = []
87
+ all_scopes = set(prev.scopes.keys()) | set(curr.scopes.keys())
88
+
89
+ for scope in sorted(all_scopes):
90
+ old = prev.scopes.get(scope, [])
91
+ new = curr.scopes.get(scope, [])
92
+
93
+ n_old = len(old)
94
+ n_new = len(new)
95
+
96
+ if n_old == 0 and n_new > 0:
97
+ summary.append(f"scope:{scope} added:{n_new} instances")
98
+ elif n_old > 0 and n_new == 0:
99
+ summary.append(f"scope:{scope} removed:{n_old} instances")
100
+ elif old != new:
101
+ detailed_changes = per_field_diff(old, new)
102
+ if detailed_changes:
103
+ summary.append(f"scope:{scope} changes: {'; '.join(detailed_changes)}")
104
+
105
+ return summary if summary else ["no changes detected"]
106
+
107
+
39
108
  class SourcePoller:
40
109
  def __init__(
41
110
  self,
@@ -179,9 +248,25 @@ class SourcePoller:
179
248
  else:
180
249
  self.stats.increment("sources.refreshed")
181
250
  self.last_updated = datetime.now()
251
+ old_data = getattr(self, "source_data", None)
182
252
  self.instance_count = len(
183
253
  [instance for scope in new.scopes.values() for instance in scope]
184
254
  )
255
+
256
+ if config.logging.log_source_diffs:
257
+ diff_summary = source_diff_summary(old_data, new)
258
+ # printing json directly because the logger is fucking stupid
259
+ print(
260
+ json.dumps(
261
+ dict(
262
+ event="Sources refreshed with changes",
263
+ level="info",
264
+ diff=diff_summary,
265
+ total_instances=self.instance_count,
266
+ )
267
+ )
268
+ )
269
+
185
270
  self.source_data = new
186
271
  return True
187
272
 
@@ -14,6 +14,12 @@
14
14
  ];
15
15
  const resources = resourceNames.map((name, index) => ({ name: name, index: index }));
16
16
  let filteredResources = [...resources];
17
+
18
+ // Function to set envoy_version cookie and reload page
19
+ function setEnvoyVersion(version) {
20
+ document.cookie = `envoy_version=${version}; path=/ui/resources/; max-age=31536000`;
21
+ window.location.reload();
22
+ }
17
23
  </script>
18
24
  <style>
19
25
  #filterInput {
@@ -284,7 +290,7 @@
284
290
  </div>
285
291
  {% for v in available_versions %}
286
292
  <a class="dropdown-item{% if v == version %} is-active{% endif %}"
287
- href="/ui/set-version?version={{ v }}">
293
+ href="#" onclick="setEnvoyVersion('{{ v }}'); return false;">
288
294
  {{ v.replace('_', '') }}
289
295
  </a>
290
296
  {% endfor %}
sovereign/views/api.py CHANGED
@@ -11,6 +11,20 @@ from sovereign.utils.mock import mock_discovery_request
11
11
  router = APIRouter()
12
12
 
13
13
 
14
+ def _traverse(data, prefix, expressions):
15
+ for key, value in data.items():
16
+ path = f"{prefix}.{key}" if prefix else key
17
+ if isinstance(value, dict):
18
+ yield from _traverse(value, path, expressions)
19
+ else:
20
+ yield f"{path}={value}"
21
+
22
+
23
+ def expand_metadata_to_expr(m):
24
+ exprs = []
25
+ yield from _traverse(m, "", exprs)
26
+
27
+
14
28
  @router.get("/resources/{resource_type}", summary="Get resources for a given type")
15
29
  async def resource(
16
30
  resource_type: DiscoveryTypes = Path(title="xDS Resource type"),
@@ -19,16 +33,25 @@ async def resource(
19
33
  service_cluster: Optional[str] = Query("*", title="Envoy Service cluster"),
20
34
  region: Optional[str] = Query(None, title="Locality Zone"),
21
35
  version: Optional[str] = Query(None, title="Envoy Semantic Version"),
36
+ metadata: Optional[str] = Query(None, title="Envoy node metadata to filter by"),
22
37
  ) -> Response:
38
+ expressions = [f"cluster={service_cluster}"]
39
+ try:
40
+ metadata = json.loads(metadata or "{}")
41
+ for expr in expand_metadata_to_expr(metadata):
42
+ expressions.append(expr)
43
+ except Exception:
44
+ pass
23
45
  kwargs = dict(
24
46
  api_version=api_version,
25
47
  resource_type=DiscoveryTypes(resource_type).value,
26
48
  resource_names=resource_name,
27
49
  version=version,
28
50
  region=region,
29
- expressions=[f"cluster={service_cluster}"],
51
+ expressions=expressions,
30
52
  )
31
53
  req = mock_discovery_request(**{k: v for k, v in kwargs.items() if v is not None})
54
+ print(req)
32
55
  response = await cache.blocking_read(req)
33
56
  if content := getattr(response, "text", None):
34
57
  return Response(content, media_type="application/json")
@@ -5,7 +5,7 @@ from typing import Any, Dict, List
5
5
  from fastapi import APIRouter, Cookie, Path, Query
6
6
  from fastapi.encoders import jsonable_encoder
7
7
  from fastapi.requests import Request
8
- from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
8
+ from fastapi.responses import HTMLResponse, JSONResponse, Response
9
9
 
10
10
  from sovereign import html_templates, cache
11
11
  from sovereign.schemas import DiscoveryTypes, XDS_TEMPLATES
@@ -18,6 +18,7 @@ all_types = [t.value for t in DiscoveryTypes]
18
18
 
19
19
 
20
20
  @router.get("/")
21
+ @router.get("/resources")
21
22
  async def ui_main(request: Request) -> HTMLResponse:
22
23
  try:
23
24
  return html_templates.TemplateResponse(
@@ -42,21 +43,6 @@ async def ui_main(request: Request) -> HTMLResponse:
42
43
  )
43
44
 
44
45
 
45
- @router.get(
46
- "/set-version", summary="Filter the UI by a certain Envoy Version (stores a Cookie)"
47
- )
48
- async def set_envoy_version(
49
- request: Request,
50
- version: str = Query(
51
- "__any__", title="The clients envoy version to emulate in this XDS request"
52
- ),
53
- ) -> Response:
54
- url = request.headers.get("Referer", "/ui")
55
- response = RedirectResponse(url=url)
56
- response.set_cookie(key="envoy_version", value=version)
57
- return response
58
-
59
-
60
46
  @router.get(
61
47
  "/resources/{xds_type}", summary="List available resources for a given xDS type"
62
48
  )
sovereign/worker.py CHANGED
@@ -1,8 +1,7 @@
1
1
  import asyncio
2
2
  from typing import Optional
3
- from multiprocessing import Process
3
+ from multiprocessing import Process, cpu_count
4
4
  from contextlib import asynccontextmanager
5
- from concurrent.futures import ThreadPoolExecutor
6
5
 
7
6
  from fastapi import FastAPI, Body
8
7
 
@@ -23,10 +22,9 @@ from sovereign.context import NEW_CONTEXT
23
22
 
24
23
  ClientId = str
25
24
  ONDEMAND: asyncio.Queue[tuple[ClientId, DiscoveryRequest]] = asyncio.Queue(100)
26
- executor = ThreadPoolExecutor(4)
25
+ RENDER_SEMAPHORE = asyncio.Semaphore(cpu_count())
27
26
 
28
27
 
29
- # TODO: do something about this ---------------------------------------
30
28
  def hidden_field(*args, **kwargs):
31
29
  return "(value hidden)"
32
30
 
@@ -62,43 +60,37 @@ if config.sources is not None:
62
60
  context_middleware.append(poller.add_to_context)
63
61
 
64
62
 
65
- if poller is not None:
66
- poller.lazy_load_modifiers(config.modifiers)
67
- poller.lazy_load_global_modifiers(config.global_modifiers)
68
-
69
- template_context.middleware = context_middleware
70
- # ---------------------------------------------------------------------
71
-
72
-
73
63
  def render(job: rendering.RenderJob):
74
64
  log.debug(f"Spawning render process for {job.id}")
75
65
  Process(target=rendering.generate, args=[job]).start()
76
66
 
77
67
 
78
- def batch_render(jobs: list[rendering.RenderJob]):
79
- log.debug("Spawning batch render process for all clients")
80
- Process(target=rendering.batch_generate, args=[jobs]).start()
68
+ async def submit_render(job: rendering.RenderJob):
69
+ async with RENDER_SEMAPHORE:
70
+ render(job)
81
71
 
82
72
 
83
73
  async def render_on_event():
84
74
  while True:
85
75
  # block forever until new context arrives
86
76
  await NEW_CONTEXT.wait()
87
- stats.increment("template.render_on_event")
88
77
  log.debug("New context detected, re-rendering templates")
89
78
  try:
90
79
  if registered := cache.clients():
91
80
  log.debug("New context detected, re-rendering templates")
92
- batch_render(
93
- [
94
- rendering.RenderJob(
95
- id=client,
96
- request=request,
97
- context=template_context.get_context(request),
98
- )
99
- for client, request in registered
100
- ]
101
- )
81
+ jobs = [
82
+ rendering.RenderJob(
83
+ id=client,
84
+ request=request,
85
+ context=template_context.get_context(request),
86
+ )
87
+ for client, request in registered
88
+ ]
89
+ tasks = [submit_render(job) for job in jobs]
90
+ size = len(tasks)
91
+ stats.increment("template.render_on_event", tags=[f"batch_size:{size}"])
92
+ await asyncio.gather(*tasks)
93
+ log.debug(f"Completed rendering {size} jobs")
102
94
  finally:
103
95
  NEW_CONTEXT.clear()
104
96
 
@@ -111,7 +103,7 @@ async def render_on_demand():
111
103
  job = rendering.RenderJob(
112
104
  id=id, request=request, context=template_context.get_context(request)
113
105
  )
114
- await asyncio.get_event_loop().run_in_executor(executor, render, job)
106
+ await submit_render(job)
115
107
  ONDEMAND.task_done()
116
108
 
117
109
 
@@ -124,25 +116,19 @@ async def lifespan(_: FastAPI):
124
116
 
125
117
  # Template context
126
118
  log.debug("Starting context loop")
119
+ template_context.middleware = context_middleware
127
120
  asyncio.create_task(template_context.start())
128
121
  await NEW_CONTEXT.wait() # first refresh finished
129
122
 
130
123
  # Source polling
131
124
  if poller is not None:
132
125
  log.debug("Starting source poller")
133
- import threading
134
-
135
- threading.Thread(target=poller_thread, args=[poller], daemon=True).start()
126
+ poller.lazy_load_modifiers(config.modifiers)
127
+ poller.lazy_load_global_modifiers(config.global_modifiers)
128
+ asyncio.create_task(poller.poll_forever())
136
129
  yield
137
130
 
138
131
 
139
- def poller_thread(poller):
140
- log.debug("Starting source poller")
141
- loop = asyncio.new_event_loop()
142
- asyncio.set_event_loop(loop)
143
- loop.run_until_complete(poller.poll_forever())
144
-
145
-
146
132
  worker = FastAPI(lifespan=lifespan)
147
133
  try:
148
134
  import sentry_sdk
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sovereign
3
- Version: 1.0.0b101
3
+ Version: 1.0.0b102
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
@@ -2,7 +2,7 @@ sovereign/__init__.py,sha256=m8MVzaMSW4AvAqHvUAsXFdp8Oas5oQ8X7BcVt7Hfcik,1431
2
2
  sovereign/app.py,sha256=fsf4Jgni2G4EYJO0oQSWfGRZVDBvE2Yfick4n2YR6K4,4876
3
3
  sovereign/cache.py,sha256=P2LAskGxZSFgQ392aKz7auHty57HJNepQ7xl2-iWbBA,3842
4
4
  sovereign/constants.py,sha256=qdWD1lTvkaW5JGF7TmZhfksQHlRAJFVqbG7v6JQA9k8,46
5
- sovereign/context.py,sha256=4rFAocaKquMIGzu72RwEi72AVUJ6Hhtvkp2Nzj9k_YQ,8839
5
+ sovereign/context.py,sha256=aoGJ5k1n8ytCk7jggQ6XTx6Hx_ocy7cEkvGOFS3gzzc,8912
6
6
  sovereign/dynamic_config/__init__.py,sha256=0hrI9Y-FzDywEM9Lu6i2mPFhs1c47C096R1B_-E3sKA,3161
7
7
  sovereign/dynamic_config/deser.py,sha256=N3iUvDpuNHWjxUbGFydMVKicx4o8DyfvNukorqnQdt8,1834
8
8
  sovereign/dynamic_config/loaders.py,sha256=gPkxTL7gep20HIMRvjgOqAdUWqtb3970VBCAcUrIM4c,2915
@@ -17,13 +17,13 @@ sovereign/modifiers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSu
17
17
  sovereign/modifiers/lib.py,sha256=Cx0VrpTKbSjb3YmHyG4Jy6YEaPlrwpeqNaom3zu1_hw,2885
18
18
  sovereign/rendering.py,sha256=MIA7se7-C4WTWf7xZSgqpf7NvhDT7NkZbR3_G9N1dHI,5015
19
19
  sovereign/response_class.py,sha256=beMAFV-4L6DwyWzJzy71GkEW4gb7fzH1jd8-Tul13cU,427
20
- sovereign/schemas.py,sha256=wCovDUrK4Tsu-eFktfy9Fo13zVmpiaOMxiL-GQBzNUE,37225
20
+ sovereign/schemas.py,sha256=nuiLKq26Fd5MGDMPcTEzyb0QuVuWKaXZRtIdlQ3BzkA,37261
21
21
  sovereign/server.py,sha256=3qfcUGaRrTF2GTfGEJgGnPXBiZGis1qxOlpIKvoCyFA,2596
22
22
  sovereign/sources/__init__.py,sha256=g9hEpFk8j5i1ApHQpbc9giTyJW41Ppgsqv5P9zGxOJk,78
23
23
  sovereign/sources/file.py,sha256=prUThsDCSPNwZaZpkKXhAm-GVRZWbBoGKGU0It4HHXs,690
24
24
  sovereign/sources/inline.py,sha256=pP77m7bHjqE3sSoqZthcuw1ARVMf9gooVwbz4B8OAek,1003
25
25
  sovereign/sources/lib.py,sha256=0hk_G6mKJrB65WokVZnqF5kdJ3vsQZMNPuJqJO0mBsI,1031
26
- sovereign/sources/poller.py,sha256=U_5oEnui36ItU7fvIa2JYTNASKRYsRWFpC3P3MmXYfs,11880
26
+ sovereign/sources/poller.py,sha256=sJ4bi6pCPX_cw1RGAUTQfM11bsWLeohEkpzSfFYwUeI,14706
27
27
  sovereign/static/node_expression.js,sha256=dL9QLM49jorqavf3Qtye6E1QTWYDT1rFI0tQR1HsiLQ,504
28
28
  sovereign/static/panel.js,sha256=i5mGExjv-I4Gtt9dQiTyFwPZa8pg5rXeuTeidXNUiTE,2695
29
29
  sovereign/static/sass/style.scss,sha256=tPHPEm3sZeBFGDyyn3pHcA-nbaKT-h-UsSTsf6dHNDU,1158
@@ -31,7 +31,7 @@ sovereign/static/style.css,sha256=vG8HPsbCbPIZfHgy7gSeof97Pnp0okkyaXyJzIEEW-8,44
31
31
  sovereign/statistics.py,sha256=gXpQgQOTKqU68loQ_NU1OmxNvsRpAp38RpunjbecIRo,2587
32
32
  sovereign/templates/base.html,sha256=5vw3-NmN291pXRdArpCwhSce9bAYBWCJVRhvO5EmE9g,2296
33
33
  sovereign/templates/err.html,sha256=a3cEzOqyqWOIe3YxfTEjkxbTfxBxq1knD6GwzEFljfs,603
34
- sovereign/templates/resources.html,sha256=NYWrfI19HzIVDe9A-DtZkBr5h4o4HCIyXRGZ_D_HwSE,39955
34
+ sovereign/templates/resources.html,sha256=QaZ1S38JhAZg3-PfQS1cAKhCczVLXw9e4pztBrqr4qs,40217
35
35
  sovereign/testing/loaders.py,sha256=mcmErhI9ZkJUBZl8jv2qP-PCBRFeAIgyBFlfCgU4Vvk,199
36
36
  sovereign/testing/modifiers.py,sha256=7_c2hWXn_sYJ6997N1_uSWtClOikcOzu1yRCY56-l-4,361
37
37
  sovereign/tracing.py,sha256=Xo3npgh6yesACSlynv9j6qnXxvYEBzXv5LL4Zkc1QDw,2446
@@ -54,14 +54,14 @@ sovereign/utils/timer.py,sha256=_dUtEasj0BKbWYuQ_T3HFIyjurXXj-La-dNSMAwKMSo,795
54
54
  sovereign/utils/version_info.py,sha256=adBfu0z6jsg8E5-BIUjZyBwZvfLASj7fpCpYeIvBeMY,576
55
55
  sovereign/utils/weighted_clusters.py,sha256=bPzuRE7Qgvv04HcR2AhMDvBrFlZ8AfteweLKhY9SvWg,1166
56
56
  sovereign/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
- sovereign/views/api.py,sha256=xiTk8QWvk8PyUQyYBX4Ufcvo5zBtLvNULDd_ePvt3xI,1498
57
+ sovereign/views/api.py,sha256=T_le6Cfw03KsAc1nx93vZw97uUIFtF86vz9daXR_yUM,2177
58
58
  sovereign/views/crypto.py,sha256=7y0eHWtt-bbr2CwHEkH7odPaJ1IEviU-71U-MYJD0Kc,3360
59
59
  sovereign/views/discovery.py,sha256=4sVRPWpH8nYwY26GWK9N8maOYUNbIJTrUckZ3MahqAU,1876
60
60
  sovereign/views/healthchecks.py,sha256=TaXbxkX679jyQ8v5FxtBa2Qa0Z7KuqQ10WgAqfuVGUc,1743
61
- sovereign/views/interface.py,sha256=BQ-lBWC0Ob1I40W7XKqPjLwVxRC6pOkZfgl2QsaKZmQ,6796
62
- sovereign/worker.py,sha256=NveJbhO01W44wCBVVTKw-Em54Fp8hbrHt8dQY8tTPFI,5365
63
- sovereign-1.0.0b101.dist-info/LICENSE.txt,sha256=2X125zvAb9AYLjCgdMDQZuufhm0kwcg31A8pGKj_-VY,560
64
- sovereign-1.0.0b101.dist-info/METADATA,sha256=9IQsOnZQ8DZtFtKN4IOafbp16i4f4T9bx783shS_dMY,6268
65
- sovereign-1.0.0b101.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
66
- sovereign-1.0.0b101.dist-info/entry_points.txt,sha256=VKJdnnN_HNL8xYQMXsFXfFmN6QkdXMEk5S964avxQJI,1404
67
- sovereign-1.0.0b101.dist-info/RECORD,,
61
+ sovereign/views/interface.py,sha256=FmQ7LiUPLSvkEDOKCncrnKMD9g1lJKu-DQNbbyi8mqk,6346
62
+ sovereign/worker.py,sha256=NqXlfi9QRlXcWeUPqHWvnYx2CPldY44iLb_fBJAiZ-4,4983
63
+ sovereign-1.0.0b102.dist-info/LICENSE.txt,sha256=2X125zvAb9AYLjCgdMDQZuufhm0kwcg31A8pGKj_-VY,560
64
+ sovereign-1.0.0b102.dist-info/METADATA,sha256=3vjnWeVBnS-UJ9buN9wk8n9PEgdeCi2hY5r2RFZ4fhc,6268
65
+ sovereign-1.0.0b102.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
66
+ sovereign-1.0.0b102.dist-info/entry_points.txt,sha256=VKJdnnN_HNL8xYQMXsFXfFmN6QkdXMEk5S964avxQJI,1404
67
+ sovereign-1.0.0b102.dist-info/RECORD,,