python-config-client 0.1.2__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.
@@ -0,0 +1,49 @@
1
+ """python-config-client — Python SDK for Config Service.
2
+
3
+ This package provides a synchronous client for fetching, decrypting,
4
+ and hot-reloading configurations from Config Service.
5
+
6
+ Example usage::
7
+
8
+ from config_client import ConfigClient, Options
9
+
10
+ client = ConfigClient(Options(
11
+ host="https://config.example.com",
12
+ service_token="your-token",
13
+ encryption_key="your-hex-key",
14
+ ))
15
+ cfg = client.get("my-service", dict)
16
+ """
17
+
18
+ from config_client.client import ConfigClient
19
+ from config_client.errors import (
20
+ ConfigClientError,
21
+ ConnectionError,
22
+ DecryptionError,
23
+ ForbiddenError,
24
+ InvalidResponseError,
25
+ NotFoundError,
26
+ UnauthorizedError,
27
+ UnmarshalError,
28
+ )
29
+ from config_client.options import GetOptions, Options
30
+ from config_client.snapshot import Snapshot
31
+ from config_client.types import ConfigChangeEvent, ConfigInfo, Format
32
+
33
+ __all__ = [
34
+ "ConfigClient",
35
+ "Options",
36
+ "GetOptions",
37
+ "ConfigInfo",
38
+ "ConfigChangeEvent",
39
+ "Format",
40
+ "Snapshot",
41
+ "ConfigClientError",
42
+ "UnauthorizedError",
43
+ "ForbiddenError",
44
+ "NotFoundError",
45
+ "DecryptionError",
46
+ "InvalidResponseError",
47
+ "ConnectionError",
48
+ "UnmarshalError",
49
+ ]
config_client/_sse.py ADDED
@@ -0,0 +1,101 @@
1
+ """Internal SSE reader — not part of the public API.
2
+
3
+ This module is intentionally private (prefixed with ``_``).
4
+ Do **not** import or re-export its contents from ``config_client/__init__.py``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import Generator
11
+ from dataclasses import dataclass, field
12
+
13
+ import httpx
14
+ import httpx_sse
15
+
16
+ logger = logging.getLogger("config_client")
17
+
18
+
19
+ @dataclass
20
+ class SSEEvent:
21
+ """A parsed Server-Sent Event received from the ``/watch`` endpoint.
22
+
23
+ Attributes:
24
+ event: The ``event:`` field value (e.g. ``"config_changed"``).
25
+ Defaults to ``"message"`` when absent in the stream.
26
+ data: The ``data:`` field value (raw string payload).
27
+ id: The ``id:`` field value. Empty string when absent.
28
+ retry: The ``retry:`` reconnection hint in milliseconds, or ``None``.
29
+
30
+ """
31
+
32
+ event: str = "message"
33
+ data: str = ""
34
+ id: str = ""
35
+ retry: int | None = field(default=None)
36
+
37
+
38
+ def iter_sse_events(
39
+ client: httpx.Client,
40
+ url: str,
41
+ headers: dict[str, str],
42
+ ) -> Generator[SSEEvent, None, None]:
43
+ """Open an SSE stream and yield parsed :class:`SSEEvent` objects.
44
+
45
+ The generator is designed to be used by the watch layer, which is
46
+ responsible for reconnection and error handling.
47
+
48
+ The underlying ``httpx`` connection is kept open for the lifetime of
49
+ the generator. Callers should either exhaust the generator or call
50
+ ``close()`` on it (e.g. via a ``try/finally`` block or ``with``
51
+ statement) to release the connection.
52
+
53
+ Args:
54
+ client: An already-configured ``httpx.Client`` instance. The caller
55
+ owns its lifecycle; this function never closes it.
56
+ url: Fully-qualified URL of the SSE endpoint.
57
+ headers: Additional HTTP headers to include in the request.
58
+
59
+ Yields:
60
+ :class:`SSEEvent` for every valid event received from the stream.
61
+
62
+ Raises:
63
+ httpx.TransportError: On network-level failures (connection reset,
64
+ timeouts, etc.). The caller (watch layer) is responsible for
65
+ catching this and deciding whether to reconnect.
66
+ httpx.HTTPStatusError: When the server returns a non-2xx status on
67
+ the initial connection.
68
+
69
+ """
70
+ logger.debug("Opening SSE stream url=%s", url)
71
+
72
+ with httpx_sse.connect_sse(client, "GET", url, headers=headers) as event_source:
73
+ event_source.response.raise_for_status()
74
+
75
+ for raw in event_source.iter_sse():
76
+ try:
77
+ retry_ms: int | None = raw.retry if raw.retry is not None else None
78
+ event = SSEEvent(
79
+ event=raw.event or "message",
80
+ data=raw.data,
81
+ id=raw.id or "",
82
+ retry=retry_ms,
83
+ )
84
+ except Exception as exc: # noqa: BLE001 — unexpected malformed event
85
+ logger.warning("Ignoring malformed SSE event: %s", exc)
86
+ continue
87
+
88
+ if not event.data:
89
+ # Keep-alive / comment lines — skip silently
90
+ logger.debug("SSE keep-alive received url=%s", url)
91
+ continue
92
+
93
+ logger.debug(
94
+ "SSE event received event=%s id=%s url=%s",
95
+ event.event,
96
+ event.id,
97
+ url,
98
+ )
99
+ yield event
100
+
101
+ logger.warning("SSE stream closed url=%s", url)
@@ -0,0 +1,520 @@
1
+ """ConfigClient — main entry point for the Config Service SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ import logging
8
+ import os
9
+ import threading
10
+ import typing
11
+ from collections.abc import Callable
12
+ from datetime import datetime, timezone
13
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
14
+
15
+ from config_client.crypto import decrypt, parse_encryption_key
16
+ from config_client.errors import ConfigClientError, UnmarshalError
17
+ from config_client.options import GetOptions, Options
18
+ from config_client.snapshot import Snapshot
19
+ from config_client.transport import Transport, backoff_delay
20
+ from config_client.types import ConfigChangeEvent, ConfigInfo, Format
21
+
22
+ if TYPE_CHECKING:
23
+ pass # nothing extra needed at runtime from type-only imports
24
+ logger = logging.getLogger("config_client")
25
+
26
+ T = TypeVar("T")
27
+
28
+ _BASE_PATH = "/api/v1/service/configs"
29
+
30
+
31
+ def _build_query(opts: GetOptions | None) -> dict[str, str | int]:
32
+ """Return non-default query parameters from *opts*."""
33
+ params: dict[str, str | int] = {}
34
+ if opts is None:
35
+ return params
36
+ if opts.environment:
37
+ params["environment"] = opts.environment
38
+ if opts.version != 0:
39
+ params["version"] = opts.version
40
+ return params
41
+
42
+
43
+ def _deserialize(data: dict[str, Any], target_type: type[T]) -> T:
44
+ """Deserialize *data* dict into an instance of *target_type*.
45
+
46
+ Dispatch order:
47
+ 1. ``dict`` — return as-is.
48
+ 2. Pydantic ``BaseModel`` subclass — ``model_validate(data)``.
49
+ 3. ``dataclass`` — recursive construction via :func:`_build_dataclass`.
50
+ 4. Anything else — raise :exc:`~config_client.errors.UnmarshalError`.
51
+
52
+ Args:
53
+ data: Decoded JSON dict from the config payload.
54
+ target_type: The type to deserialize into.
55
+
56
+ Returns:
57
+ An instance of *target_type*.
58
+
59
+ Raises:
60
+ UnmarshalError: If deserialization fails for any reason.
61
+
62
+ """
63
+ # 1. dict passthrough
64
+ if target_type is dict:
65
+ return cast(T, data)
66
+
67
+ # 2. Pydantic v2
68
+ try:
69
+ import pydantic # noqa: PLC0415
70
+
71
+ if isinstance(target_type, type) and issubclass(target_type, pydantic.BaseModel):
72
+ try:
73
+ return target_type.model_validate(data)
74
+ except pydantic.ValidationError as exc:
75
+ raise UnmarshalError(
76
+ f"Pydantic validation failed for {target_type.__name__}: {exc}"
77
+ ) from exc
78
+ except ImportError:
79
+ pass
80
+
81
+ # 3. dataclass
82
+ if dataclasses.is_dataclass(target_type) and isinstance(target_type, type):
83
+ try:
84
+ return cast(T, _build_dataclass(target_type, data))
85
+ except (TypeError, KeyError, ValueError) as exc:
86
+ raise UnmarshalError(
87
+ f"Dataclass construction failed for {target_type.__name__}: {exc}"
88
+ ) from exc
89
+
90
+ raise UnmarshalError(
91
+ f"Unsupported target_type {target_type!r}. "
92
+ "Use dict, a dataclass, or a pydantic.BaseModel subclass."
93
+ )
94
+
95
+
96
+ def _build_dataclass(cls: type, data: dict[str, Any]) -> Any: # noqa: ANN401
97
+ """Recursively construct a dataclass from a nested dict.
98
+
99
+ Uses :func:`typing.get_type_hints` to resolve string annotations
100
+ (produced by ``from __future__ import annotations``) without ``eval``.
101
+
102
+ Args:
103
+ cls: A dataclass type.
104
+ data: Dict of field values (may contain nested dicts).
105
+
106
+ Returns:
107
+ An instance of *cls*.
108
+
109
+ """
110
+ try:
111
+ hints: dict[str, Any] = typing.get_type_hints(cls)
112
+ except Exception: # noqa: BLE001 — graceful fallback for unresolvable hints
113
+ hints = {}
114
+
115
+ kwargs: dict[str, Any] = {}
116
+ for f in dataclasses.fields(cls):
117
+ value = data[f.name]
118
+ actual_type = hints.get(f.name)
119
+
120
+ if (
121
+ actual_type is not None
122
+ and isinstance(actual_type, type)
123
+ and dataclasses.is_dataclass(actual_type)
124
+ and isinstance(value, dict)
125
+ ):
126
+ kwargs[f.name] = _build_dataclass(actual_type, value)
127
+ else:
128
+ kwargs[f.name] = value
129
+ return cls(**kwargs)
130
+
131
+
132
+ def _parse_config_info(raw: dict[str, Any]) -> ConfigInfo:
133
+ """Parse a single ConfigInfo dict from the list endpoint response.
134
+
135
+ Args:
136
+ raw: A dict with ``name``, ``is_valid``, ``valid_from``, ``updated_at`` keys.
137
+
138
+ Returns:
139
+ A :class:`~config_client.types.ConfigInfo` instance.
140
+
141
+ """
142
+ return ConfigInfo(
143
+ name=str(raw["name"]),
144
+ is_valid=bool(raw["is_valid"]),
145
+ valid_from=datetime.fromisoformat(str(raw["valid_from"])).replace(tzinfo=timezone.utc)
146
+ if datetime.fromisoformat(str(raw["valid_from"])).tzinfo is None
147
+ else datetime.fromisoformat(str(raw["valid_from"])),
148
+ updated_at=datetime.fromisoformat(str(raw["updated_at"])).replace(tzinfo=timezone.utc)
149
+ if datetime.fromisoformat(str(raw["updated_at"])).tzinfo is None
150
+ else datetime.fromisoformat(str(raw["updated_at"])),
151
+ )
152
+
153
+
154
+ def _parse_change_event(data: str) -> ConfigChangeEvent | None:
155
+ """Parse a JSON SSE data payload into a :class:`ConfigChangeEvent`.
156
+
157
+ Returns ``None`` when the payload is invalid or missing required fields,
158
+ logging a warning so the caller can skip the event gracefully.
159
+
160
+ Args:
161
+ data: Raw ``data:`` field from an SSE event (JSON string).
162
+
163
+ Returns:
164
+ A :class:`ConfigChangeEvent` or ``None`` if parsing fails.
165
+
166
+ """
167
+ try:
168
+ raw = json.loads(data)
169
+ return ConfigChangeEvent(
170
+ config_name=str(raw["config_name"]),
171
+ version=int(raw["version"]),
172
+ changed_by=int(raw["changed_by"]),
173
+ timestamp=datetime.fromisoformat(str(raw["timestamp"])).replace(tzinfo=timezone.utc)
174
+ if datetime.fromisoformat(str(raw["timestamp"])).tzinfo is None
175
+ else datetime.fromisoformat(str(raw["timestamp"])),
176
+ )
177
+ except (KeyError, ValueError, TypeError) as exc:
178
+ logger.warning("Could not parse SSE change event data=%r: %s", data, exc)
179
+ return None
180
+
181
+
182
+ class ConfigClient:
183
+ """Synchronous client for Config Service.
184
+
185
+ Fetches, decrypts, and deserializes service configurations. Supports
186
+ plain-dict, dataclass, and Pydantic v2 deserialization targets.
187
+
188
+ Example::
189
+
190
+ with ConfigClient(Options(
191
+ host="https://config.example.com",
192
+ service_token="tok",
193
+ encryption_key="aa" * 32,
194
+ )) as client:
195
+ cfg = client.get("my-service", AppConfig)
196
+
197
+ """
198
+
199
+ def __init__(self, opts: Options) -> None:
200
+ """Initialise the client with the given options.
201
+
202
+ Args:
203
+ opts: :class:`~config_client.options.Options` instance. Validation
204
+ happens inside ``Options.__post_init__``; any invalid value
205
+ raises :exc:`~config_client.errors.ConfigClientError` before
206
+ this constructor returns.
207
+
208
+ """
209
+ # Options validates itself; we just store what we need.
210
+ self._opts = opts
211
+ self._key: bytes = parse_encryption_key(opts.encryption_key)
212
+
213
+ self._transport = Transport(
214
+ host=opts.host,
215
+ service_token=opts.service_token,
216
+ request_timeout=opts.request_timeout,
217
+ retry_count=opts.retry_count,
218
+ retry_delay=opts.retry_delay,
219
+ http_client=opts.http_client,
220
+ )
221
+
222
+ # Threading event used to signal watch loops to stop.
223
+ self._stop_event: threading.Event = threading.Event()
224
+
225
+ logger.debug("ConfigClient initialised host=%s", opts.host)
226
+
227
+ # ------------------------------------------------------------------
228
+ # Constructors
229
+ # ------------------------------------------------------------------
230
+
231
+ @classmethod
232
+ def from_env(cls) -> ConfigClient:
233
+ """Create a :class:`ConfigClient` from environment variables.
234
+
235
+ Reads ``CONFIG_SERVICE_HOST``, ``CONFIG_SERVICE_TOKEN``, and
236
+ ``CONFIG_SERVICE_KEY`` from the process environment.
237
+
238
+ Returns:
239
+ A configured :class:`ConfigClient`.
240
+
241
+ Raises:
242
+ ConfigClientError: If any required environment variable is missing.
243
+
244
+ """
245
+ required = {
246
+ "host": "CONFIG_SERVICE_HOST",
247
+ "service_token": "CONFIG_SERVICE_TOKEN",
248
+ "encryption_key": "CONFIG_SERVICE_KEY",
249
+ }
250
+ values: dict[str, str] = {}
251
+ for field, var in required.items():
252
+ val = os.environ.get(var)
253
+ if not val:
254
+ raise ConfigClientError(f"Required environment variable {var!r} is not set")
255
+ values[field] = val
256
+
257
+ return cls(Options(**values)) # type: ignore[arg-type]
258
+
259
+ # ------------------------------------------------------------------
260
+ # Lifecycle
261
+ # ------------------------------------------------------------------
262
+
263
+ def close(self) -> None:
264
+ """Stop all active watch loops and close the HTTP transport."""
265
+ self._stop_event.set()
266
+ self._transport.close()
267
+ logger.debug("ConfigClient closed")
268
+
269
+ def __enter__(self) -> ConfigClient:
270
+ """Support use as a context manager."""
271
+ return self
272
+
273
+ def __exit__(self, *args: object) -> None:
274
+ """Close on context manager exit."""
275
+ self.close()
276
+
277
+ # ------------------------------------------------------------------
278
+ # Config retrieval
279
+ # ------------------------------------------------------------------
280
+
281
+ def get(
282
+ self,
283
+ config_name: str,
284
+ target_type: type[T],
285
+ opts: GetOptions | None = None,
286
+ ) -> T:
287
+ """Fetch, decrypt, and deserialize a configuration.
288
+
289
+ Args:
290
+ config_name: The configuration name (slug).
291
+ target_type: Deserialization target — ``dict``, a ``dataclass``
292
+ type, or a ``pydantic.BaseModel`` subclass.
293
+ opts: Optional per-request options (environment, version).
294
+
295
+ Returns:
296
+ An instance of *target_type*.
297
+
298
+ Raises:
299
+ UnauthorizedError: HTTP 401.
300
+ ForbiddenError: HTTP 403.
301
+ NotFoundError: HTTP 404.
302
+ ConnectionError: Network / 5xx after retries exhausted.
303
+ DecryptionError: AES-GCM decryption failed.
304
+ UnmarshalError: JSON→type deserialization failed.
305
+
306
+ """
307
+ path = f"{_BASE_PATH}/{config_name}"
308
+ params = _build_query(opts)
309
+ response = self._transport.get(path, **params)
310
+
311
+ plaintext = decrypt(response.content, self._key)
312
+ return _deserialize(json.loads(plaintext), target_type)
313
+
314
+ def get_raw(
315
+ self,
316
+ config_name: str,
317
+ opts: GetOptions | None = None,
318
+ ) -> dict[str, object]:
319
+ """Fetch and decrypt a configuration, returning a raw ``dict``.
320
+
321
+ Args:
322
+ config_name: The configuration name (slug).
323
+ opts: Optional per-request options.
324
+
325
+ Returns:
326
+ Decrypted JSON payload as a ``dict``.
327
+
328
+ """
329
+ return cast("dict[str, object]", self.get(config_name, dict, opts))
330
+
331
+ def get_bytes(
332
+ self,
333
+ config_name: str,
334
+ opts: GetOptions | None = None,
335
+ ) -> bytes:
336
+ """Fetch and decrypt a configuration, returning raw JSON bytes.
337
+
338
+ Args:
339
+ config_name: The configuration name (slug).
340
+ opts: Optional per-request options.
341
+
342
+ Returns:
343
+ Decrypted JSON as raw ``bytes`` (not parsed).
344
+
345
+ """
346
+ path = f"{_BASE_PATH}/{config_name}"
347
+ params = _build_query(opts)
348
+ response = self._transport.get(path, **params)
349
+ return decrypt(response.content, self._key)
350
+
351
+ def get_formatted(self, config_name: str, fmt: Format) -> bytes:
352
+ """Fetch a configuration in plaintext format (no decryption).
353
+
354
+ Uses the ``/formatted`` endpoint which returns plaintext JSON, YAML,
355
+ or env-file content directly.
356
+
357
+ Args:
358
+ config_name: The configuration name (slug).
359
+ fmt: Output format — :attr:`Format.JSON`, :attr:`Format.YAML`,
360
+ or :attr:`Format.ENV`.
361
+
362
+ Returns:
363
+ Raw bytes of the formatted configuration.
364
+
365
+ """
366
+ path = f"{_BASE_PATH}/{config_name}/formatted"
367
+ response = self._transport.get(path, format=fmt.value)
368
+ return response.content
369
+
370
+ def list(self) -> list[ConfigInfo]:
371
+ """Return metadata for all configurations accessible to this token.
372
+
373
+ Returns:
374
+ A list of :class:`~config_client.types.ConfigInfo` objects.
375
+
376
+ Raises:
377
+ UnauthorizedError: HTTP 401.
378
+ ForbiddenError: HTTP 403.
379
+ ConnectionError: Network / 5xx after retries exhausted.
380
+ InvalidResponseError: Unexpected response format.
381
+
382
+ """
383
+ response = self._transport.get(_BASE_PATH)
384
+ payload: list[dict[str, Any]] = response.json()
385
+ return [_parse_config_info(item) for item in payload]
386
+
387
+ # ------------------------------------------------------------------
388
+ # Watch & Hot-Reload
389
+ # ------------------------------------------------------------------
390
+
391
+ def watch(
392
+ self,
393
+ config_name: str,
394
+ callback: Callable[[ConfigChangeEvent], None],
395
+ ) -> None:
396
+ """Subscribe to SSE change events for a configuration.
397
+
398
+ Blocks the calling thread until :meth:`close` is called or
399
+ :exc:`KeyboardInterrupt` is raised. Automatically reconnects
400
+ after connection drops using exponential backoff.
401
+
402
+ Args:
403
+ config_name: The configuration name to watch.
404
+ callback: Invoked with a :class:`~config_client.types.ConfigChangeEvent`
405
+ on every change event received from the SSE stream.
406
+
407
+ """
408
+ from config_client._sse import iter_sse_events # noqa: PLC0415
409
+
410
+ url = f"{self._opts.host.rstrip('/')}{_BASE_PATH}/{config_name}/watch"
411
+ headers = {"X-Service-Token": self._opts.service_token}
412
+ attempt = 0
413
+ max_reconnects = max(self._opts.retry_count, 1)
414
+
415
+ while not self._stop_event.is_set():
416
+ try:
417
+ for event in iter_sse_events(
418
+ self._transport._client, # noqa: SLF001
419
+ url,
420
+ headers,
421
+ ):
422
+ if self._stop_event.is_set():
423
+ return
424
+
425
+ change_event = _parse_change_event(event.data)
426
+ if change_event is None:
427
+ continue
428
+
429
+ try:
430
+ callback(change_event)
431
+ except Exception as cb_exc: # noqa: BLE001
432
+ logger.error(
433
+ "watch callback raised an exception config=%s: %s",
434
+ config_name,
435
+ cb_exc,
436
+ )
437
+
438
+ if self._opts.on_change is not None:
439
+ self._opts.on_change(config_name)
440
+
441
+ # Stream ended cleanly — reconnect
442
+ attempt = 0
443
+ logger.warning("SSE stream ended, reconnecting config=%s", config_name)
444
+
445
+ except KeyboardInterrupt:
446
+ logger.info("watch interrupted by user config=%s", config_name)
447
+ return
448
+
449
+ except Exception as exc: # noqa: BLE001
450
+ if self._stop_event.is_set():
451
+ return
452
+
453
+ logger.warning("SSE error config=%s attempt=%d: %s", config_name, attempt, exc)
454
+
455
+ if self._opts.on_error is not None:
456
+ self._opts.on_error(exc)
457
+
458
+ attempt += 1
459
+ if attempt > max_reconnects:
460
+ logger.error(
461
+ "watch giving up after %d attempts config=%s",
462
+ attempt,
463
+ config_name,
464
+ )
465
+ return
466
+
467
+ delay = backoff_delay(attempt, self._opts.retry_delay)
468
+ logger.warning(
469
+ "Reconnecting in %.2fs config=%s attempt=%d/%d",
470
+ delay,
471
+ config_name,
472
+ attempt,
473
+ max_reconnects,
474
+ )
475
+ self._stop_event.wait(timeout=delay)
476
+
477
+ def watch_and_decode(
478
+ self,
479
+ config_name: str,
480
+ target_type: type[T],
481
+ snapshot: Snapshot[T],
482
+ opts: GetOptions | None = None,
483
+ ) -> None:
484
+ """Watch for config changes and update *snapshot* on every change.
485
+
486
+ Internally calls :meth:`watch` with a callback that fetches the
487
+ latest configuration via :meth:`get` and stores it in *snapshot*.
488
+ Errors from :meth:`get` are forwarded to ``Options.on_error`` and
489
+ do **not** stop the watch loop.
490
+
491
+ Blocks the calling thread. Recommended usage is inside a daemon
492
+ ``threading.Thread``.
493
+
494
+ Args:
495
+ config_name: The configuration name to watch.
496
+ target_type: Deserialization target type for :meth:`get`.
497
+ snapshot: Thread-safe container to update on each change.
498
+ opts: Optional per-request options passed to :meth:`get`.
499
+
500
+ """
501
+
502
+ def _on_change(event: ConfigChangeEvent) -> None: # noqa: ARG001
503
+ try:
504
+ new_value = self.get(config_name, target_type, opts)
505
+ snapshot.store(new_value)
506
+ logger.debug(
507
+ "Snapshot updated config=%s version=%s",
508
+ config_name,
509
+ event.version,
510
+ )
511
+ except Exception as exc: # noqa: BLE001
512
+ logger.error(
513
+ "watch_and_decode get() failed config=%s: %s",
514
+ config_name,
515
+ exc,
516
+ )
517
+ if self._opts.on_error is not None:
518
+ self._opts.on_error(exc)
519
+
520
+ self.watch(config_name, _on_change)