sentry-options 1.2.0__tar.gz → 1.2.2__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 (34) hide show
  1. {sentry_options-1.2.0 → sentry_options-1.2.2}/Cargo.lock +5 -5
  2. {sentry_options-1.2.0 → sentry_options-1.2.2}/Cargo.toml +2 -2
  3. {sentry_options-1.2.0 → sentry_options-1.2.2}/PKG-INFO +1 -1
  4. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/python/src/lib.rs +45 -10
  5. sentry_options-1.2.2/clients/python/tests/conftest.py +70 -0
  6. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/python/tests/propagation_test.py +12 -61
  7. sentry_options-1.2.2/clients/python/tests/refresh_test.py +73 -0
  8. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/python/uv.lock +1 -1
  9. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/rust/src/lib.rs +147 -26
  10. {sentry_options-1.2.0 → sentry_options-1.2.2}/pyproject.toml +1 -1
  11. {sentry_options-1.2.0 → sentry_options-1.2.2}/python/sentry_options/__init__.py +2 -0
  12. {sentry_options-1.2.0 → sentry_options-1.2.2}/python/sentry_options/_core.pyi +20 -1
  13. {sentry_options-1.2.0 → sentry_options-1.2.2}/sentry-options-validation/src/lib.rs +325 -112
  14. sentry_options-1.2.0/clients/python/tests/conftest.py +0 -10
  15. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/python/Cargo.toml +0 -0
  16. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/python/benches/bench_get.py +0 -0
  17. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/python/benches/bench_has.py +0 -0
  18. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/python/benches/bench_isset.py +0 -0
  19. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/python/tests/features_test.py +0 -0
  20. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/python/tests/options_override_test.py +0 -0
  21. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/python/tests/options_test.py +0 -0
  22. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/rust/Cargo.toml +0 -0
  23. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/rust/benches/features_bench.rs +0 -0
  24. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/rust/benches/isset_bench.rs +0 -0
  25. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/rust/benches/options_bench.rs +0 -0
  26. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/rust/benches/reload_trace.rs +0 -0
  27. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/rust/src/features.rs +0 -0
  28. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/rust/src/testing.rs +0 -0
  29. {sentry_options-1.2.0 → sentry_options-1.2.2}/clients/rust/tests/override_validation.rs +0 -0
  30. {sentry_options-1.2.0 → sentry_options-1.2.2}/python/sentry_options/py.typed +0 -0
  31. {sentry_options-1.2.0 → sentry_options-1.2.2}/python/sentry_options/testing.py +0 -0
  32. {sentry_options-1.2.0 → sentry_options-1.2.2}/sentry-options-validation/Cargo.toml +0 -0
  33. {sentry_options-1.2.0 → sentry_options-1.2.2}/sentry-options-validation/src/feature-schema-defs.json +0 -0
  34. {sentry_options-1.2.0 → sentry_options-1.2.2}/sentry-options-validation/src/namespace-schema.json +0 -0
