sovereign 1.0.0b102__py3-none-any.whl → 1.0.0b104__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/schemas.py CHANGED
@@ -44,7 +44,7 @@ class CacheStrategy(str, Enum):
44
44
 
45
45
 
46
46
  class SourceData(BaseModel):
47
- scopes: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
47
+ scopes: Dict[str, List[Dict[str, Any]]] = Field(default_factory=dict)
48
48
 
49
49
 
50
50
  class ConfiguredSource(BaseModel):
@@ -716,6 +716,8 @@ class ContextConfiguration(BaseSettings):
716
716
 
717
717
  class SourcesConfiguration(BaseSettings):
718
718
  refresh_rate: int = Field(30, alias="SOVEREIGN_SOURCES_REFRESH_RATE")
719
+ max_retries: int = Field(3, alias="SOVEREIGN_SOURCES_MAX_RETRIES")
720
+ retry_delay: int = Field(1, alias="SOVEREIGN_SOURCES_RETRY_DELAY")
719
721
  cache_strategy: Optional[Any] = None
720
722
  model_config = SettingsConfigDict(
721
723
  env_file=".env",
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import uuid
2
3
  import asyncio
3
4
  import traceback
4
5
  from copy import deepcopy
@@ -33,29 +34,74 @@ Mods = Dict[str, Type[Modifier]]
33
34
  GMods = Dict[str, Type[GlobalModifier]]
34
35
 
35
36
 
36
- def item_comparison(old, new):
37
- if not isinstance(old, dict) or not isinstance(new, dict):
38
- return ["content_changed"]
39
-
37
+ def _deep_diff(old, new, path="") -> list[dict[str, Any]]:
40
38
  changes = []
41
- all_keys = set(old.keys()) | set(new.keys())
42
39
 
43
- for key in sorted(all_keys):
44
- old_val = old.get(key)
45
- new_val = new.get(key)
40
+ # handle add/remove
41
+ if (old, new) == (None, None):
42
+ return changes
43
+ elif old is None:
44
+ changes.append({"op": "add", "path": path, "value": new})
45
+ return changes
46
+ elif new is None:
47
+ changes.append({"op": "remove", "path": path, "old_value": old})
48
+ return changes
49
+
50
+ # handle completely different types
51
+ if type(old) is not type(new):
52
+ changes.append(
53
+ {"op": "change", "path": path, "old_value": old, "new_value": new}
54
+ )
55
+ return changes
56
+
57
+ # handle fields recursively
58
+ if isinstance(old, dict) and isinstance(new, dict):
59
+ all_keys = set(old.keys()) | set(new.keys())
60
+
61
+ for key in sorted(all_keys):
62
+ old_val = old.get(key)
63
+ new_val = new.get(key)
64
+
65
+ current_path = f"{path}.{key}" if path else key
46
66
 
47
- if old_val != new_val:
48
67
  if key not in old:
49
- changes.append(f"+{key}:{new_val}")
68
+ changes.append({"op": "add", "path": current_path, "value": new_val})
50
69
  elif key not in new:
51
- changes.append(f"-{key}:{old_val}")
52
- else:
53
- changes.append(f"~{key}:{old_val}→{new_val}")
70
+ changes.append(
71
+ {"op": "remove", "path": current_path, "old_value": old_val}
72
+ )
73
+ elif old_val != new_val:
74
+ nested_changes = _deep_diff(old_val, new_val, current_path)
75
+ changes.extend(nested_changes)
76
+
77
+ # handle items recursively
78
+ elif isinstance(old, list) and isinstance(new, list):
79
+ max_len = max(len(old), len(new))
80
+
81
+ for i in range(max_len):
82
+ current_path = f"{path}[{i}]" if path else f"[{i}]"
83
+
84
+ if i >= len(old):
85
+ changes.append({"op": "add", "path": current_path, "value": new[i]})
86
+ elif i >= len(new):
87
+ changes.append(
88
+ {"op": "remove", "path": current_path, "old_value": old[i]}
89
+ )
90
+ elif old[i] != new[i]:
91
+ nested_changes = _deep_diff(old[i], new[i], current_path)
92
+ changes.extend(nested_changes)
93
+
94
+ # handle primitives
95
+ else:
96
+ if old != new:
97
+ changes.append(
98
+ {"op": "change", "path": path, "old_value": old, "new_value": new}
99
+ )
54
100
 
55
101
  return changes
56
102
 
57
103
 
58
- def per_field_diff(old, new):
104
+ def per_field_diff(old, new) -> list[dict[str, Any]]:
59
105
  changes = []
60
106
  max_len = max(len(old), len(new))
61
107
 
@@ -64,45 +110,64 @@ def per_field_diff(old, new):
64
110
  new_inst = new[i] if i < len(new) else None
65
111
 
66
112
  if old_inst is None:
67
- changes.append(f"added [index:{i}] {new_inst}")
113
+ changes.append({"op": "add", "path": f"[{i}]", "value": new_inst})
68
114
  elif new_inst is None:
69
- changes.append(f"removed [index:{i}] {old_inst}")
115
+ changes.append({"op": "remove", "path": f"[{i}]", "old_value": old_inst})
70
116
  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)}")
