keepm 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.
- keepm-0.2.0/LICENSE +21 -0
- keepm-0.2.0/PKG-INFO +15 -0
- keepm-0.2.0/README.md +161 -0
- keepm-0.2.0/pyproject.toml +29 -0
- keepm-0.2.0/setup.cfg +4 -0
- keepm-0.2.0/src/keepm/__init__.py +1 -0
- keepm-0.2.0/src/keepm/cli.py +18 -0
- keepm-0.2.0/src/keepm/config.py +97 -0
- keepm-0.2.0/src/keepm/database.py +572 -0
- keepm-0.2.0/src/keepm/doctor.py +131 -0
- keepm-0.2.0/src/keepm/instructions.py +226 -0
- keepm-0.2.0/src/keepm/integrations.py +253 -0
- keepm-0.2.0/src/keepm/markdown.py +152 -0
- keepm-0.2.0/src/keepm/models.py +48 -0
- keepm-0.2.0/src/keepm/projects.py +57 -0
- keepm-0.2.0/src/keepm/server.py +450 -0
- keepm-0.2.0/src/keepm/service.py +299 -0
- keepm-0.2.0/src/keepm/setup.py +405 -0
- keepm-0.2.0/src/keepm/storage.py +66 -0
- keepm-0.2.0/src/keepm/sync.py +230 -0
- keepm-0.2.0/src/keepm/watcher.py +113 -0
- keepm-0.2.0/src/keepm.egg-info/PKG-INFO +15 -0
- keepm-0.2.0/src/keepm.egg-info/SOURCES.txt +39 -0
- keepm-0.2.0/src/keepm.egg-info/dependency_links.txt +1 -0
- keepm-0.2.0/src/keepm.egg-info/entry_points.txt +2 -0
- keepm-0.2.0/src/keepm.egg-info/requires.txt +9 -0
- keepm-0.2.0/src/keepm.egg-info/top_level.txt +1 -0
- keepm-0.2.0/tests/test_cli.py +32 -0
- keepm-0.2.0/tests/test_config.py +49 -0
- keepm-0.2.0/tests/test_database.py +104 -0
- keepm-0.2.0/tests/test_end_to_end.py +130 -0
- keepm-0.2.0/tests/test_instructions.py +201 -0
- keepm-0.2.0/tests/test_integrations.py +200 -0
- keepm-0.2.0/tests/test_markdown.py +60 -0
- keepm-0.2.0/tests/test_projects.py +38 -0
- keepm-0.2.0/tests/test_server.py +179 -0
- keepm-0.2.0/tests/test_service.py +145 -0
- keepm-0.2.0/tests/test_setup.py +453 -0
- keepm-0.2.0/tests/test_storage.py +36 -0
- keepm-0.2.0/tests/test_sync.py +110 -0
- keepm-0.2.0/tests/test_watcher.py +126 -0
keepm-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Beijing Tinqiao Technology Co., Ltd.
|
|
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.
|
keepm-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepm
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Local Markdown memory MCP for coding agents
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: fastmcp<4,>=3
|
|
8
|
+
Requires-Dist: PyYAML<7,>=6
|
|
9
|
+
Requires-Dist: questionary<3,>=2
|
|
10
|
+
Requires-Dist: tomlkit<1,>=0.13
|
|
11
|
+
Requires-Dist: watchfiles<2,>=1
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest<9,>=8; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio<2,>=0.24; extra == "dev"
|
|
15
|
+
Dynamic: license-file
|
keepm-0.2.0/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# KeepM
|
|
2
|
+
|
|
3
|
+
KeepM is a local Markdown memory MCP for coding agents. It stores long-term memories in one Obsidian Vault, separates global and project-specific knowledge, and exposes bounded search, context, and WikiLink tools over MCP stdio.
|
|
4
|
+
|
|
5
|
+
[中文文档](README-zh.md)
|
|
6
|
+
|
|
7
|
+
Markdown is the only source of truth. A local SQLite FTS5 database is a disposable index for keyword search, checksums, project scopes, WikiLinks, and backlinks. KeepM has no embeddings, vector database, cloud service, HTTP server, or required Obsidian plugin. Obsidian may remain closed.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Python 3.11 or newer
|
|
12
|
+
- [uv](https://docs.astral.sh/uv/) for the recommended `uvx` installation flow
|
|
13
|
+
- One local Obsidian Vault
|
|
14
|
+
- A local filesystem path visible to KeepM
|
|
15
|
+
|
|
16
|
+
WSL can use a Windows Vault directly, for example:
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
/mnt/c/Users/alice/Documents/ObsidianVault
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Install and setup
|
|
23
|
+
|
|
24
|
+
After KeepM is published to PyPI, the normal installation is one command:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uvx keepm setup
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The setup wizard asks for an existing local Obsidian Vault and a `zh` or `en` memory language, creates `<Vault>/Memory/`, detects Codex and Claude Code, then registers exactly one selected Agent. The selected Agent can use either scope:
|
|
31
|
+
|
|
32
|
+
Interactive setup uses a colored terminal UI: use `↑` / `↓` and Enter to select the language, one Agent, and its scope. The current pointer is bright green. Vault paths remain text input. For non-interactive automation use `--yes --agent codex|claude` and optionally `--scope user|project`; if only one Agent is installed, `--agent` may be omitted. Running interactive setup without a TTY is rejected with a clear message.
|
|
33
|
+
|
|
34
|
+
| Agent | User scope | Project scope |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| Codex | `~/.codex/config.toml` | `<project>/.codex/config.toml` |
|
|
37
|
+
| Claude Code | `~/.claude.json` | `<project>/.mcp.json` |
|
|
38
|
+
|
|
39
|
+
For every successful registration, setup installs the automatic memory protocol at the same scope:
|
|
40
|
+
|
|
41
|
+
| Agent | User instructions | Project instructions |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| Codex | `~/.codex/AGENTS.md` | `<project>/AGENTS.md` |
|
|
44
|
+
| Claude Code | `~/.claude/CLAUDE.md` | `<project>/CLAUDE.md` |
|
|
45
|
+
|
|
46
|
+
The protocol tells the Agent to load relevant context at task start and write only durable preferences, feedback, decisions, constraints, and reusable fixes before completion. KeepM owns only the section between `<!-- keepm:start -->` and `<!-- keepm:end -->`: rerunning setup replaces that section without duplicating it or changing surrounding instructions. Malformed or duplicate markers are reported and never forced. A KeepM block written by an older version to `<project>/.claude/CLAUDE.md` is removed from that old location and migrated to the canonical project-root `CLAUDE.md`; unrelated old-file content is preserved.
|
|
47
|
+
|
|
48
|
+
The scope only decides where an Agent can start KeepM. Every registration uses the same MCP command, `uvx --from keepm keepm`, and therefore the same Vault and `Memory/` tree. The wizard never silently replaces another MCP named `keepm`: an equal configuration is kept, while a different one must be replaced explicitly, skipped, or cancelled. Use `--force` only for non-interactive deliberate replacement.
|
|
49
|
+
|
|
50
|
+
KeepM's own single-Vault configuration is stored at `~/.config/keepm/config.toml`, including `language = "zh"` or `language = "en"`; SQLite indexes are stored separately at `~/.local/share/keepm/indexes/`. Neither is a Codex or Claude Code configuration file. The language controls the managed instruction block and the default prose language of new memories. Existing memories are not translated, and technical names may remain in their original language.
|
|
51
|
+
|
|
52
|
+
For source development in this repository:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uv sync --extra dev
|
|
56
|
+
uv run keepm setup
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`keepm` with no subcommand always starts only the MCP stdio server. It never opens the setup wizard or writes conversational output to MCP stdout. `memory_configure` remains available for Agent-driven reconfiguration when needed.
|
|
60
|
+
|
|
61
|
+
## Vault Layout
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
<Vault>/
|
|
65
|
+
└── Memory/
|
|
66
|
+
├── global/
|
|
67
|
+
│ └── verify-before-done.md
|
|
68
|
+
├── projects/
|
|
69
|
+
│ ├── keepm/
|
|
70
|
+
│ ├── mizupanel/
|
|
71
|
+
│ └── comet/
|
|
72
|
+
└── .trash/
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- `global/` applies across projects.
|
|
76
|
+
- `projects/<project>/` contains project-specific memories.
|
|
77
|
+
- `.trash/` contains soft-deleted notes and is not indexed.
|
|
78
|
+
- Nested folders inside a scope are allowed.
|
|
79
|
+
- No `MEMORY.md` file is created or maintained.
|
|
80
|
+
|
|
81
|
+
The project name comes from the MCP client workspace, then `.keepm.toml`, Git origin, or the workspace directory name. A repository can override it:
|
|
82
|
+
|
|
83
|
+
```toml
|
|
84
|
+
# <repository>/.keepm.toml
|
|
85
|
+
project = "keepm-v2"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
When a project and global memory have the same logical `name`, the project memory wins.
|
|
89
|
+
|
|
90
|
+
## Memory Format
|
|
91
|
+
|
|
92
|
+
```markdown
|
|
93
|
+
---
|
|
94
|
+
id: 01JXYZEXAMPLE
|
|
95
|
+
name: storage-design
|
|
96
|
+
description: KeepM local storage decision
|
|
97
|
+
type: project
|
|
98
|
+
scope: keepm
|
|
99
|
+
tags: [storage, obsidian]
|
|
100
|
+
pinned: true
|
|
101
|
+
created: 2026-07-17
|
|
102
|
+
updated: 2026-07-17
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
Markdown remains authoritative; SQLite is rebuilt from these files.
|
|
106
|
+
|
|
107
|
+
**Why:** The user can inspect and edit every memory.
|
|
108
|
+
|
|
109
|
+
**How to apply:** Write Markdown first, then update the derived index.
|
|
110
|
+
|
|
111
|
+
## Relations
|
|
112
|
+
|
|
113
|
+
- depends_on [[Local First]]
|
|
114
|
+
- integrates_with [[Obsidian]]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Required types are `user`, `feedback`, `project`, and `reference`. `feedback` and `project` memories require `Why:` and `How to apply:` sections. `pinned: true` includes the compact description early in `memory_context`.
|
|
118
|
+
|
|
119
|
+
## MCP Tools
|
|
120
|
+
|
|
121
|
+
| Tool | Purpose |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `memory_configure` | Store the Vault and language configuration and build the first index |
|
|
124
|
+
| `memory_status` | Report configuration, project, watcher, index, and sync state |
|
|
125
|
+
| `memory_context` | Return bounded pinned and query-relevant memories |
|
|
126
|
+
| `memory_search` | Search global/project memories with FTS5 or escaped substring matching |
|
|
127
|
+
| `memory_read` | Read one bounded memory plus its checksum by stable ID, logical name, or safe path |
|
|
128
|
+
| `memory_write` | Atomically create or update Markdown and refresh SQLite |
|
|
129
|
+
| `memory_delete` | Soft-delete a memory into `.trash` |
|
|
130
|
+
| `memory_links` | Return forward WikiLinks and backlinks |
|
|
131
|
+
| `memory_sync` | Run quick reconciliation or a full index rebuild |
|
|
132
|
+
| `memory_doctor` | Diagnose Markdown/index consistency without changing memories |
|
|
133
|
+
|
|
134
|
+
The setup-managed Agent protocol calls `memory_context` at task start, searches when needed, and reads only selected full memories. Before completion it writes only durable, non-sensitive knowledge. This keeps the entire Vault and routine task noise out of the model context and memory store.
|
|
135
|
+
|
|
136
|
+
## Synchronization
|
|
137
|
+
|
|
138
|
+
KeepM performs a metadata reconciliation at startup. Equal `mtime_ns` and size values are treated as unchanged without reading content. Suspected changes are hashed with SHA-256; only new or checksum-changed files are parsed and indexed.
|
|
139
|
+
|
|
140
|
+
While the MCP is running, `watchfiles` batches Obsidian or filesystem changes and refreshes affected notes. The watcher is only a latency optimization. Startup and stale-index reconciliation repair missed Windows-to-WSL events.
|
|
141
|
+
|
|
142
|
+
Writes use a flushed sibling temporary file followed by `os.replace`. `memory_read` returns the current checksum; passing it back as `expected_checksum` rejects an update if Obsidian changed the note after it was read. Deletes move Markdown to `.trash`; permanent deletion is not exposed.
|
|
143
|
+
|
|
144
|
+
## Recovery
|
|
145
|
+
|
|
146
|
+
1. Call `memory_status` to inspect watcher errors, the database path, and the last reconciliation.
|
|
147
|
+
2. Call `memory_sync` with `full: false` after bulk external changes, watcher failure, or a suspected mismatch.
|
|
148
|
+
3. Call `memory_doctor` to inspect invalid Markdown, duplicates, unresolved links, and divergence.
|
|
149
|
+
4. Call `memory_sync` with `full: true` to clear and rebuild the derived index.
|
|
150
|
+
|
|
151
|
+
The index is stored under `~/.local/share/keepm/indexes`, outside the Vault. Deleting it never deletes memories. A corrupt or incompatible database is preserved with a `.corrupt-<timestamp>` suffix and rebuilt from Markdown.
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
python -m pip install -e '.[dev]'
|
|
157
|
+
python -m pytest -q
|
|
158
|
+
python -m keepm.server --help
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The new MCP runtime does not import or migrate the legacy `.keepm-memory/` layout or bounded `MEMORY.md` pointer workflow automatically.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keepm"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Local Markdown memory MCP for coding agents"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fastmcp>=3,<4",
|
|
12
|
+
"PyYAML>=6,<7",
|
|
13
|
+
"questionary>=2,<3",
|
|
14
|
+
"tomlkit>=0.13,<1",
|
|
15
|
+
"watchfiles>=1,<2",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest>=8,<9", "pytest-asyncio>=0.24,<2"]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
keepm = "keepm.cli:main"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
pythonpath = ["src"]
|
|
29
|
+
asyncio_mode = "auto"
|
keepm-0.2.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
8
|
+
"""Dispatch the management CLI without contaminating MCP stdio mode."""
|
|
9
|
+
arguments = list(sys.argv[1:] if argv is None else argv)
|
|
10
|
+
if arguments[:1] == ["setup"]:
|
|
11
|
+
from keepm.setup import main as setup_main
|
|
12
|
+
|
|
13
|
+
setup_main(arguments[1:])
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
from keepm.server import main as server_main
|
|
17
|
+
|
|
18
|
+
server_main(arguments)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal, cast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MemoryLanguage = Literal["zh", "en"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def default_config_path() -> Path:
|
|
15
|
+
root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
16
|
+
return root / "keepm" / "config.toml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def default_data_root() -> Path:
|
|
20
|
+
root = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
21
|
+
return root / "keepm"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class KeepMConfig:
|
|
26
|
+
vault_path: Path
|
|
27
|
+
memory_dir: str = "Memory"
|
|
28
|
+
stale_after_seconds: int = 30
|
|
29
|
+
language: MemoryLanguage = "en"
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
memory_path = Path(self.memory_dir)
|
|
33
|
+
if (
|
|
34
|
+
not self.memory_dir.strip()
|
|
35
|
+
or memory_path.is_absolute()
|
|
36
|
+
or ".." in memory_path.parts
|
|
37
|
+
):
|
|
38
|
+
raise ValueError("memory_dir must be relative and remain inside the Vault")
|
|
39
|
+
if self.stale_after_seconds < 0:
|
|
40
|
+
raise ValueError("stale_after_seconds must be non-negative")
|
|
41
|
+
if self.language not in ("zh", "en"):
|
|
42
|
+
raise ValueError("language must be 'zh' or 'en'")
|
|
43
|
+
object.__setattr__(self, "vault_path", self.vault_path.expanduser().resolve())
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def memory_root(self) -> Path:
|
|
47
|
+
return self.vault_path / self.memory_dir
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def global_root(self) -> Path:
|
|
51
|
+
return self.memory_root / "global"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def projects_root(self) -> Path:
|
|
55
|
+
return self.memory_root / "projects"
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def trash_root(self) -> Path:
|
|
59
|
+
return self.memory_root / ".trash"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ConfigStore:
|
|
63
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
64
|
+
self.path = path or default_config_path()
|
|
65
|
+
|
|
66
|
+
def load(self) -> KeepMConfig:
|
|
67
|
+
if not self.path.is_file():
|
|
68
|
+
raise FileNotFoundError(f"KeepM is not configured: {self.path}")
|
|
69
|
+
data = tomllib.loads(self.path.read_text(encoding="utf-8"))
|
|
70
|
+
return KeepMConfig(
|
|
71
|
+
vault_path=Path(data["vault_path"]),
|
|
72
|
+
memory_dir=str(data.get("memory_dir", "Memory")),
|
|
73
|
+
stale_after_seconds=int(data.get("stale_after_seconds", 30)),
|
|
74
|
+
language=cast(MemoryLanguage, str(data.get("language", "en"))),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def load_optional(self) -> KeepMConfig | None:
|
|
78
|
+
try:
|
|
79
|
+
return self.load()
|
|
80
|
+
except FileNotFoundError:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def save(self, config: KeepMConfig) -> KeepMConfig:
|
|
84
|
+
config.vault_path.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
for path in (config.global_root, config.projects_root, config.trash_root):
|
|
86
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
content = (
|
|
89
|
+
f"vault_path = {json.dumps(str(config.vault_path))}\n"
|
|
90
|
+
f"memory_dir = {json.dumps(config.memory_dir)}\n"
|
|
91
|
+
f"stale_after_seconds = {config.stale_after_seconds}\n"
|
|
92
|
+
f"language = {json.dumps(config.language)}\n"
|
|
93
|
+
)
|
|
94
|
+
temporary = self.path.with_suffix(".tmp")
|
|
95
|
+
temporary.write_text(content, encoding="utf-8", newline="\n")
|
|
96
|
+
os.replace(temporary, self.path)
|
|
97
|
+
return config
|