@@ -435,7 +435,7 @@ dependencies = [
435
435
 
436
436
  [[package]]
437
437
  name = "example"
438
- version = "1.2.0"
438
+ version = "1.2.2"
439
439
  dependencies = [
440
440
  "anyhow",
441
441
  "sentry-options",
@@ -1437,7 +1437,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1437
1437
 
1438
1438
  [[package]]
1439
1439
  name = "sentry-options"
1440
- version = "1.2.0"
1440
+ version = "1.2.2"
1441
1441
  dependencies = [
1442
1442
  "arc-swap",
1443
1443
  "criterion",
@@ -1451,7 +1451,7 @@ dependencies = [
1451
1451
 
1452
1452
  [[package]]
1453
1453
  name = "sentry-options-cli"
1454
- version = "1.2.0"
1454
+ version = "1.2.2"
1455
1455
  dependencies = [
1456
1456
  "chrono",
1457
1457
  "clap",
@@ -1466,7 +1466,7 @@ dependencies = [
1466
1466
 
1467
1467
  [[package]]
1468
1468
  name = "sentry-options-python"
1469
- version = "1.2.0"
1469
+ version = "1.2.2"
1470
1470
  dependencies = [
1471
1471
  "pyo3",
1472
1472
  "sentry-options",
@@ -1475,7 +1475,7 @@ dependencies = [
1475
1475
 
1476
1476
  [[package]]
1477
1477
  name = "sentry-options-validation"
1478
- version = "1.2.0"
1478
+ version = "1.2.2"
1479
1479
  dependencies = [
1480
1480
  "anyhow",
1481
1481
  "arc-swap",
@@ -3,13 +3,13 @@ resolver = "3"
3
3
  members = ["sentry-options-validation", "clients/rust", "clients/python"]
4
4
 
5
5
  [workspace.package]
6
- version = "1.2.0"
6
+ version = "1.2.2"
7
7
  edition = "2024"
8
8
  repository = "https://github.com/getsentry/sentry-options"
9
9
  license = "Apache-2.0"
10
10
 
11
11
  [workspace.dependencies]
12
- sentry-options-validation = { path = "sentry-options-validation", version = "1.2.0" }
12
+ sentry-options-validation = { path = "sentry-options-validation", version = "1.2.2" }
13
13
  serde = { version = "1.0", features = ["derive"] }
14
14
  serde_json = "1.0.145"
15
15
  anyhow = "1.0.100"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sentry_options
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3 :: Only
6
6
  Classifier: Programming Language :: Python :: Implementation :: CPython
@@ -2,10 +2,11 @@
2
2
 
3
3
  use std::collections::HashMap;
4
4
  use std::sync::OnceLock;
5
+ use std::time::Duration;
5
6
 
6
7
  use ::sentry_options::{
7
- FeatureChecker as RustFeatureChecker, FeatureContext as RustFeatureContext,
8
- Options as RustOptions, OptionsError as RustOptionsError,
8
+ DEFAULT_REFRESH_THRESHOLD, FeatureChecker as RustFeatureChecker,
9
+ FeatureContext as RustFeatureContext, Options as RustOptions, OptionsError as RustOptionsError,
9
10
  };
10
11
  use pyo3::exceptions::{PyException, PyRuntimeError, PyValueError};
11
12
  use pyo3::prelude::*;
@@ -193,29 +194,62 @@ impl PyFeatureChecker {
193
194
  /// Optionally accepts an `on_propagation` callback that fires whenever values
194
195
  /// are refreshed with a new `generated_at` timestamp. The callback receives
195
196
  /// `(namespace: str, delay_secs: float)`.
197
+ ///
198
+ /// `refresh_threshold` is the staleness threshold in seconds for
199
+ /// refresh-on-read (default: 5.0). Pass `None` to disable refresh-on-read
200
+ /// entirely; values then only change via `refresh()`.
196
201
  #[pyfunction]
197
- #[pyo3(signature = (on_propagation=None))]
198
- fn init(on_propagation: Option<Py<PyAny>>) -> PyResult<()> {
202
+ #[pyo3(signature = (on_propagation=None, refresh_threshold=Some(DEFAULT_REFRESH_THRESHOLD.as_secs_f64())))]
203
+ fn init(on_propagation: Option<Py<PyAny>>, refresh_threshold: Option<f64>) -> PyResult<()> {
199
204
  if GLOBAL_OPTIONS.get().is_some() {
200
205
  return Ok(());
201
206
  }
202
- let opts = match on_propagation {
207
+
208
+ let refresh_threshold = match refresh_threshold {
209
+ Some(secs) if !secs.is_finite() || secs < 0.0 => {
210
+ return Err(PyValueError::new_err(
211
+ "refresh_threshold must be a non-negative number or None",
212
+ ));
213
+ }
214
+ Some(secs) => Some(Duration::from_secs_f64(secs)),
215
+ None => None,
216
+ };
217
+
218
+ let mut builder = RustOptions::builder().with_refresh_threshold(refresh_threshold);
219
+ if let Some(cb) = on_propagation {
203
220
  // `Py<PyAny>` is `Send + Sync`; the closure re-acquires the GIL for the
204
221
  // call, so the callback can run on whichever thread triggers a refresh.
205
- Some(cb) => RustOptions::new_with_propagation_callback(Box::new(move |ns, delay| {
222
+ builder = builder.with_callback(move |ns, delay| {
206
223
  Python::attach(|py| {
207
224
  if let Err(e) = cb.call1(py, (ns, delay)) {
208
225
  e.print(py);
209
226
  }
210
227
  });
211
- }))
212
- .map_err(options_err)?,
213
- None => RustOptions::new().map_err(options_err)?,
214
- };
228
+ });
229
+ }
230
+ let opts = builder.build().map_err(options_err)?;
215
231
  let _ = GLOBAL_OPTIONS.set(opts);
216
232
  Ok(())
217
233
  }
218
234
 
235
+ /// Refresh values from disk, ignoring the staleness threshold.
236
+ ///
237
+ /// Returns whether a new snapshot was published, i.e. the files changed on
238
+ /// disk and reloaded successfully. On error the previous snapshot is retained
239
+ /// and served. Calling this more often than the threshold guarantees reads
240
+ /// never refresh inline.
241
+ ///
242
+ /// Raises NotInitializedError if init() has not been called.
243
+ #[pyfunction]
244
+ fn refresh(py: Python<'_>) -> PyResult<bool> {
245
+ let opts = GLOBAL_OPTIONS.get().ok_or_else(|| {
246
+ NotInitializedError::new_err("Options not initialized - call init() first")
247
+ })?;
248
+ // Release the GIL: the reload does file IO, and the propagation callback
249
+ // re-acquires the GIL itself.
250
+ py.detach(|| opts.refresh()).map_err(options_err)
251
+ }
252
+
219
253
  /// Get a namespace handle for accessing options.
220
254
  ///
221
255
  /// Raises RuntimeError if init() has not been called.
@@ -327,6 +361,7 @@ fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> {
327
361
  m.add_function(wrap_pyfunction!(init, m)?)?;
328
362
  m.add_function(wrap_pyfunction!(options, m)?)?;
329
363
  m.add_function(wrap_pyfunction!(features, m)?)?;
364
+ m.add_function(wrap_pyfunction!(refresh, m)?)?;
330
365
  // Classes
331
366
  m.add_class::<NamespaceOptions>()?;
332
367
  m.add_class::<PyFeatureContext>()?;
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+ from textwrap import dedent
9
+
10
+ import pytest
11
+ from sentry_options import init
12
+
13
+
14
+ @pytest.fixture(scope='session', autouse=True)
15
+ def init_options(tmp_path_factory: pytest.TempPathFactory):
16
+ """Initialize options once for the test session."""
17
+ init()
18
+
19
+
20
+ def make_options_dir(root: Path, *, generated_at: str | None = None) -> Path:
21
+ """Create an options directory with a ``test-ns`` schema and values file."""
22
+ options_dir = root / 'opts'
23
+ schemas_dir = options_dir / 'schemas' / 'test-ns'
24
+ values_dir = options_dir / 'values' / 'test-ns'
25
+ schemas_dir.mkdir(parents=True)
26
+ values_dir.mkdir(parents=True)
27
+
28
+ schemas_dir.joinpath('schema.json').write_text(
29
+ json.dumps(
30
+ {
31
+ 'version': '1.0',
32
+ 'type': 'object',
33
+ 'properties': {
34
+ 'enabled': {
35
+ 'type': 'boolean',
36
+ 'default': False,
37
+ 'description': 'Enabled',
38
+ },
39
+ },
40
+ },
41
+ ),
42
+ )
43
+
44
+ values: dict = {'options': {'enabled': True}}
45
+ if generated_at is not None:
46
+ values['generated_at'] = generated_at
47
+ values_dir.joinpath('values.json').write_text(json.dumps(values))
48
+
49
+ return options_dir
50
+
51
+
52
+ def run_isolated(script: str, options_dir: Path, timeout: int = 30, **env_extra: str) -> None:
53
+ """Run a Python script in a subprocess with its own ``SENTRY_OPTIONS_DIR``.
54
+
55
+ Each subprocess gets a fresh Rust ``OnceLock``, so scripts can call
56
+ ``init(...)`` with their own settings.
57
+ """
58
+ env = os.environ.copy()
59
+ env['SENTRY_OPTIONS_DIR'] = str(options_dir)
60
+ env.update(env_extra)
61
+ result = subprocess.run(
62
+ [sys.executable, '-c', dedent(script)],
63
+ env=env,
64
+ capture_output=True,
65
+ text=True,
66
+ timeout=timeout,
67
+ )
68
+ assert result.returncode == 0, (
69
+ f"Script failed.\nstdout: {result.stdout}\nstderr: {result.stderr}"
70
+ )
@@ -8,65 +8,16 @@ to wait it out.
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
- import json
12
- import os
13
- import subprocess
14
- import sys
15
11
  from pathlib import Path
16
- from textwrap import dedent
17
-
18
-
19
- def _make_options_dir(root: Path, *, generated_at: str | None = None) -> Path:
20
- options_dir = root / 'opts'
21
- schemas_dir = options_dir / 'schemas' / 'test-ns'
22
- values_dir = options_dir / 'values' / 'test-ns'
23
- schemas_dir.mkdir(parents=True)
24
- values_dir.mkdir(parents=True)
25
-
26
- schemas_dir.joinpath('schema.json').write_text(
27
- json.dumps(
28
- {
29
- 'version': '1.0',
30
- 'type': 'object',
31
- 'properties': {
32
- 'enabled': {
33
- 'type': 'boolean',
34
- 'default': False,
35
- 'description': 'Enabled',
36
- },
37
- },
38
- },
39
- ),
40
- )
41
-
42
- values: dict = {'options': {'enabled': True}}
43
- if generated_at is not None:
44
- values['generated_at'] = generated_at
45
- values_dir.joinpath('values.json').write_text(json.dumps(values))
46
-
47
- return options_dir
48
12
 
49
-
50
- def _run(script: str, options_dir: Path, timeout: int = 30, **env_extra: str) -> None:
51
- env = os.environ.copy()
52
- env['SENTRY_OPTIONS_DIR'] = str(options_dir)
53
- env.update(env_extra)
54
- result = subprocess.run(
55
- [sys.executable, '-c', dedent(script)],
56
- env=env,
57
- capture_output=True,
58
- text=True,
59
- timeout=timeout,
60
- )
61
- assert result.returncode == 0, (
62
- f"Script failed.\nstdout: {result.stdout}\nstderr: {result.stderr}"
63
- )
13
+ from conftest import make_options_dir
14
+ from conftest import run_isolated
64
15
 
65
16
 
66
17
  def test_propagation_callback_fires_on_generated_at_change(tmp_path: Path) -> None:
67
- options_dir = _make_options_dir(tmp_path, generated_at='2024-01-21T18:30:00+00:00')
18
+ options_dir = make_options_dir(tmp_path, generated_at='2024-01-21T18:30:00+00:00')
68
19
  values_file = options_dir / 'values' / 'test-ns' / 'values.json'
69
- _run(
20
+ run_isolated(
70
21
  f"""\
71
22
  import json
72
23
  from sentry_options import init, options
@@ -90,9 +41,9 @@ def test_propagation_callback_fires_on_generated_at_change(tmp_path: Path) -> No
90
41
 
91
42
 
92
43
  def test_get_forced_sees_change_without_waiting(tmp_path: Path) -> None:
93
- options_dir = _make_options_dir(tmp_path)
44
+ options_dir = make_options_dir(tmp_path)
94
45
  values_file = options_dir / 'values' / 'test-ns' / 'values.json'
95
- _run(
46
+ run_isolated(
96
47
  f"""\
97
48
  import json
98
49
  from sentry_options import init, options
@@ -114,9 +65,9 @@ def test_get_forced_sees_change_without_waiting(tmp_path: Path) -> None:
114
65
 
115
66
 
116
67
  def test_propagation_callback_not_called_without_generated_at(tmp_path: Path) -> None:
117
- options_dir = _make_options_dir(tmp_path)
68
+ options_dir = make_options_dir(tmp_path)
118
69
  values_file = options_dir / 'values' / 'test-ns' / 'values.json'
119
- _run(
70
+ run_isolated(
120
71
  f"""\
121
72
  import json
122
73
  from sentry_options import init, options
@@ -137,9 +88,9 @@ def test_propagation_callback_not_called_without_generated_at(tmp_path: Path) ->
137
88
 
138
89
 
139
90
  def test_propagation_callback_exception_does_not_crash(tmp_path: Path) -> None:
140
- options_dir = _make_options_dir(tmp_path, generated_at='2024-01-21T18:30:00+00:00')
91
+ options_dir = make_options_dir(tmp_path, generated_at='2024-01-21T18:30:00+00:00')
141
92
  values_file = options_dir / 'values' / 'test-ns' / 'values.json'
142
- _run(
93
+ run_isolated(
143
94
  f"""\
144
95
  import json
145
96
  from sentry_options import init, options
@@ -161,9 +112,9 @@ def test_propagation_callback_exception_does_not_crash(tmp_path: Path) -> None:
161
112
 
162
113
 
163
114
  def test_propagation_callback_receives_multiple_updates(tmp_path: Path) -> None:
164
- options_dir = _make_options_dir(tmp_path, generated_at='2024-01-21T18:00:00+00:00')
115
+ options_dir = make_options_dir(tmp_path, generated_at='2024-01-21T18:00:00+00:00')
165
116
  values_file = options_dir / 'values' / 'test-ns' / 'values.json'
166
- _run(
117
+ run_isolated(
167
118
  f"""\
168
119
  import json
169
120
  from sentry_options import init, options
@@ -0,0 +1,73 @@
1
+ """E2E tests for manual refresh and the configurable staleness threshold.
2
+
3
+ Each test spins up a subprocess with its own ``SENTRY_OPTIONS_DIR`` so the
4
+ Rust ``OnceLock`` starts fresh and ``init(refresh_threshold=...)`` takes
5
+ effect.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from conftest import make_options_dir
12
+ from conftest import run_isolated
13
+
14
+
15
+ def test_manual_refresh_with_disabled_threshold(tmp_path: Path) -> None:
16
+ options_dir = make_options_dir(tmp_path)
17
+ values_file = options_dir / 'values' / 'test-ns' / 'values.json'
18
+ run_isolated(
19
+ f"""\
20
+ import json
21
+ from sentry_options import init, options, refresh
22
+
23
+ init(refresh_threshold=None)
24
+ opts = options("test-ns")
25
+ assert opts.get("enabled") is True
26
+
27
+ with open("{values_file}", "w") as f:
28
+ json.dump({{"options": {{"enabled": False}}}}, f)
29
+
30
+ # With refresh-on-read disabled, reads never pick up the change.
31
+ assert opts.get("enabled") is True
32
+ # A manual refresh does.
33
+ assert refresh() is True
34
+ assert opts.get("enabled") is False
35
+ # Nothing changed since the last refresh.
36
+ assert refresh() is False
37
+ """,
38
+ options_dir,
39
+ )
40
+
41
+
42
+ def test_refresh_before_init_raises(tmp_path: Path) -> None:
43
+ options_dir = make_options_dir(tmp_path)
44
+ run_isolated(
45
+ """\
46
+ from sentry_options import NotInitializedError, refresh
47
+
48
+ try:
49
+ refresh()
50
+ except NotInitializedError:
51
+ pass
52
+ else:
53
+ raise AssertionError("Expected NotInitializedError")
54
+ """,
55
+ options_dir,
56
+ )
57
+
58
+
59
+ def test_init_rejects_negative_refresh_threshold(tmp_path: Path) -> None:
60
+ options_dir = make_options_dir(tmp_path)
61
+ run_isolated(
62
+ """\
63
+ from sentry_options import init
64
+
65
+ try:
66
+ init(refresh_threshold=-1.0)
67
+ except ValueError:
68
+ pass
69
+ else:
70
+ raise AssertionError("Expected ValueError")
71
+ """,
72
+ options_dir,
73
+ )
@@ -126,7 +126,7 @@ wheels = [
126
126
 
127
127
  [[package]]
128
128
  name = "sentry-options"
129
- version = "1.2.0"
129
+ version = "1.2.2"
130
130
  source = { editable = "." }
131
131
 
132
132
  [package.optional-dependencies]