117
+ # Use the deep diff with index prefix
118
+ field_changes = _deep_diff(old_inst, new_inst, f"[{i}]")
119
+ changes.extend(field_changes)
74
120
 
75
121
  return changes
76
122
 
77
123
 
78
- def source_diff_summary(prev, curr):
124
+ def _gen_uuid(diff_summary: dict[str, Any]) -> str:
125
+ blob = json.dumps(diff_summary, sort_keys=True, separators=("", ""))
126
+ return str(uuid.uuid5(uuid.NAMESPACE_DNS, blob))
127
+
128
+
129
+ def source_diff_summary(prev, curr) -> dict[str, Any]:
79
130
  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
- ]
131
+ summary = {
132
+ "type": "initial_load",
133
+ "scopes": {
134
+ scope: {"added": len(instances)}
135
+ for scope, instances in curr.scopes.items()
136
+ if instances
137
+ },
138
+ }
139
+ else:
140
+ summary = {"type": "update", "scopes": {}}
141
+
142
+ all_scopes = set(prev.scopes.keys()) | set(curr.scopes.keys())
143
+
144
+ for scope in sorted(all_scopes):
145
+ old = prev.scopes.get(scope, [])
146
+ new = curr.scopes.get(scope, [])
85
147
 
86
- summary = []
87
- all_scopes = set(prev.scopes.keys()) | set(curr.scopes.keys())
148
+ n_old = len(old)
149
+ n_new = len(new)
88
150
 
89
- for scope in sorted(all_scopes):
90
- old = prev.scopes.get(scope, [])
91
- new = curr.scopes.get(scope, [])
151
+ scope_changes = {}
92
152
 
93
- n_old = len(old)
94
- n_new = len(new)
153
+ if n_old == 0 and n_new > 0:
154
+ scope_changes["added"] = n_new
155
+ elif n_old > 0 and n_new == 0:
156
+ scope_changes["removed"] = n_old
157
+ elif old != new:
158
+ detailed_changes = per_field_diff(old, new)
159
+ if detailed_changes:
160
+ scope_changes["field_changes"] = detailed_changes
161
+ scope_changes["count_change"] = n_new - n_old
95
162
 
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)}")
163
+ if scope_changes:
164
+ summary["scopes"][scope] = scope_changes
104
165
 
105
- return summary if summary else ["no changes detected"]
166
+ if not summary["scopes"]:
167
+ summary = {"type": "no_changes"}
168
+
169
+ summary["uuid"] = _gen_uuid(summary)
170
+ return summary
106
171
 
107
172
 
108
173
  class SourcePoller:
@@ -137,13 +202,17 @@ class SourcePoller:
137
202
  self.global_modifiers: GMods = dict()
138
203
 
139
204
  # initially set data and modify
140
- self.source_data: SourceData
205
+ self.source_data: SourceData = SourceData()
206
+ self.source_data_modified: SourceData = SourceData()
141
207
  self.last_updated = datetime.now()
