gistfs 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.
- gistfs-0.1.0/.gitignore +32 -0
- gistfs-0.1.0/CLAUDE.md +38 -0
- gistfs-0.1.0/LICENSE +21 -0
- gistfs-0.1.0/Makefile +17 -0
- gistfs-0.1.0/PKG-INFO +142 -0
- gistfs-0.1.0/README.md +106 -0
- gistfs-0.1.0/gistfs/__init__.py +7 -0
- gistfs-0.1.0/gistfs/core.py +330 -0
- gistfs-0.1.0/gistfs/integrations/__init__.py +0 -0
- gistfs-0.1.0/gistfs/integrations/langgraph.py +229 -0
- gistfs-0.1.0/gistfs/integrations/llamaindex.py +115 -0
- gistfs-0.1.0/gistfs/memory.py +155 -0
- gistfs-0.1.0/pyproject.toml +48 -0
- gistfs-0.1.0/tests/conftest.py +47 -0
- gistfs-0.1.0/tests/test_core.py +121 -0
- gistfs-0.1.0/tests/test_gistfile.py +159 -0
- gistfs-0.1.0/tests/test_langgraph.py +100 -0
- gistfs-0.1.0/tests/test_llamaindex.py +61 -0
- gistfs-0.1.0/tests/test_memory.py +103 -0
- gistfs-0.1.0/uv.lock +2829 -0
gistfs-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.so
|
|
5
|
+
*.egg
|
|
6
|
+
*.egg-info/
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
*.whl
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
|
|
15
|
+
# Environment
|
|
16
|
+
.env
|
|
17
|
+
|
|
18
|
+
# IDE
|
|
19
|
+
.idea/
|
|
20
|
+
.vscode/
|
|
21
|
+
*.swp
|
|
22
|
+
|
|
23
|
+
# Testing
|
|
24
|
+
.pytest_cache/
|
|
25
|
+
.coverage
|
|
26
|
+
htmlcov/
|
|
27
|
+
|
|
28
|
+
# macOS
|
|
29
|
+
.DS_Store
|
|
30
|
+
|
|
31
|
+
# Project
|
|
32
|
+
junk/
|
gistfs-0.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project overview
|
|
6
|
+
|
|
7
|
+
gistfs is a Python library that uses GitHub Gists as a persistent key-value filesystem, designed for AI agent memory. It provides a layered architecture:
|
|
8
|
+
|
|
9
|
+
- **GistFS** (`gistfs/core.py`) — Low-level filesystem abstraction over a single gist. Handles CRUD on gist files via the GitHub API, with local caching and a context manager interface. Also provides `GistFile`, a `io.StringIO` subclass for file-like read/write/append.
|
|
10
|
+
- **GistMemory** (`gistfs/memory.py`) — Higher-level key-value store built on GistFS. Supports collections (each collection = one JSON file in the gist).
|
|
11
|
+
- **Integrations** (`gistfs/integrations/`) — Adapters that implement third-party store interfaces:
|
|
12
|
+
- `GistKVStore` (LlamaIndex `BaseKVStore`) — delegates to GistMemory
|
|
13
|
+
- `GistStore` (LangGraph `BaseStore`) — uses GistFS directly, maps namespace tuples to filenames (`("user", "prefs")` → `user__prefs.json`)
|
|
14
|
+
|
|
15
|
+
## Commands
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Install (uses uv, hatchling build backend)
|
|
19
|
+
uv pip install -e ".[dev,all]"
|
|
20
|
+
|
|
21
|
+
# Run all tests (requires GITHUB_TOKEN — tests hit the live GitHub API)
|
|
22
|
+
uv run pytest
|
|
23
|
+
|
|
24
|
+
# Run a single test file or test
|
|
25
|
+
uv run pytest tests/test_core.py
|
|
26
|
+
uv run pytest tests/test_core.py::test_write_and_read -v
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Testing
|
|
30
|
+
|
|
31
|
+
Tests are **live integration tests** — they create a real gist, run operations, then delete it. The `test_gist` session-scoped fixture in `tests/conftest.py` handles gist lifecycle. A `GITHUB_TOKEN` env var with `gist` scope is required; tests skip if it's missing. A `.env` file is loaded via `python-dotenv`.
|
|
32
|
+
|
|
33
|
+
## Key design decisions
|
|
34
|
+
|
|
35
|
+
- All gist file content is JSON-serialized. Non-JSON content is stored/returned as raw strings.
|
|
36
|
+
- GistFS uses an in-memory `_cache` dict synced from the API. The cache is populated on `sync()` / context manager entry and cleared on exit.
|
|
37
|
+
- Write operations (`write`, `delete`) hit the GitHub API immediately then update the local cache — there is no batching or deferred flush.
|
|
38
|
+
- `GistFile.close()` flushes writes to the gist; the context manager calls `close()` automatically.
|
gistfs-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hervé Mignot
|
|
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.
|
gistfs-0.1.0/Makefile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.PHONY: test build clean publish-test publish
|
|
2
|
+
|
|
3
|
+
test:
|
|
4
|
+
uv run pytest
|
|
5
|
+
|
|
6
|
+
build: clean
|
|
7
|
+
uv build
|
|
8
|
+
|
|
9
|
+
clean:
|
|
10
|
+
rm -rf dist/
|
|
11
|
+
|
|
12
|
+
publish-test: build
|
|
13
|
+
UV_PUBLISH_TOKEN=$(UV_PUBLISH_TOKEN_TEST)
|
|
14
|
+
uv publish --publish-url https://test.pypi.org/legacy/
|
|
15
|
+
|
|
16
|
+
publish: build
|
|
17
|
+
uv publish
|
gistfs-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gistfs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Use GitHub Gists as a persistent key-value filesystem — ideal for AI agent memory
|
|
5
|
+
Project-URL: Homepage, https://github.com/HerveMignot/gistfs
|
|
6
|
+
Project-URL: Repository, https://github.com/HerveMignot/gistfs
|
|
7
|
+
Project-URL: Issues, https://github.com/HerveMignot/gistfs/issues
|
|
8
|
+
Author: Hervé Mignot
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,ai,filesystem,gist,github,llm,memory
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: requests>=2.28
|
|
24
|
+
Provides-Extra: all
|
|
25
|
+
Requires-Dist: langgraph-checkpoint>=2.0; extra == 'all'
|
|
26
|
+
Requires-Dist: llama-index-core>=0.11; extra == 'all'
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest-asyncio>=1.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: python-dotenv>=1.0; extra == 'dev'
|
|
31
|
+
Provides-Extra: langgraph
|
|
32
|
+
Requires-Dist: langgraph-checkpoint>=2.0; extra == 'langgraph'
|
|
33
|
+
Provides-Extra: llamaindex
|
|
34
|
+
Requires-Dist: llama-index-core>=0.11; extra == 'llamaindex'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# gistfs
|
|
38
|
+
|
|
39
|
+
Use GitHub Gists as a persistent key-value filesystem — ideal for AI agent memory.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install gistfs
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
With optional integrations:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install gistfs[llamaindex] # LlamaIndex KVStore
|
|
51
|
+
pip install gistfs[langgraph] # LangGraph BaseStore
|
|
52
|
+
pip install gistfs[all] # everything
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quick start
|
|
56
|
+
|
|
57
|
+
### Creating a new gist
|
|
58
|
+
|
|
59
|
+
No need to create a gist manually — bootstrap one from code:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from gistfs import GistFS
|
|
63
|
+
|
|
64
|
+
gfs = GistFS.create(description="my agent memory")
|
|
65
|
+
print(gfs.gist_id) # save this for later
|
|
66
|
+
|
|
67
|
+
# Or with GistMemory:
|
|
68
|
+
from gistfs import GistMemory
|
|
69
|
+
mem = GistMemory.create(description="my agent memory")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### As a filesystem (context manager)
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from gistfs import GistFS
|
|
76
|
+
|
|
77
|
+
with GistFS(gist_id="your_gist_id") as gfs:
|
|
78
|
+
gfs.write("config.json", {"model": "gpt-4", "temperature": 0.7})
|
|
79
|
+
config = gfs.read("config.json")
|
|
80
|
+
print(gfs.list_files())
|
|
81
|
+
gfs.delete("config.json")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### File-like interface
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
with GistFS(gist_id="your_gist_id") as gfs:
|
|
88
|
+
with gfs.open("notes.txt", "w") as f:
|
|
89
|
+
f.write("hello world")
|
|
90
|
+
|
|
91
|
+
with gfs.open("notes.txt", "r") as f:
|
|
92
|
+
content = f.read()
|
|
93
|
+
|
|
94
|
+
with gfs.open("notes.txt", "a") as f:
|
|
95
|
+
f.write("\nappended line")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### As AI agent memory
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from gistfs import GistMemory
|
|
102
|
+
|
|
103
|
+
with GistMemory(gist_id="your_gist_id") as mem:
|
|
104
|
+
mem.put("conversation_1", {"messages": [{"role": "user", "content": "hi"}]})
|
|
105
|
+
history = mem.get("conversation_1")
|
|
106
|
+
all_data = mem.get_all()
|
|
107
|
+
mem.delete("conversation_1")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### LlamaIndex integration
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from gistfs.integrations.llamaindex import GistKVStore
|
|
114
|
+
|
|
115
|
+
store = GistKVStore(gist_id="your_gist_id")
|
|
116
|
+
store.put("doc1", {"text": "hello world"}, collection="docstore")
|
|
117
|
+
doc = store.get("doc1", collection="docstore")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### LangGraph integration
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from gistfs.integrations.langgraph import GistStore
|
|
124
|
+
|
|
125
|
+
store = GistStore(gist_id="your_gist_id")
|
|
126
|
+
store.put(("user", "prefs"), "theme", {"value": "dark"})
|
|
127
|
+
item = store.get(("user", "prefs"), "theme")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Authentication
|
|
131
|
+
|
|
132
|
+
Set the `GITHUB_TOKEN` environment variable with a GitHub personal access token that has `gist` scope. Read-only operations on public gists work without a token.
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
export GITHUB_TOKEN=ghp_your_token_here
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Or pass it directly:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
gfs = GistFS(gist_id="abc123", token="ghp_...")
|
|
142
|
+
```
|
gistfs-0.1.0/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# gistfs
|
|
2
|
+
|
|
3
|
+
Use GitHub Gists as a persistent key-value filesystem — ideal for AI agent memory.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install gistfs
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
With optional integrations:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install gistfs[llamaindex] # LlamaIndex KVStore
|
|
15
|
+
pip install gistfs[langgraph] # LangGraph BaseStore
|
|
16
|
+
pip install gistfs[all] # everything
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
### Creating a new gist
|
|
22
|
+
|
|
23
|
+
No need to create a gist manually — bootstrap one from code:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from gistfs import GistFS
|
|
27
|
+
|
|
28
|
+
gfs = GistFS.create(description="my agent memory")
|
|
29
|
+
print(gfs.gist_id) # save this for later
|
|
30
|
+
|
|
31
|
+
# Or with GistMemory:
|
|
32
|
+
from gistfs import GistMemory
|
|
33
|
+
mem = GistMemory.create(description="my agent memory")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### As a filesystem (context manager)
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from gistfs import GistFS
|
|
40
|
+
|
|
41
|
+
with GistFS(gist_id="your_gist_id") as gfs:
|
|
42
|
+
gfs.write("config.json", {"model": "gpt-4", "temperature": 0.7})
|
|
43
|
+
config = gfs.read("config.json")
|
|
44
|
+
print(gfs.list_files())
|
|
45
|
+
gfs.delete("config.json")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### File-like interface
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
with GistFS(gist_id="your_gist_id") as gfs:
|
|
52
|
+
with gfs.open("notes.txt", "w") as f:
|
|
53
|
+
f.write("hello world")
|
|
54
|
+
|
|
55
|
+
with gfs.open("notes.txt", "r") as f:
|
|
56
|
+
content = f.read()
|
|
57
|
+
|
|
58
|
+
with gfs.open("notes.txt", "a") as f:
|
|
59
|
+
f.write("\nappended line")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### As AI agent memory
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from gistfs import GistMemory
|
|
66
|
+
|
|
67
|
+
with GistMemory(gist_id="your_gist_id") as mem:
|
|
68
|
+
mem.put("conversation_1", {"messages": [{"role": "user", "content": "hi"}]})
|
|
69
|
+
history = mem.get("conversation_1")
|
|
70
|
+
all_data = mem.get_all()
|
|
71
|
+
mem.delete("conversation_1")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### LlamaIndex integration
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from gistfs.integrations.llamaindex import GistKVStore
|
|
78
|
+
|
|
79
|
+
store = GistKVStore(gist_id="your_gist_id")
|
|
80
|
+
store.put("doc1", {"text": "hello world"}, collection="docstore")
|
|
81
|
+
doc = store.get("doc1", collection="docstore")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### LangGraph integration
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from gistfs.integrations.langgraph import GistStore
|
|
88
|
+
|
|
89
|
+
store = GistStore(gist_id="your_gist_id")
|
|
90
|
+
store.put(("user", "prefs"), "theme", {"value": "dark"})
|
|
91
|
+
item = store.get(("user", "prefs"), "theme")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Authentication
|
|
95
|
+
|
|
96
|
+
Set the `GITHUB_TOKEN` environment variable with a GitHub personal access token that has `gist` scope. Read-only operations on public gists work without a token.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
export GITHUB_TOKEN=ghp_your_token_here
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Or pass it directly:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
gfs = GistFS(gist_id="abc123", token="ghp_...")
|
|
106
|
+
```
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Core GistFS class — use GitHub Gists as a persistent key-value filesystem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
GITHUB_API_GISTS = "https://api.github.com/gists/{gist_id}"
|
|
14
|
+
GITHUB_API_GISTS_BASE = "https://api.github.com/gists"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GistFS:
|
|
18
|
+
"""A filesystem-like interface backed by a single GitHub Gist.
|
|
19
|
+
|
|
20
|
+
Each "file" in the gist stores a JSON-serialized value, giving you a
|
|
21
|
+
persistent key-value store accessible from anywhere with an internet
|
|
22
|
+
connection.
|
|
23
|
+
|
|
24
|
+
Usage::
|
|
25
|
+
|
|
26
|
+
with GistFS(gist_id="abc123") as gfs:
|
|
27
|
+
gfs.write("state.json", {"counter": 1})
|
|
28
|
+
data = gfs.read("state.json")
|
|
29
|
+
print(gfs.list_files())
|
|
30
|
+
gfs.delete("state.json")
|
|
31
|
+
|
|
32
|
+
The token is read from the ``GITHUB_TOKEN`` environment variable by
|
|
33
|
+
default, or can be passed explicitly. Read-only operations on public
|
|
34
|
+
gists work without a token.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
gist_id: str,
|
|
40
|
+
token: str | None = None,
|
|
41
|
+
*,
|
|
42
|
+
auto_sync: bool = True,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.gist_id = gist_id
|
|
45
|
+
self.token = token or os.environ.get("GITHUB_TOKEN", "")
|
|
46
|
+
self.auto_sync = auto_sync
|
|
47
|
+
self._url = GITHUB_API_GISTS.format(gist_id=gist_id)
|
|
48
|
+
self._cache: dict[str, Any] | None = None
|
|
49
|
+
|
|
50
|
+
# ── factory ───────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def create(
|
|
54
|
+
cls,
|
|
55
|
+
description: str = "",
|
|
56
|
+
*,
|
|
57
|
+
public: bool = False,
|
|
58
|
+
token: str | None = None,
|
|
59
|
+
init_files: dict[str, Any] | None = None,
|
|
60
|
+
) -> GistFS:
|
|
61
|
+
"""Create a new GitHub Gist and return a :class:`GistFS` bound to it.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
description:
|
|
66
|
+
Gist description shown on GitHub.
|
|
67
|
+
public:
|
|
68
|
+
If ``True`` the gist is public; otherwise it is secret (default).
|
|
69
|
+
token:
|
|
70
|
+
GitHub personal access token. Falls back to ``GITHUB_TOKEN``.
|
|
71
|
+
init_files:
|
|
72
|
+
Optional mapping of ``{filename: content}`` to seed the gist with.
|
|
73
|
+
If *None*, a single placeholder file ``_gistfs.json`` is created.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
GistFS
|
|
78
|
+
A new instance already synced with the freshly created gist.
|
|
79
|
+
"""
|
|
80
|
+
resolved_token = token or os.environ.get("GITHUB_TOKEN", "")
|
|
81
|
+
if not resolved_token:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"A GitHub token is required to create a gist. "
|
|
84
|
+
"Set GITHUB_TOKEN or pass token=."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if init_files:
|
|
88
|
+
files_payload = {
|
|
89
|
+
fname: {"content": json.dumps(data, ensure_ascii=False, default=str)}
|
|
90
|
+
for fname, data in init_files.items()
|
|
91
|
+
}
|
|
92
|
+
else:
|
|
93
|
+
files_payload = {"_gistfs.json": {"content": "{}"}}
|
|
94
|
+
|
|
95
|
+
payload: dict[str, Any] = {
|
|
96
|
+
"description": description,
|
|
97
|
+
"public": public,
|
|
98
|
+
"files": files_payload,
|
|
99
|
+
}
|
|
100
|
+
headers = {
|
|
101
|
+
"Accept": "application/vnd.github+json",
|
|
102
|
+
"Authorization": f"Bearer {resolved_token}",
|
|
103
|
+
}
|
|
104
|
+
resp = requests.post(GITHUB_API_GISTS_BASE, headers=headers, json=payload)
|
|
105
|
+
resp.raise_for_status()
|
|
106
|
+
|
|
107
|
+
gist_id = resp.json()["id"]
|
|
108
|
+
instance = cls(gist_id, token=resolved_token)
|
|
109
|
+
instance.sync()
|
|
110
|
+
return instance
|
|
111
|
+
|
|
112
|
+
# ── context manager ──────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def __enter__(self) -> GistFS:
|
|
115
|
+
self.sync()
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # noqa: ANN001
|
|
119
|
+
self._cache = None
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
# ── public API ───────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def sync(self) -> None:
|
|
125
|
+
"""Fetch the latest gist state from GitHub."""
|
|
126
|
+
resp = self._get_request()
|
|
127
|
+
resp.raise_for_status()
|
|
128
|
+
gist = resp.json()
|
|
129
|
+
self._cache = {}
|
|
130
|
+
for fname, fdata in gist.get("files", {}).items():
|
|
131
|
+
try:
|
|
132
|
+
self._cache[fname] = json.loads(fdata["content"])
|
|
133
|
+
except (json.JSONDecodeError, TypeError):
|
|
134
|
+
self._cache[fname] = fdata["content"]
|
|
135
|
+
|
|
136
|
+
def read(self, filename: str) -> Any:
|
|
137
|
+
"""Read and return the parsed content of *filename*."""
|
|
138
|
+
self._ensure_synced()
|
|
139
|
+
assert self._cache is not None
|
|
140
|
+
if filename not in self._cache:
|
|
141
|
+
raise FileNotFoundError(f"{filename!r} not found in gist {self.gist_id}")
|
|
142
|
+
return self._cache[filename]
|
|
143
|
+
|
|
144
|
+
def write(self, filename: str, data: Any) -> None:
|
|
145
|
+
"""Write *data* (JSON-serializable) to *filename* in the gist."""
|
|
146
|
+
content = json.dumps(data, ensure_ascii=False, default=str)
|
|
147
|
+
payload = {"files": {filename: {"content": content}}}
|
|
148
|
+
resp = self._patch_request(payload)
|
|
149
|
+
resp.raise_for_status()
|
|
150
|
+
# Update local cache
|
|
151
|
+
self._ensure_synced()
|
|
152
|
+
assert self._cache is not None
|
|
153
|
+
self._cache[filename] = data
|
|
154
|
+
|
|
155
|
+
def delete(self, filename: str) -> bool:
|
|
156
|
+
"""Delete *filename* from the gist. Returns True if it existed."""
|
|
157
|
+
self._ensure_synced()
|
|
158
|
+
assert self._cache is not None
|
|
159
|
+
if filename not in self._cache:
|
|
160
|
+
return False
|
|
161
|
+
# GitHub API: setting content to empty string with null deletes the file
|
|
162
|
+
payload = {"files": {filename: None}}
|
|
163
|
+
resp = self._patch_request(payload)
|
|
164
|
+
resp.raise_for_status()
|
|
165
|
+
self._cache.pop(filename, None)
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
def list_files(self) -> list[str]:
|
|
169
|
+
"""Return a list of filenames in the gist."""
|
|
170
|
+
self._ensure_synced()
|
|
171
|
+
assert self._cache is not None
|
|
172
|
+
return list(self._cache.keys())
|
|
173
|
+
|
|
174
|
+
def exists(self, filename: str) -> bool:
|
|
175
|
+
"""Check whether *filename* exists in the gist."""
|
|
176
|
+
self._ensure_synced()
|
|
177
|
+
assert self._cache is not None
|
|
178
|
+
return filename in self._cache
|
|
179
|
+
|
|
180
|
+
def read_all(self) -> dict[str, Any]:
|
|
181
|
+
"""Return all files as a ``{filename: content}`` dict."""
|
|
182
|
+
self._ensure_synced()
|
|
183
|
+
assert self._cache is not None
|
|
184
|
+
return dict(self._cache)
|
|
185
|
+
|
|
186
|
+
def open(self, filename: str, mode: str = "r") -> GistFile:
|
|
187
|
+
"""Open a file in the gist, returning a file-like object.
|
|
188
|
+
|
|
189
|
+
Supported modes: ``"r"`` (read), ``"w"`` (write), ``"a"`` (append),
|
|
190
|
+
``"r+"`` (read and write).
|
|
191
|
+
|
|
192
|
+
Usage::
|
|
193
|
+
|
|
194
|
+
with gfs.open("notes.txt", "w") as f:
|
|
195
|
+
f.write("hello world")
|
|
196
|
+
|
|
197
|
+
with gfs.open("notes.txt", "r") as f:
|
|
198
|
+
content = f.read()
|
|
199
|
+
"""
|
|
200
|
+
if mode not in ("r", "w", "a", "r+"):
|
|
201
|
+
raise ValueError(f"Unsupported mode {mode!r} — use 'r', 'w', 'a', or 'r+'")
|
|
202
|
+
return GistFile(self, filename, mode)
|
|
203
|
+
|
|
204
|
+
# ── helpers ──────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
def _ensure_synced(self) -> None:
|
|
207
|
+
if self._cache is None:
|
|
208
|
+
if self.auto_sync:
|
|
209
|
+
self.sync()
|
|
210
|
+
else:
|
|
211
|
+
raise RuntimeError("GistFS not synced — call .sync() first")
|
|
212
|
+
|
|
213
|
+
def _headers(self, *, auth: bool = True) -> dict[str, str]:
|
|
214
|
+
headers = {"Accept": "application/vnd.github+json"}
|
|
215
|
+
if auth and self.token:
|
|
216
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
217
|
+
return headers
|
|
218
|
+
|
|
219
|
+
def _get_request(self) -> requests.Response:
|
|
220
|
+
return requests.get(self._url, headers=self._headers(auth=bool(self.token)))
|
|
221
|
+
|
|
222
|
+
def _patch_request(self, payload: dict) -> requests.Response:
|
|
223
|
+
if not self.token:
|
|
224
|
+
raise ValueError(
|
|
225
|
+
"A GitHub token is required for write operations. "
|
|
226
|
+
"Set GITHUB_TOKEN or pass token= to GistFS()."
|
|
227
|
+
)
|
|
228
|
+
return requests.patch(self._url, headers=self._headers(), json=payload)
|
|
229
|
+
|
|
230
|
+
def __repr__(self) -> str:
|
|
231
|
+
return f"GistFS(gist_id={self.gist_id!r})"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class GistFile(io.StringIO):
|
|
235
|
+
"""File-like object for a single file in a gist.
|
|
236
|
+
|
|
237
|
+
Wraps :class:`io.StringIO` so that standard file operations
|
|
238
|
+
(``read``, ``write``, ``seek``, ``tell``, iteration) work as expected.
|
|
239
|
+
On :meth:`close` (or exiting the context manager), any writes are
|
|
240
|
+
flushed back to the gist.
|
|
241
|
+
|
|
242
|
+
Do not instantiate directly — use :meth:`GistFS.open` instead.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(self, gfs: GistFS, filename: str, mode: str) -> None:
|
|
246
|
+
self._gfs = gfs
|
|
247
|
+
self._filename = filename
|
|
248
|
+
self._mode = mode
|
|
249
|
+
self._writable = mode in ("w", "a", "r+")
|
|
250
|
+
|
|
251
|
+
# Seed the buffer with existing content for read / append / r+
|
|
252
|
+
initial = ""
|
|
253
|
+
if mode in ("r", "a", "r+"):
|
|
254
|
+
try:
|
|
255
|
+
content = gfs.read(filename)
|
|
256
|
+
if isinstance(content, str):
|
|
257
|
+
initial = content
|
|
258
|
+
else:
|
|
259
|
+
initial = json.dumps(content, ensure_ascii=False, default=str)
|
|
260
|
+
except FileNotFoundError:
|
|
261
|
+
if mode == "r":
|
|
262
|
+
raise
|
|
263
|
+
# "a" and "r+" start empty if file doesn't exist
|
|
264
|
+
|
|
265
|
+
super().__init__(initial)
|
|
266
|
+
|
|
267
|
+
# Position cursor at end for append, at start for read/r+
|
|
268
|
+
if mode == "a":
|
|
269
|
+
self.seek(0, io.SEEK_END)
|
|
270
|
+
else:
|
|
271
|
+
self.seek(0)
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def name(self) -> str:
|
|
275
|
+
return self._filename
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def mode(self) -> str:
|
|
279
|
+
return self._mode
|
|
280
|
+
|
|
281
|
+
def writable(self) -> bool:
|
|
282
|
+
return self._writable
|
|
283
|
+
|
|
284
|
+
def readable(self) -> bool:
|
|
285
|
+
return self._mode in ("r", "r+")
|
|
286
|
+
|
|
287
|
+
def write(self, s: str) -> int:
|
|
288
|
+
if not self._writable:
|
|
289
|
+
raise io.UnsupportedOperation("not writable")
|
|
290
|
+
return super().write(s)
|
|
291
|
+
|
|
292
|
+
def read(self, size: int = -1) -> str:
|
|
293
|
+
if self._mode == "w":
|
|
294
|
+
raise io.UnsupportedOperation("not readable")
|
|
295
|
+
return super().read(size)
|
|
296
|
+
|
|
297
|
+
def readline(self, size: int = -1) -> str:
|
|
298
|
+
if self._mode == "w":
|
|
299
|
+
raise io.UnsupportedOperation("not readable")
|
|
300
|
+
return super().readline(size)
|
|
301
|
+
|
|
302
|
+
def readlines(self, hint: int = -1) -> list[str]:
|
|
303
|
+
if self._mode == "w":
|
|
304
|
+
raise io.UnsupportedOperation("not readable")
|
|
305
|
+
return super().readlines(hint)
|
|
306
|
+
|
|
307
|
+
def close(self) -> None:
|
|
308
|
+
if not self.closed and self._writable:
|
|
309
|
+
self._flush_to_gist()
|
|
310
|
+
super().close()
|
|
311
|
+
|
|
312
|
+
def _flush_to_gist(self) -> None:
|
|
313
|
+
"""Push the buffer content to the gist as a raw string."""
|
|
314
|
+
content = self.getvalue()
|
|
315
|
+
if not content:
|
|
316
|
+
return
|
|
317
|
+
payload = {"files": {self._filename: {"content": content}}}
|
|
318
|
+
resp = self._gfs._patch_request(payload)
|
|
319
|
+
resp.raise_for_status()
|
|
320
|
+
# Update cache
|
|
321
|
+
self._gfs._ensure_synced()
|
|
322
|
+
assert self._gfs._cache is not None
|
|
323
|
+
try:
|
|
324
|
+
self._gfs._cache[self._filename] = json.loads(content)
|
|
325
|
+
except (json.JSONDecodeError, ValueError):
|
|
326
|
+
self._gfs._cache[self._filename] = content
|
|
327
|
+
|
|
328
|
+
def __repr__(self) -> str:
|
|
329
|
+
state = "closed" if self.closed else self._mode
|
|
330
|
+
return f"GistFile({self._filename!r}, {state!r})"
|
|
File without changes
|