axor-telemetry 0.1.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.
@@ -0,0 +1,88 @@
1
+ name: CI/CD
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*.*.*"]
7
+ pull_request:
8
+ branches: [main]
9
+
10
+ jobs:
11
+ test:
12
+ name: Test (Python ${{ matrix.python-version }})
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python-version: ["3.11", "3.12"]
17
+
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Checkout axor-core
22
+ uses: actions/checkout@v4
23
+ with:
24
+ repository: ${{ github.repository_owner }}/axor-core
25
+ path: axor-core
26
+
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: ${{ matrix.python-version }}
30
+ cache: pip
31
+
32
+ - name: Install
33
+ run: |
34
+ pip install -e axor-core/
35
+ pip install -e ".[dev]"
36
+
37
+ - name: Run tests
38
+ run: pytest tests/ -v --tb=short
39
+
40
+ publish:
41
+ name: Publish to PyPI
42
+ needs: test
43
+ runs-on: ubuntu-latest
44
+ if: startsWith(github.ref, 'refs/tags/v')
45
+ environment: pypi
46
+
47
+ permissions:
48
+ id-token: write
49
+
50
+ steps:
51
+ - uses: actions/checkout@v4
52
+
53
+ - uses: actions/setup-python@v5
54
+ with:
55
+ python-version: "3.12"
56
+
57
+ - name: Verify tag matches package version
58
+ run: |
59
+ python - << 'EOF'
60
+ import pathlib
61
+ import re
62
+ import sys
63
+ import tomllib
64
+
65
+ ref = "${{ github.ref_name }}"
66
+ m = re.fullmatch(r"v(\d+\.\d+\.\d+)", ref)
67
+ if not m:
68
+ print(f"Tag {ref!r} must match vX.Y.Z")
69
+ sys.exit(1)
70
+
71
+ tag_version = m.group(1)
72
+ data = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8"))
73
+ pkg_version = data["project"]["version"]
74
+
75
+ if tag_version != pkg_version:
76
+ print(f"Version mismatch: tag={tag_version}, pyproject={pkg_version}")
77
+ sys.exit(1)
78
+
79
+ print(f"Version check passed: {pkg_version}")
80
+ EOF
81
+
82
+ - name: Build
83
+ run: |
84
+ pip install hatchling build
85
+ python -m build
86
+
87
+ - name: Publish to PyPI
88
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .coverage
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Axor Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: axor-telemetry
3
+ Version: 0.1.0
4
+ Summary: Anonymous telemetry pipeline for axor-core: MinHash embedder, local/HTTP sinks, opt-in consent CLI
5
+ Project-URL: Repository, https://github.com/Bucha11/axor-telemetry
6
+ Project-URL: Bug Tracker, https://github.com/Bucha11/axor-telemetry/issues
7
+ Project-URL: Changelog, https://github.com/Bucha11/axor-telemetry/releases
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents,axor,minhash,telemetry
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.11
18
+ Provides-Extra: core
19
+ Requires-Dist: axor-core>=0.3.0; extra == 'core'
20
+ Provides-Extra: dev
21
+ Requires-Dist: axor-core>=0.3.0; extra == 'dev'
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # axor-telemetry
27
+
28
+ Anonymous telemetry pipeline for [axor-core](https://github.com/Bucha11/axor-core).
29
+
30
+ **Opt-in only.** Nothing is sent without explicit user consent.
31
+
32
+ ## What gets sent (when consent is given)
33
+
34
+ - `signal_chosen` (e.g. `focused_generative`)
35
+ - `classifier_used`, `confidence`
36
+ - MinHash fingerprint of the raw input (128 ints, non-reversible)
37
+ - `tokens_spent`, `policy_adjusted`
38
+ - `axor_version`
39
+
40
+ **Not sent:** raw task text, file contents, user or session identifiers,
41
+ tool arguments, secrets.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install axor-telemetry[core]
47
+ ```
48
+
49
+ ## CLI
50
+
51
+ ```bash
52
+ python -m axor_telemetry consent # interactive opt-in
53
+ python -m axor_telemetry status # show current config
54
+ python -m axor_telemetry preview # show the last queued record
55
+ python -m axor_telemetry on # non-interactive: set local mode
56
+ python -m axor_telemetry off # disable
57
+ ```
58
+
59
+ Config lives at `~/.axor/config.toml` under `[telemetry]`.
60
+
61
+ ## Modes
62
+
63
+ | mode | behavior |
64
+ |----------|----------|
65
+ | `off` | Default. Pipeline does nothing. |
66
+ | `local` | Writes to `~/.axor/telemetry_queue.jsonl`. Never sent anywhere. |
67
+ | `remote` | Writes local queue + ships batches to `telemetry.useaxor.net/v1/records`. Retry-on-next-start if offline. |
68
+
69
+ ## Programmatic usage
70
+
71
+ ```python
72
+ from axor_telemetry import TelemetryPipeline, MinHashEmbedder, FileTelemetrySink
73
+
74
+ pipeline = TelemetryPipeline(
75
+ embedder=MinHashEmbedder(),
76
+ sink=FileTelemetrySink(queue_path="~/.axor/telemetry_queue.jsonl"),
77
+ )
78
+ ```
79
+
80
+ Inject `pipeline` into `GovernedSession` (see axor-cli integration).
@@ -0,0 +1,55 @@
1
+ # axor-telemetry
2
+
3
+ Anonymous telemetry pipeline for [axor-core](https://github.com/Bucha11/axor-core).
4
+
5
+ **Opt-in only.** Nothing is sent without explicit user consent.
6
+
7
+ ## What gets sent (when consent is given)
8
+
9
+ - `signal_chosen` (e.g. `focused_generative`)
10
+ - `classifier_used`, `confidence`
11
+ - MinHash fingerprint of the raw input (128 ints, non-reversible)
12
+ - `tokens_spent`, `policy_adjusted`
13
+ - `axor_version`
14
+
15
+ **Not sent:** raw task text, file contents, user or session identifiers,
16
+ tool arguments, secrets.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install axor-telemetry[core]
22
+ ```
23
+
24
+ ## CLI
25
+
26
+ ```bash
27
+ python -m axor_telemetry consent # interactive opt-in
28
+ python -m axor_telemetry status # show current config
29
+ python -m axor_telemetry preview # show the last queued record
30
+ python -m axor_telemetry on # non-interactive: set local mode
31
+ python -m axor_telemetry off # disable
32
+ ```
33
+
34
+ Config lives at `~/.axor/config.toml` under `[telemetry]`.
35
+
36
+ ## Modes
37
+
38
+ | mode | behavior |
39
+ |----------|----------|
40
+ | `off` | Default. Pipeline does nothing. |
41
+ | `local` | Writes to `~/.axor/telemetry_queue.jsonl`. Never sent anywhere. |
42
+ | `remote` | Writes local queue + ships batches to `telemetry.useaxor.net/v1/records`. Retry-on-next-start if offline. |
43
+
44
+ ## Programmatic usage
45
+
46
+ ```python
47
+ from axor_telemetry import TelemetryPipeline, MinHashEmbedder, FileTelemetrySink
48
+
49
+ pipeline = TelemetryPipeline(
50
+ embedder=MinHashEmbedder(),
51
+ sink=FileTelemetrySink(queue_path="~/.axor/telemetry_queue.jsonl"),
52
+ )
53
+ ```
54
+
55
+ Inject `pipeline` into `GovernedSession` (see axor-cli integration).
@@ -0,0 +1,31 @@
1
+ """
2
+ axor-telemetry
3
+ ──────────────
4
+ Anonymous telemetry pipeline for axor-core. Opt-in only.
5
+
6
+ Public surface:
7
+ - MinHashEmbedder — pure-Python 128-dim char-3 MinHash fingerprint
8
+ - FileTelemetrySink — JSONL queue at ~/.axor/telemetry_queue.jsonl
9
+ - HTTPTelemetrySink — POST batches with retry-on-next-start
10
+ - TelemetryPipeline — trace → anonymize → dispatch
11
+ - TelemetryConfig — resolved mode + endpoint from env + config.toml
12
+ """
13
+
14
+ from axor_telemetry.embedder import MinHashEmbedder
15
+ from axor_telemetry.sinks.file_sink import FileTelemetrySink
16
+ from axor_telemetry.sinks.http_sink import HTTPTelemetrySink
17
+ from axor_telemetry.pipeline import TelemetryPipeline, build_pipeline
18
+ from axor_telemetry.config import TelemetryConfig, TelemetryMode
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "MinHashEmbedder",
24
+ "FileTelemetrySink",
25
+ "HTTPTelemetrySink",
26
+ "TelemetryPipeline",
27
+ "build_pipeline",
28
+ "TelemetryConfig",
29
+ "TelemetryMode",
30
+ "__version__",
31
+ ]
@@ -0,0 +1,4 @@
1
+ from axor_telemetry.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,165 @@
1
+ """
2
+ CLI for axor-telemetry: `python -m axor_telemetry <command>`.
3
+
4
+ Commands:
5
+ consent — interactive opt-in; writes mode + endpoint to ~/.axor/config.toml
6
+ status — show current effective config (env + file merged)
7
+ preview — print the last queued record so the user sees exactly what goes out
8
+ on — non-interactive: set mode=local
9
+ off — disable telemetry
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from axor_telemetry.config import TelemetryConfig, TelemetryMode
19
+
20
+
21
+ def cmd_consent(args: argparse.Namespace, stream=sys.stdout, prompt_input=input) -> int:
22
+ current = TelemetryConfig.load()
23
+ stream.write(_consent_text(current))
24
+ stream.flush()
25
+ try:
26
+ answer = prompt_input("> ").strip().lower()
27
+ except (EOFError, KeyboardInterrupt):
28
+ stream.write("\naborted. no change.\n")
29
+ return 1
30
+
31
+ if answer in ("r", "remote"):
32
+ new = TelemetryConfig(mode=TelemetryMode.REMOTE, endpoint=current.endpoint,
33
+ queue_path=current.queue_path,
34
+ fingerprint_kind=current.fingerprint_kind)
35
+ elif answer in ("l", "local"):
36
+ new = TelemetryConfig(mode=TelemetryMode.LOCAL, endpoint=current.endpoint,
37
+ queue_path=current.queue_path,
38
+ fingerprint_kind=current.fingerprint_kind)
39
+ else:
40
+ new = TelemetryConfig(mode=TelemetryMode.OFF, endpoint=current.endpoint,
41
+ queue_path=current.queue_path,
42
+ fingerprint_kind=current.fingerprint_kind)
43
+
44
+ path = new.write()
45
+ stream.write(f"saved to {path}: mode={new.mode.value}\n")
46
+ return 0
47
+
48
+
49
+ def cmd_status(args: argparse.Namespace, stream=sys.stdout) -> int:
50
+ cfg = TelemetryConfig.load()
51
+ stream.write(f"mode: {cfg.mode.value}\n")
52
+ stream.write(f"endpoint: {cfg.endpoint}\n")
53
+ stream.write(f"queue_path: {cfg.queue_path}\n")
54
+ stream.write(f"fingerprint_kind: {cfg.fingerprint_kind}\n")
55
+ queue = Path(cfg.queue_path).expanduser()
56
+ if queue.is_file():
57
+ try:
58
+ size = queue.stat().st_size
59
+ except OSError:
60
+ size = 0
61
+ stream.write(f"queue_bytes: {size}\n")
62
+ return 0
63
+
64
+
65
+ def cmd_preview(args: argparse.Namespace, stream=sys.stdout) -> int:
66
+ cfg = TelemetryConfig.load()
67
+ queue = Path(cfg.queue_path).expanduser()
68
+ if not queue.is_file():
69
+ stream.write("queue is empty (no records have been generated yet).\n")
70
+ return 0
71
+ last = None
72
+ try:
73
+ with queue.open("r", encoding="utf-8") as f:
74
+ for line in f:
75
+ line = line.strip()
76
+ if line:
77
+ last = line
78
+ except OSError as exc:
79
+ stream.write(f"failed to read queue: {exc}\n")
80
+ return 1
81
+ if not last:
82
+ stream.write("queue file exists but is empty.\n")
83
+ return 0
84
+ try:
85
+ record = json.loads(last)
86
+ stream.write(json.dumps(record, indent=2, ensure_ascii=False) + "\n")
87
+ except json.JSONDecodeError:
88
+ stream.write(last + "\n")
89
+ return 0
90
+
91
+
92
+ def cmd_on(args: argparse.Namespace, stream=sys.stdout) -> int:
93
+ current = TelemetryConfig.load()
94
+ mode = TelemetryMode.REMOTE if args.remote else TelemetryMode.LOCAL
95
+ new = TelemetryConfig(mode=mode, endpoint=current.endpoint,
96
+ queue_path=current.queue_path,
97
+ fingerprint_kind=current.fingerprint_kind)
98
+ path = new.write()
99
+ stream.write(f"telemetry is now {mode.value}. config: {path}\n")
100
+ return 0
101
+
102
+
103
+ def cmd_off(args: argparse.Namespace, stream=sys.stdout) -> int:
104
+ current = TelemetryConfig.load()
105
+ new = TelemetryConfig(mode=TelemetryMode.OFF, endpoint=current.endpoint,
106
+ queue_path=current.queue_path,
107
+ fingerprint_kind=current.fingerprint_kind)
108
+ path = new.write()
109
+ stream.write(f"telemetry disabled. config: {path}\n")
110
+ return 0
111
+
112
+
113
+ def _consent_text(current: TelemetryConfig) -> str:
114
+ return (
115
+ "axor telemetry is opt-in. Nothing has been sent yet.\n"
116
+ "\n"
117
+ "What gets collected (when enabled):\n"
118
+ " - chosen classification (e.g. focused_generative)\n"
119
+ " - classifier name + confidence\n"
120
+ " - a 128-int MinHash fingerprint of the raw input (non-reversible)\n"
121
+ " - tokens spent, whether policy was adjusted mid-run\n"
122
+ "\n"
123
+ "What is NEVER collected:\n"
124
+ " - raw task text, file contents, tool arguments, secrets\n"
125
+ " - user/session identifiers\n"
126
+ "\n"
127
+ f"Current mode: {current.mode.value}\n"
128
+ f"Endpoint (remote mode): {current.endpoint}\n"
129
+ "\n"
130
+ "Choose:\n"
131
+ " [l] local — write records to a local JSONL queue, never send anywhere\n"
132
+ " [r] remote — also ship to the project telemetry server (retry on next start if offline)\n"
133
+ " [n] off — do nothing (default)\n"
134
+ )
135
+
136
+
137
+ def build_parser() -> argparse.ArgumentParser:
138
+ p = argparse.ArgumentParser(prog="python -m axor_telemetry")
139
+ sub = p.add_subparsers(dest="cmd", required=True)
140
+
141
+ sub.add_parser("consent", help="interactive opt-in prompt")
142
+ sub.add_parser("status", help="show current effective config")
143
+ sub.add_parser("preview", help="print the last queued record")
144
+ on_parser = sub.add_parser("on", help="enable telemetry (local by default)")
145
+ on_parser.add_argument("--remote", action="store_true", help="enable remote shipping")
146
+ sub.add_parser("off", help="disable telemetry")
147
+
148
+ return p
149
+
150
+
151
+ def main(argv: list[str] | None = None) -> int:
152
+ parser = build_parser()
153
+ args = parser.parse_args(argv)
154
+ dispatch = {
155
+ "consent": cmd_consent,
156
+ "status": cmd_status,
157
+ "preview": cmd_preview,
158
+ "on": cmd_on,
159
+ "off": cmd_off,
160
+ }
161
+ return dispatch[args.cmd](args)
162
+
163
+
164
+ if __name__ == "__main__":
165
+ raise SystemExit(main())
@@ -0,0 +1,145 @@
1
+ """
2
+ Telemetry configuration — resolved from env and ~/.axor/config.toml.
3
+
4
+ Priority (highest first):
5
+ 1. env vars AXOR_TELEMETRY, AXOR_TELEMETRY_ENDPOINT, AXOR_TELEMETRY_QUEUE
6
+ 2. ~/.axor/config.toml [telemetry] section
7
+ 3. defaults (off, stock endpoint, ~/.axor/telemetry_queue.jsonl)
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import tomllib
13
+ from dataclasses import dataclass
14
+ from enum import Enum
15
+ from pathlib import Path
16
+
17
+ _CONFIG_PATH = Path("~/.axor/config.toml").expanduser()
18
+ _DEFAULT_QUEUE = "~/.axor/telemetry_queue.jsonl"
19
+ _DEFAULT_ENDPOINT = "https://telemetry.useaxor.net/v1/records"
20
+
21
+
22
+ class TelemetryMode(str, Enum):
23
+ OFF = "off"
24
+ LOCAL = "local"
25
+ REMOTE = "remote"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class TelemetryConfig:
30
+ mode: TelemetryMode = TelemetryMode.OFF
31
+ endpoint: str = _DEFAULT_ENDPOINT
32
+ queue_path: str = _DEFAULT_QUEUE
33
+ fingerprint_kind: str = "minhash_v1"
34
+
35
+ @property
36
+ def enabled(self) -> bool:
37
+ return self.mode is not TelemetryMode.OFF
38
+
39
+ @property
40
+ def ships_remote(self) -> bool:
41
+ return self.mode is TelemetryMode.REMOTE
42
+
43
+ @classmethod
44
+ def load(cls, config_path: Path | None = None) -> "TelemetryConfig":
45
+ """Resolve config from env + TOML file. Returns defaults when nothing is set."""
46
+ path = config_path or _CONFIG_PATH
47
+ data: dict = {}
48
+ if path.is_file():
49
+ try:
50
+ with path.open("rb") as fh:
51
+ data = tomllib.load(fh).get("telemetry", {}) or {}
52
+ except (OSError, tomllib.TOMLDecodeError):
53
+ data = {}
54
+
55
+ env_mode = os.environ.get("AXOR_TELEMETRY")
56
+ mode_raw = env_mode or data.get("mode") or "off"
57
+ try:
58
+ mode = TelemetryMode(mode_raw.lower())
59
+ except ValueError:
60
+ mode = TelemetryMode.OFF
61
+
62
+ endpoint = os.environ.get("AXOR_TELEMETRY_ENDPOINT") or data.get("endpoint") or _DEFAULT_ENDPOINT
63
+ queue_path = os.environ.get("AXOR_TELEMETRY_QUEUE") or data.get("queue_path") or _DEFAULT_QUEUE
64
+ fingerprint_kind = data.get("fingerprint_kind") or "minhash_v1"
65
+
66
+ return cls(
67
+ mode=mode,
68
+ endpoint=endpoint,
69
+ queue_path=queue_path,
70
+ fingerprint_kind=fingerprint_kind,
71
+ )
72
+
73
+ def write(self, config_path: Path | None = None) -> Path:
74
+ """
75
+ Persist [telemetry] section to ~/.axor/config.toml, preserving other
76
+ sections already present. Atomic: writes to tmp file then renames.
77
+ """
78
+ path = config_path or _CONFIG_PATH
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+
81
+ existing = ""
82
+ if path.is_file():
83
+ try:
84
+ existing = path.read_text(encoding="utf-8")
85
+ except OSError:
86
+ existing = ""
87
+
88
+ section = self._render_section()
89
+ new_text = _replace_section(existing, "telemetry", section)
90
+ tmp = path.with_suffix(path.suffix + ".tmp")
91
+ tmp.write_text(new_text, encoding="utf-8")
92
+ os.replace(tmp, path)
93
+ try:
94
+ path.chmod(0o600)
95
+ except OSError:
96
+ pass
97
+ return path
98
+
99
+ def _render_section(self) -> str:
100
+ return (
101
+ "[telemetry]\n"
102
+ f'mode = "{self.mode.value}"\n'
103
+ f'endpoint = "{_escape_toml(self.endpoint)}"\n'
104
+ f'queue_path = "{_escape_toml(self.queue_path)}"\n'
105
+ f'fingerprint_kind = "{_escape_toml(self.fingerprint_kind)}"\n'
106
+ )
107
+
108
+
109
+ def _escape_toml(s: str) -> str:
110
+ return s.replace("\\", "\\\\").replace('"', '\\"')
111
+
112
+
113
+ def _replace_section(text: str, section: str, new_block: str) -> str:
114
+ """
115
+ Rewrite a single `[section]` block in a TOML document. If missing, append.
116
+ Preserves all other sections verbatim.
117
+ """
118
+ lines = text.splitlines(keepends=True)
119
+ out: list[str] = []
120
+ i = 0
121
+ replaced = False
122
+ header = f"[{section}]"
123
+ while i < len(lines):
124
+ line = lines[i]
125
+ stripped = line.strip()
126
+ if stripped == header:
127
+ # skip until next header or EOF
128
+ j = i + 1
129
+ while j < len(lines) and not lines[j].lstrip().startswith("["):
130
+ j += 1
131
+ out.append(new_block)
132
+ if j < len(lines):
133
+ out.append("\n")
134
+ i = j
135
+ replaced = True
136
+ continue
137
+ out.append(line)
138
+ i += 1
139
+ if not replaced:
140
+ if out and not out[-1].endswith("\n"):
141
+ out.append("\n")
142
+ if out:
143
+ out.append("\n")
144
+ out.append(new_block)
145
+ return "".join(out)