142
208
  self.instance_count = 0
143
209
 
144
210
  self.cache: dict[str, dict[str, list[dict[str, Any]]]] = {}
145
211
  self.registry: set[Any] = set()
146
212
 
213
+ # Retry state
214
+ self.retry_count = 0
215
+
147
216
  @property
148
217
  def data_is_stale(self) -> bool:
149
218
  return self.last_updated < datetime.now() - timedelta(minutes=2)
@@ -226,20 +295,37 @@ class SourcePoller:
226
295
 
227
296
  def refresh(self) -> bool:
228
297
  self.stats.increment("sources.attempt")
298
+
299
+ # Get retry config from global source config
300
+ max_retries = config.source_config.max_retries
301
+
229
302
  try:
230
303
  new = SourceData()
231
304
  for source in self.sources:
232
- new.scopes[source.scope].extend(source.get())
305
+ scope = source.scope
306
+ if scope not in new.scopes:
307
+ new.scopes[scope] = []
308
+ new.scopes[scope].extend(source.get())
233
309
  except Exception as e:
310
+ self.retry_count += 1
234
311
  self.logger.error(
235
- event="Error while refreshing sources",
312
+ event=f"Error while refreshing sources (attempt {self.retry_count}/{max_retries})",
236
313
  traceback=[line for line in traceback.format_exc().split("\n")],
237
314
  error=e.__class__.__name__,
238
315
  detail=getattr(e, "detail", "-"),
316
+ retry_count=self.retry_count,
239
317
  )
240
318
  self.stats.increment("sources.error")
319
+
320
+ if self.retry_count >= max_retries:
321
+ # Reset retry count for next cycle
322
+ self.retry_count = 0
323
+ self.stats.increment("sources.error.final")
241
324
  return False
242
325
 
326
+ # Success - reset retry count
327
+ self.retry_count = 0
328
+
243
329
  # Is the new data the same as what we currently have
244
330
  if new == getattr(self, "source_data", None):
245
331
  self.stats.increment("sources.unchanged")
@@ -345,6 +431,8 @@ class SourcePoller:
345
431
  or is_debug_request(node_value)
346
432
  )
347
433
  if match:
434
+ if scope not in ret.scopes:
435
+ ret.scopes[scope] = []
348
436
  ret.scopes[scope].append(instance)
349
437
  return ret
350
438
 
@@ -406,5 +494,17 @@ class SourcePoller:
406
494
  while True:
407
495
  try:
408
496
  self.poll()
409
- finally:
497
+
498
+ # If we have retry count, use exponential backoff for next attempt
499
+ if self.retry_count > 0:
500
+ retry_delay = config.source_config.retry_delay
501
+ delay = min(
502
+ retry_delay * (2 ** (self.retry_count - 1)),
503
+ self.source_refresh_rate, # Cap at normal refresh rate
504
+ )
505
+ await asyncio.sleep(delay)
506
+ else:
507
+ await asyncio.sleep(self.source_refresh_rate)
508
+ except Exception as e:
509
+ self.logger.error(f"Unexpected error in poll loop: {e}")
410
510
  await asyncio.sleep(self.source_refresh_rate)
sovereign/statistics.py CHANGED
@@ -53,10 +53,12 @@ def configure_statsd() -> StatsDProxy:
53
53
  from datadog import DogStatsd
54
54
 
55
55
  class CustomStatsd(DogStatsd): # type: ignore
56
- def _report(self, metric, metric_type, value, tags, sample_rate) -> None: # type: ignore
57
- super()._report(metric, metric_type, value, tags, sample_rate)
58
- self.emitted: Dict[str, Any] = dict()
59
- self.emitted[metric] = self.emitted.setdefault(metric, 0) + 1
56
+ def _report(self, *args, **kwargs) -> None: # type: ignore
57
+ super()._report(*args, **kwargs)
58
+ # Capture the metric name and increment its count for debugging
59
+ if metric := kwargs.get("metric"):
60
+ self.emitted: Dict[str, Any] = dict()
61
+ self.emitted[metric] = self.emitted.setdefault(metric, 0) + 1
60
62
 
