jigkit 0.1.0__tar.gz → 0.2.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.
Files changed (47) hide show
  1. {jigkit-0.1.0 → jigkit-0.2.0}/PKG-INFO +11 -2
  2. {jigkit-0.1.0 → jigkit-0.2.0}/README.md +5 -1
  3. {jigkit-0.1.0 → jigkit-0.2.0}/pyproject.toml +10 -2
  4. jigkit-0.2.0/src/jigkit/delivery.py +156 -0
  5. jigkit-0.2.0/src/jigkit/heartbeat.py +96 -0
  6. jigkit-0.2.0/src/jigkit/influx/__init__.py +31 -0
  7. jigkit-0.2.0/src/jigkit/influx/lineprotocol.py +76 -0
  8. jigkit-0.2.0/src/jigkit/influx/sink.py +320 -0
  9. jigkit-0.2.0/src/jigkit/sinks.py +137 -0
  10. jigkit-0.2.0/src/jigkit/spool.py +224 -0
  11. jigkit-0.2.0/src/jigkit/testing/http.py +77 -0
  12. jigkit-0.2.0/tests/conftest.py +29 -0
  13. jigkit-0.2.0/tests/test_delivery.py +385 -0
  14. jigkit-0.2.0/tests/test_heartbeat.py +170 -0
  15. jigkit-0.2.0/tests/test_influx_lineprotocol.py +125 -0
  16. jigkit-0.2.0/tests/test_influx_read.py +221 -0
  17. jigkit-0.2.0/tests/test_influx_sink.py +276 -0
  18. jigkit-0.2.0/tests/test_sinks.py +93 -0
  19. jigkit-0.2.0/tests/test_spool.py +238 -0
  20. jigkit-0.1.0/tests/conftest.py +0 -13
  21. {jigkit-0.1.0 → jigkit-0.2.0}/.gitignore +0 -0
  22. {jigkit-0.1.0 → jigkit-0.2.0}/LICENSE +0 -0
  23. {jigkit-0.1.0 → jigkit-0.2.0}/src/jigkit/__init__.py +0 -0
  24. {jigkit-0.1.0 → jigkit-0.2.0}/src/jigkit/db.py +0 -0
  25. {jigkit-0.1.0 → jigkit-0.2.0}/src/jigkit/healthcheck.py +0 -0
  26. {jigkit-0.1.0 → jigkit-0.2.0}/src/jigkit/logging.py +0 -0
  27. {jigkit-0.1.0 → jigkit-0.2.0}/src/jigkit/migrations.py +0 -0
  28. {jigkit-0.1.0 → jigkit-0.2.0}/src/jigkit/models.py +0 -0
  29. {jigkit-0.1.0 → jigkit-0.2.0}/src/jigkit/py.typed +0 -0
  30. {jigkit-0.1.0 → jigkit-0.2.0}/src/jigkit/secrets.py +0 -0
  31. {jigkit-0.1.0 → jigkit-0.2.0}/src/jigkit/settings.py +0 -0
  32. {jigkit-0.1.0 → jigkit-0.2.0}/src/jigkit/testing/__init__.py +0 -0
  33. {jigkit-0.1.0 → jigkit-0.2.0}/src/jigkit/testing/migrations.py +0 -0
  34. {jigkit-0.1.0 → jigkit-0.2.0}/tests/sample_app/alembic/env.py +0 -0
  35. {jigkit-0.1.0 → jigkit-0.2.0}/tests/sample_app/alembic/script.py.mako +0 -0
  36. {jigkit-0.1.0 → jigkit-0.2.0}/tests/sample_app/alembic/versions/0001_initial.py +0 -0
  37. {jigkit-0.1.0 → jigkit-0.2.0}/tests/sample_app/alembic.ini +0 -0
  38. {jigkit-0.1.0 → jigkit-0.2.0}/tests/sample_app/sample_schema.py +0 -0
  39. {jigkit-0.1.0 → jigkit-0.2.0}/tests/test_db.py +0 -0
  40. {jigkit-0.1.0 → jigkit-0.2.0}/tests/test_healthcheck.py +0 -0
  41. {jigkit-0.1.0 → jigkit-0.2.0}/tests/test_logging.py +0 -0
  42. {jigkit-0.1.0 → jigkit-0.2.0}/tests/test_migrations.py +0 -0
  43. {jigkit-0.1.0 → jigkit-0.2.0}/tests/test_models.py +0 -0
  44. {jigkit-0.1.0 → jigkit-0.2.0}/tests/test_secrets.py +0 -0
  45. {jigkit-0.1.0 → jigkit-0.2.0}/tests/test_settings.py +0 -0
  46. {jigkit-0.1.0 → jigkit-0.2.0}/tests/test_testing_plugin.py +0 -0
  47. {jigkit-0.1.0 → jigkit-0.2.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: jigkit
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: The shared foundations of small self-hosted web apps: settings, database, migrations, logging, health checks and test fixtures
5
5
  Project-URL: Homepage, https://github.com/jmerhar/jigkit
6
6
  Project-URL: Changelog, https://github.com/jmerhar/jigkit/blob/main/CHANGELOG.md
@@ -26,12 +26,17 @@ Provides-Extra: dev
26
26
  Requires-Dist: aiosqlite>=0.22; extra == 'dev'
27
27
  Requires-Dist: alembic>=1.19; extra == 'dev'
28
28
  Requires-Dist: build>=1.3; extra == 'dev'
29
+ Requires-Dist: httpx2>=2.13; extra == 'dev'
29
30
  Requires-Dist: pytest-asyncio>=1.4; extra == 'dev'
30
31
  Requires-Dist: pytest-cov>=7.1; extra == 'dev'
31
32
  Requires-Dist: pytest>=9.1; extra == 'dev'
32
33
  Requires-Dist: ruff>=0.16; extra == 'dev'
33
34
  Requires-Dist: sqlalchemy[asyncio]>=2.0.52; extra == 'dev'
34
35
  Requires-Dist: twine>=6.2; extra == 'dev'
36
+ Provides-Extra: heartbeat
37
+ Requires-Dist: httpx2>=2.13; extra == 'heartbeat'
38
+ Provides-Extra: influx
39
+ Requires-Dist: httpx2>=2.13; extra == 'influx'
35
40
  Provides-Extra: postgres
36
41
  Requires-Dist: aiosqlite>=0.22; extra == 'postgres'
37
42
  Requires-Dist: alembic>=1.19; extra == 'postgres'
@@ -61,7 +66,11 @@ pip install "jigkit[db]"
61
66
  | `jigkit.logging` | — | stream and rotating file handlers, a level that changes at runtime, credential-carrying HTTP client loggers kept quiet |
62
67
  | `jigkit.healthcheck` | — | `python -m jigkit.healthcheck URL --expect database=connected`, a container health probe |
63
68
  | `jigkit.secrets` | — | `load_or_create_secret`, a generated secret kept owner-only in the data directory |
64
- | `jigkit.testing` | `testing` | a pytest plugin (`data_dir`, `no_network`) and migration checks run through the application's own `env.py` |
69
+ | `jigkit.sinks` | — | `Point` (timestamp with an explicit precision), `Outcome`, the `Sink` protocol, the neutral spooled form |
70
+ | `jigkit.spool`, `jigkit.delivery` | — | a bounded on-disk queue per destination, and write-now-or-spool-and-replay |
71
+ | `jigkit.influx` | `influx` | InfluxDB 3 and 2.x sinks: line protocol in each batch's precision, and SQL reads from InfluxDB 3 |
72
+ | `jigkit.heartbeat` | `heartbeat` | a push-monitor heartbeat after delivered writes, at most once per interval |
73
+ | `jigkit.testing` | `testing` | a pytest plugin (`data_dir`, `no_network`), migration checks run through the application's own `env.py`, and a recording stand-in HTTP server |
65
74
 
66
75
  PostgreSQL is supported through the `postgres` extra. Python 3.14 or newer.
67
76
 
@@ -17,7 +17,11 @@ pip install "jigkit[db]"
17
17
  | `jigkit.logging` | — | stream and rotating file handlers, a level that changes at runtime, credential-carrying HTTP client loggers kept quiet |
18
18
  | `jigkit.healthcheck` | — | `python -m jigkit.healthcheck URL --expect database=connected`, a container health probe |
19
19
  | `jigkit.secrets` | — | `load_or_create_secret`, a generated secret kept owner-only in the data directory |
20
- | `jigkit.testing` | `testing` | a pytest plugin (`data_dir`, `no_network`) and migration checks run through the application's own `env.py` |
20
+ | `jigkit.sinks` | — | `Point` (timestamp with an explicit precision), `Outcome`, the `Sink` protocol, the neutral spooled form |
21
+ | `jigkit.spool`, `jigkit.delivery` | — | a bounded on-disk queue per destination, and write-now-or-spool-and-replay |
22
+ | `jigkit.influx` | `influx` | InfluxDB 3 and 2.x sinks: line protocol in each batch's precision, and SQL reads from InfluxDB 3 |
23
+ | `jigkit.heartbeat` | `heartbeat` | a push-monitor heartbeat after delivered writes, at most once per interval |
24
+ | `jigkit.testing` | `testing` | a pytest plugin (`data_dir`, `no_network`), migration checks run through the application's own `env.py`, and a recording stand-in HTTP server |
21
25
 
22
26
  PostgreSQL is supported through the `postgres` extra. Python 3.14 or newer.
23
27
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "jigkit"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "The shared foundations of small self-hosted web apps: settings, database, migrations, logging, health checks and test fixtures"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -39,6 +39,14 @@ postgres = [
39
39
  "jigkit[db]",
40
40
  "asyncpg>=0.30",
41
41
  ]
42
+ # InfluxDB sinks: line protocol to InfluxDB 3 and 2.x, and SQL reads from InfluxDB 3.
43
+ influx = [
44
+ "httpx2>=2.13",
45
+ ]
46
+ # The push-monitor heartbeat.
47
+ heartbeat = [
48
+ "httpx2>=2.13",
49
+ ]
42
50
  # The pytest plugin: fixtures and migration checks for applications built on jigkit.
43
51
  testing = [
44
52
  "pytest>=9.1",
@@ -46,7 +54,7 @@ testing = [
46
54
  ]
47
55
  # Everything this repository's own checks need.
48
56
  dev = [
49
- "jigkit[db,testing]",
57
+ "jigkit[db,heartbeat,influx,testing]",
50
58
  "pytest-cov>=7.1",
51
59
  "ruff>=0.16",
52
60
  "build>=1.3",
@@ -0,0 +1,156 @@
1
+ """Getting points into a sink eventually: write now if possible, spool and replay if not.
2
+
3
+ A batch is written straight away while nothing is waiting. Once anything is, new batches join the
4
+ end of the queue instead, and one background loop drains it oldest first with growing pauses
5
+ between failed attempts -- so an outage costs one retry per pause rather than one per batch, and
6
+ the backlog lands in the order it arrived.
7
+
8
+ One :class:`Delivery` serves one sink with its own :class:`~jigkit.spool.Spool`; an application
9
+ writing to several destinations runs one per destination, so a failing destination holds back
10
+ only its own backlog.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ from collections.abc import Awaitable, Callable
18
+ from typing import Protocol
19
+
20
+ from jigkit.sinks import Outcome, Point, dump_points, load_points
21
+ from jigkit.spool import Spool
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ FIRST_PAUSE_SECONDS = 1.0
26
+ MAX_PAUSE_SECONDS = 60.0
27
+
28
+
29
+ class Writer(Protocol):
30
+ """Something that attempts one write and says what became of it -- a sink, in practice."""
31
+
32
+ async def write(self, points: list[Point]) -> Outcome:
33
+ """Write points once."""
34
+
35
+
36
+ class DeliveryEvents(Protocol):
37
+ """What a delivery reports as it works, for an application's status page to count."""
38
+
39
+ def record_write(self, ok: bool) -> None:
40
+ """A write was attempted; ``ok`` says whether the sink accepted it."""
41
+
42
+ def record_spooled(self) -> None:
43
+ """A batch was queued to be written later."""
44
+
45
+ def record_rejected_write(self) -> None:
46
+ """The sink refused a batch for good."""
47
+
48
+
49
+ class _NoEvents:
50
+ """The events of an application that counts nothing."""
51
+
52
+ def record_write(self, ok: bool) -> None:
53
+ """Ignore a write."""
54
+
55
+ def record_spooled(self) -> None:
56
+ """Ignore a spooled batch."""
57
+
58
+ def record_rejected_write(self) -> None:
59
+ """Ignore a rejected batch."""
60
+
61
+
62
+ class Delivery:
63
+ """Writes batches, spooling the ones that cannot be written yet.
64
+
65
+ ``on_written`` is called with the points of every write the sink accepts, live or replayed.
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ sink: Writer,
71
+ spool: Spool,
72
+ *,
73
+ events: DeliveryEvents | None = None,
74
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
75
+ on_written: Callable[[list[Point]], None] = lambda _: None,
76
+ ) -> None:
77
+ self._sink = sink
78
+ self._spool = spool
79
+ self._events = events or _NoEvents()
80
+ self._sleep = sleep
81
+ self._on_written = on_written
82
+ self._wake = asyncio.Event()
83
+
84
+ async def submit(self, points: list[Point]) -> None:
85
+ """Deliver one batch now, or queue it for the replay loop."""
86
+ if not points:
87
+ return
88
+ if len(self._spool):
89
+ self._queue(points)
90
+ return
91
+ outcome = await self._sink.write(points)
92
+ self._events.record_write(outcome is Outcome.OK)
93
+ if outcome is Outcome.OK:
94
+ self._on_written(points)
95
+ elif outcome is Outcome.RETRY:
96
+ self._queue(points)
97
+ else:
98
+ self._events.record_rejected_write()
99
+ self._spool.quarantine_body(dump_points(points))
100
+
101
+ async def run(self) -> None:
102
+ """Replay spooled batches for ever, oldest first. Cancel the task to stop it.
103
+
104
+ An unexpected error is logged and waited out like a failed write, never allowed to end
105
+ the loop: with it gone, every later batch would queue with nothing to drain it.
106
+ """
107
+ pause = FIRST_PAUSE_SECONDS
108
+ while True:
109
+ try:
110
+ pause = await self._step(pause)
111
+ except Exception:
112
+ logger.exception("replaying the spool failed; trying again in %.0f s", pause)
113
+ await self._sleep(pause)
114
+ pause = min(pause * 2, MAX_PAUSE_SECONDS)
115
+
116
+ async def _step(self, pause: float) -> float:
117
+ """Replay the oldest batch, or wait for one; return the pause the next failure takes."""
118
+ path = self._spool.oldest()
119
+ if path is None:
120
+ self._wake.clear()
121
+ await self._wake.wait()
122
+ return FIRST_PAUSE_SECONDS
123
+ try:
124
+ points = load_points(self._spool.read(path))
125
+ except OSError, UnicodeDecodeError:
126
+ logger.exception("cannot read spooled batch %s; setting it aside", path.name)
127
+ self._spool.quarantine(path)
128
+ return pause
129
+ except ValueError:
130
+ # Not points -- a format an older version spooled, or a file edited by hand -- so no
131
+ # sink can be given it.
132
+ logger.error("spooled batch %s is not points; setting it aside", path.name)
133
+ self._spool.quarantine(path)
134
+ return pause
135
+
136
+ outcome = await self._sink.write(points)
137
+ self._events.record_write(outcome is Outcome.OK)
138
+ if outcome is Outcome.OK:
139
+ self._spool.ack(path)
140
+ self._on_written(points)
141
+ pause = FIRST_PAUSE_SECONDS
142
+ if not len(self._spool):
143
+ logger.info("spool drained")
144
+ elif outcome is Outcome.REJECT:
145
+ self._events.record_rejected_write()
146
+ self._spool.quarantine(path)
147
+ else:
148
+ await self._sleep(pause)
149
+ pause = min(pause * 2, MAX_PAUSE_SECONDS)
150
+ return pause
151
+
152
+ def _queue(self, points: list[Point]) -> None:
153
+ """Add a batch to the spool and wake the replay loop."""
154
+ if self._spool.enqueue(dump_points(points)):
155
+ self._events.record_spooled()
156
+ self._wake.set()
@@ -0,0 +1,96 @@
1
+ """Tell a push monitor that data is arriving (extra ``heartbeat``).
2
+
3
+ A push monitor -- Uptime Kuma's, healthchecks.io's -- alerts when its URL stops being called.
4
+ Calling it after each write a sink accepts makes one monitor cover the whole chain: a source that
5
+ stops sending, an unreachable server and a sink that refuses or cannot take writes all stop the
6
+ calls alike, while a batch that is merely spooled does not count as delivered.
7
+
8
+ The calls run on their own task, at most once per interval, so a replayed backlog costs one call
9
+ rather than one per batch and a slow monitor never holds up the work. A push URL carries its
10
+ monitor's token, so read it from a secret (``HEARTBEAT_URL_FILE``) and never log more than its
11
+ host.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ from collections.abc import Awaitable, Callable
19
+ from urllib.parse import urlsplit
20
+
21
+ import httpx2
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ TIMEOUT_SECONDS = 10.0
26
+
27
+
28
+ class Heartbeat:
29
+ """Calls a URL after each :meth:`beat`, no more often than once per interval.
30
+
31
+ ``on_result`` is called with whether each call got through, for a status page to show.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ client: httpx2.AsyncClient,
37
+ url: str,
38
+ *,
39
+ interval_seconds: float,
40
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
41
+ on_result: Callable[[bool], None] = lambda _: None,
42
+ ) -> None:
43
+ self._client = client
44
+ self._url = url
45
+ self._interval = interval_seconds
46
+ self._sleep = sleep
47
+ self._on_result = on_result
48
+ self._due = asyncio.Event()
49
+ self._failing = False
50
+
51
+ @property
52
+ def host(self) -> str:
53
+ """Where the calls go, without the path or any credentials in the URL.
54
+
55
+ Push URLs carry their monitor's token in the path, and some carry a login, so only the
56
+ host is ever logged.
57
+ """
58
+ return urlsplit(self._url).hostname or "?"
59
+
60
+ def beat(self) -> None:
61
+ """Note that data was delivered; the next call goes out as soon as one may."""
62
+ self._due.set()
63
+
64
+ async def run(self) -> None:
65
+ """Call the URL for each beat, an interval apart at most. Cancel the task to stop it.
66
+
67
+ An unexpected error is logged and the loop carries on: ending it would stop the calls,
68
+ and the monitor would report data missing that is arriving.
69
+ """
70
+ while True:
71
+ await self._due.wait()
72
+ self._due.clear()
73
+ try:
74
+ await self.call()
75
+ except Exception:
76
+ logger.exception("heartbeat to %s failed unexpectedly", self.host)
77
+ await self._sleep(self._interval)
78
+
79
+ async def call(self) -> bool:
80
+ """Call the URL once, returning whether the monitor accepted it.
81
+
82
+ A failure is logged when calls start failing and again when they recover, not on every
83
+ attempt: the monitor itself raises the alarm, and the log only has to explain it.
84
+ """
85
+ try:
86
+ response = await self._client.get(self._url, timeout=TIMEOUT_SECONDS)
87
+ ok, reason = response.is_success, f"HTTP {response.status_code}"
88
+ except httpx2.RequestError as error:
89
+ ok, reason = False, type(error).__name__
90
+ if not ok and not self._failing:
91
+ logger.warning("heartbeat to %s failed: %s", self.host, reason)
92
+ elif ok and self._failing:
93
+ logger.info("heartbeat to %s is getting through again", self.host)
94
+ self._failing = not ok
95
+ self._on_result(ok)
96
+ return ok
@@ -0,0 +1,31 @@
1
+ """InfluxDB sinks (extra ``influx``): line protocol over HTTP to InfluxDB 3 and 2.x.
2
+
3
+ :class:`Influx3Sink` also reads back, with SQL whose table and field names are checked against
4
+ the database's own catalogue.
5
+ """
6
+
7
+ from jigkit.influx.lineprotocol import encode
8
+ from jigkit.influx.sink import (
9
+ Influx2Sink,
10
+ Influx3Sink,
11
+ InfluxSink,
12
+ QueryError,
13
+ TableInfo,
14
+ TableNotFound,
15
+ classify,
16
+ parse_time,
17
+ sql_timestamp,
18
+ )
19
+
20
+ __all__ = [
21
+ "Influx2Sink",
22
+ "Influx3Sink",
23
+ "InfluxSink",
24
+ "QueryError",
25
+ "TableInfo",
26
+ "TableNotFound",
27
+ "classify",
28
+ "encode",
29
+ "parse_time",
30
+ "sql_timestamp",
31
+ ]
@@ -0,0 +1,76 @@
1
+ """Encode points as InfluxDB line protocol, which InfluxDB 3 and 2.x both accept."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Sequence
7
+
8
+ from jigkit.sinks import FieldValue, Point, Precision
9
+
10
+
11
+ def encode(points: Sequence[Point]) -> tuple[str, Precision]:
12
+ """Encode points as newline-separated line protocol, and the precision to write it at.
13
+
14
+ A write states one precision for all its lines. Points that share one are written in it;
15
+ a batch that mixes them is written in the finest it contains, so no timestamp loses
16
+ anything. Points with no field line protocol can carry are left out.
17
+ """
18
+ precisions = {point.precision for point in points}
19
+ precision = max(precisions, key=lambda p: p.per_second) if precisions else Precision.SECONDS
20
+ lines = [line for point in points if (line := encode_point(point, precision))]
21
+ return "\n".join(lines), precision
22
+
23
+
24
+ def encode_point(point: Point, precision: Precision) -> str:
25
+ """Encode one point at ``precision``, or return "" if none of its fields can be written."""
26
+ fields = ",".join(
27
+ f"{_escape_key(name)}={encoded}"
28
+ for name, value in point.fields.items()
29
+ if (encoded := _value(value)) is not None
30
+ )
31
+ if not fields:
32
+ return ""
33
+ # Line protocol forbids an empty tag value, so such a tag is left off rather than written.
34
+ tags = "".join(
35
+ f",{_escape_key(key)}={_escape_key(value)}" for key, value in point.tags if value
36
+ )
37
+ return f"{_escape_measurement(point.table)}{tags} {fields} {point.timestamp_in(precision)}"
38
+
39
+
40
+ def _value(value: FieldValue) -> str | None:
41
+ """Encode a field value, or None for one line protocol cannot represent.
42
+
43
+ A float is written as one even when it has no fraction: InfluxDB fixes a field's type on its
44
+ first write, so a humidity first seen as ``60`` would otherwise reject every later ``60.5``.
45
+ An int is written as an integer.
46
+ """
47
+ if isinstance(value, bool):
48
+ return "true" if value else "false"
49
+ if isinstance(value, int):
50
+ return f"{value}i"
51
+ if isinstance(value, float):
52
+ return repr(value) if math.isfinite(value) else None
53
+ escaped = _flatten(value).replace("\\", "\\\\").replace('"', '\\"')
54
+ return f'"{escaped}"'
55
+
56
+
57
+ def _flatten(text: str) -> str:
58
+ """Replace line breaks with spaces: line protocol has no escape for them, and one would end
59
+ the line mid-point, making InfluxDB refuse the whole write."""
60
+ return text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
61
+
62
+
63
+ def _escape_measurement(name: str) -> str:
64
+ """Escape a measurement name: commas and spaces would end it early."""
65
+ return _flatten(name).replace("\\", "\\\\").replace(",", "\\,").replace(" ", "\\ ")
66
+
67
+
68
+ def _escape_key(name: str) -> str:
69
+ """Escape a tag key, tag value or field key: commas, equals signs and spaces."""
70
+ return (
71
+ _flatten(name)
72
+ .replace("\\", "\\\\")
73
+ .replace(",", "\\,")
74
+ .replace("=", "\\=")
75
+ .replace(" ", "\\ ")
76
+ )