61
63
  module: Optional[CustomStatsd]
62
64
  module = CustomStatsd()
sovereign/views/api.py CHANGED
@@ -37,8 +37,8 @@ async def resource(
37
37
  ) -> Response:
38
38
  expressions = [f"cluster={service_cluster}"]
39
39
  try:
40
- metadata = json.loads(metadata or "{}")
41
- for expr in expand_metadata_to_expr(metadata):
40
+ data = {"metadata": json.loads(metadata or "{}")}
41
+ for expr in expand_metadata_to_expr(data):
42
42
  expressions.append(expr)
43
43
  except Exception:
44
44
  pass
@@ -51,7 +51,6 @@ async def resource(
51
51
  expressions=expressions,
52
52
  )
53
53
  req = mock_discovery_request(**{k: v for k, v in kwargs.items() if v is not None})
54
- print(req)
55
54
  response = await cache.blocking_read(req)
56
55
  if content := getattr(response, "text", None):
57
56
  return Response(content, media_type="application/json")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sovereign
3
- Version: 1.0.0b102
3
+ Version: 1.0.0b104
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
@@ -17,18 +17,18 @@ 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=nuiLKq26Fd5MGDMPcTEzyb0QuVuWKaXZRtIdlQ3BzkA,37261
20
+ sovereign/schemas.py,sha256=PlAhNOeVKLlKIlMHKAHNfkPiE1cnpRnIIiNgJVr5ChQ,37413
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=sJ4bi6pCPX_cw1RGAUTQfM11bsWLeohEkpzSfFYwUeI,14706
26
+ sovereign/sources/poller.py,sha256=9iwmP4wjc951OTxKDB8RYhKwEKpgW5tC5-Js5OA6zVo,18415
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
30
30
  sovereign/static/style.css,sha256=vG8HPsbCbPIZfHgy7gSeof97Pnp0okkyaXyJzIEEW-8,447517
31
- sovereign/statistics.py,sha256=gXpQgQOTKqU68loQ_NU1OmxNvsRpAp38RpunjbecIRo,2587
31
+ sovereign/statistics.py,sha256=-me83Bkfya5vQai3Irrf5R-RNG-XE7wlyqQFqKeueFo,2666
32
32
  sovereign/templates/base.html,sha256=5vw3-NmN291pXRdArpCwhSce9bAYBWCJVRhvO5EmE9g,2296
33
33
  sovereign/templates/err.html,sha256=a3cEzOqyqWOIe3YxfTEjkxbTfxBxq1knD6GwzEFljfs,603
34
34
  sovereign/templates/resources.html,sha256=QaZ1S38JhAZg3-PfQS1cAKhCczVLXw9e4pztBrqr4qs,40217
@@ -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=T_le6Cfw03KsAc1nx93vZw97uUIFtF86vz9daXR_yUM,2177
57
+ sovereign/views/api.py,sha256=jKVjSvi0Tr1HHix3hc0H8yGZoyDind2sJ4w7a4pJvy0,2168
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
61
  sovereign/views/interface.py,sha256=FmQ7LiUPLSvkEDOKCncrnKMD9g1lJKu-DQNbbyi8mqk,6346
62
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,,
63
+ sovereign-1.0.0b104.dist-info/LICENSE.txt,sha256=2X125zvAb9AYLjCgdMDQZuufhm0kwcg31A8pGKj_-VY,560
64
+ sovereign-1.0.0b104.dist-info/METADATA,sha256=qjbGaqLJwlJsHaJuKbaye4TL1JRudbtuF0mVIN6sPqg,6268
65
+ sovereign-1.0.0b104.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
66
+ sovereign-1.0.0b104.dist-info/entry_points.txt,sha256=VKJdnnN_HNL8xYQMXsFXfFmN6QkdXMEk5S964avxQJI,1404
67
+ sovereign-1.0.0b104.dist-info/RECORD,,