mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b8cefcd45 | |||
| 57984aa912 | |||
| f5a7541da1 | |||
| bc9ca0744f | |||
| 2c8ed1737d | |||
| 02f8e86692 | |||
| 0123544556 | |||
| 00d23a5ee1 | |||
| 812136c8c2 | |||
| 3e8e3e8961 | |||
| 8544bb7966 | |||
| 66b57e682f | |||
| 58a1296111 | |||
| 6da143898b | |||
| a6b46908b1 | |||
| 0689e7a730 | |||
| 16466e9269 | |||
| 39bd5ca08f | |||
| 4a1f5452c4 | |||
| 9c9960e2ca | |||
| 30cd74ec95 | |||
| 132c5671d8 | |||
| 71de8acfd0 | |||
| 50469d691b | |||
| 85e620c72b | |||
| 493e0902b6 | |||
| 56f47d6812 | |||
| a15c346d5e | |||
| c06cf19e62 | |||
| 41f5b1667b | |||
| ca632beb6f | |||
| a491c2b7d4 | |||
| 674dd1fd47 | |||
| a91da13967 | |||
| e0803734e6 | |||
| 8a5a970ba9 | |||
| 65ebe5d194 | |||
| 8370a06b6c | |||
| 3270e6d82b | |||
| c05d363a99 | |||
| 3f9c68ed85 | |||
| a8ffe051ea | |||
| 2c74434e99 | |||
| bea3d6c889 | |||
| 14539010b1 | |||
| a19287c967 | |||
| 64430b850e | |||
| 10b2e91e34 | |||
| 1989db15fa | |||
| 17f0517917 | |||
| 8de84c0221 | |||
| b1d2a64933 | |||
| fe8c3d87b0 | |||
| 89ee324df0 | |||
| 8664c57bb3 | |||
| 8117a7b0ed | |||
| f47fb2aeef | |||
| 8fa197e2ec | |||
| 58f3fe95b7 | |||
| 1d6054d30a |
@@ -57,10 +57,14 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.release.outputs.tag }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
- name: Set up Python "3.12"
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: "3.12"
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install librsvg
|
||||
run: brew install librsvg
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
@@ -72,41 +76,17 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e .[dev]
|
||||
|
||||
- name: Install librsvg
|
||||
run: brew install librsvg
|
||||
|
||||
- name: Create icon
|
||||
run: |
|
||||
cd installer
|
||||
chmod +x make_icons.sh
|
||||
./make_icons.sh
|
||||
uv sync
|
||||
|
||||
- name: Build macOS installer
|
||||
run: |
|
||||
# Debug info
|
||||
echo "Environment Info:"
|
||||
uv pip list
|
||||
python --version
|
||||
which uv
|
||||
which python
|
||||
ls -la $(dirname $(which python))
|
||||
echo "PYTHONPATH=$PYTHONPATH"
|
||||
|
||||
# Build
|
||||
make installer-mac
|
||||
xattr -dr com.apple.quarantine "installer/build/Basic Memory Installer.app"
|
||||
|
||||
- name: Inspect build
|
||||
run: |
|
||||
echo "Build structure:"
|
||||
ls -R installer/build/Basic\ Memory\ Installer.app
|
||||
|
||||
- name: Zip macOS installer
|
||||
run: |
|
||||
cd installer/build
|
||||
zip -r "Basic-Memory-Installer-${{ needs.release.outputs.tag }}.zip" "Basic Memory Installer.app"
|
||||
zip -ry "Basic-Memory-Installer-${{ needs.release.outputs.tag }}.zip" "Basic Memory Installer.app"
|
||||
|
||||
- name: Upload macOS installer
|
||||
uses: softprops/action-gh-release@v1
|
||||
|
||||
@@ -42,3 +42,4 @@ ENV/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
/.coverage.*
|
||||
|
||||
+172
@@ -1,6 +1,178 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v0.7.0 (2025-02-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add logfire instrumentation to tools
|
||||
([`3e8e3e8`](https://github.com/basicmachines-co/basic-memory/commit/3e8e3e8961eae2e82839746e28963191b0aef0a0))
|
||||
|
||||
- Add logfire spans to cli
|
||||
([`00d23a5`](https://github.com/basicmachines-co/basic-memory/commit/00d23a5ee15ddac4ea45e702dcd02ab9f0509276))
|
||||
|
||||
- Add logfire spans to cli
|
||||
([`812136c`](https://github.com/basicmachines-co/basic-memory/commit/812136c8c22ad191d14ff32dcad91aae076d4120))
|
||||
|
||||
- Search query pagination params
|
||||
([`bc9ca07`](https://github.com/basicmachines-co/basic-memory/commit/bc9ca0744ffe4296d7d597b4dd9b7c73c2d63f3f))
|
||||
|
||||
### Chores
|
||||
|
||||
- Fix tests
|
||||
([`57984aa`](https://github.com/basicmachines-co/basic-memory/commit/57984aa912625dcde7877afb96d874c164af2896))
|
||||
|
||||
- Remove unused tests
|
||||
([`2c8ed17`](https://github.com/basicmachines-co/basic-memory/commit/2c8ed1737d6769fe1ef5c96f8a2bd75b9899316a))
|
||||
|
||||
### Features
|
||||
|
||||
- Add cli commands for mcp tools
|
||||
([`f5a7541`](https://github.com/basicmachines-co/basic-memory/commit/f5a7541da17e97403b7a702720a05710f68b223a))
|
||||
|
||||
- Add pagination to build_context and recent_activity
|
||||
([`0123544`](https://github.com/basicmachines-co/basic-memory/commit/0123544556513af943d399d70b849b142b834b15))
|
||||
|
||||
- Add pagination to read_notes
|
||||
([`02f8e86`](https://github.com/basicmachines-co/basic-memory/commit/02f8e866923d5793d2620076c709c920d99f2c4f))
|
||||
|
||||
|
||||
## v0.6.0 (2025-02-18)
|
||||
|
||||
### Chores
|
||||
|
||||
- Re-add sync status console on watch
|
||||
([`66b57e6`](https://github.com/basicmachines-co/basic-memory/commit/66b57e682f2e9c432bffd4af293b0d1db1d3469b))
|
||||
|
||||
### Features
|
||||
|
||||
- Configure logfire telemetry ([#12](https://github.com/basicmachines-co/basic-memory/pull/12),
|
||||
[`6da1438`](https://github.com/basicmachines-co/basic-memory/commit/6da143898bd45cdab8db95b5f2b75810fbb741ba))
|
||||
|
||||
Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
|
||||
## v0.5.0 (2025-02-18)
|
||||
|
||||
### Features
|
||||
|
||||
- Return semantic info in markdown after write_note
|
||||
([#11](https://github.com/basicmachines-co/basic-memory/pull/11),
|
||||
[`0689e7a`](https://github.com/basicmachines-co/basic-memory/commit/0689e7a730497827bf4e16156ae402ddc5949077))
|
||||
|
||||
Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
|
||||
## v0.4.3 (2025-02-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Re do enhanced read note format ([#10](https://github.com/basicmachines-co/basic-memory/pull/10),
|
||||
[`39bd5ca`](https://github.com/basicmachines-co/basic-memory/commit/39bd5ca08fd057220b95a8b5d82c5e73a1f5722b))
|
||||
|
||||
Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
|
||||
## v0.4.2 (2025-02-17)
|
||||
|
||||
|
||||
## v0.4.1 (2025-02-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix alemic config
|
||||
([`71de8ac`](https://github.com/basicmachines-co/basic-memory/commit/71de8acfd0902fc60f27deb3638236a3875787ab))
|
||||
|
||||
- More alembic fixes
|
||||
([`30cd74e`](https://github.com/basicmachines-co/basic-memory/commit/30cd74ec95c04eaa92b41b9815431f5fbdb46ef8))
|
||||
|
||||
|
||||
## v0.4.0 (2025-02-16)
|
||||
|
||||
### Features
|
||||
|
||||
- Import chatgpt conversation data ([#9](https://github.com/basicmachines-co/basic-memory/pull/9),
|
||||
[`56f47d6`](https://github.com/basicmachines-co/basic-memory/commit/56f47d6812982437f207629e6ac9a82e0e56514e))
|
||||
|
||||
Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
- Import claude.ai data ([#8](https://github.com/basicmachines-co/basic-memory/pull/8),
|
||||
[`a15c346`](https://github.com/basicmachines-co/basic-memory/commit/a15c346d5ebd44344b76bad877bb4d1073fcbc3b))
|
||||
|
||||
Import Claude.ai conversation and project data to basic-memory Markdown format.
|
||||
|
||||
---------
|
||||
|
||||
Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
|
||||
## v0.3.0 (2025-02-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Refactor db schema migrate handling
|
||||
([`ca632be`](https://github.com/basicmachines-co/basic-memory/commit/ca632beb6fed5881f4d8ba5ce698bb5bc681e6aa))
|
||||
|
||||
|
||||
## v0.2.21 (2025-02-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix osx installer github action
|
||||
([`65ebe5d`](https://github.com/basicmachines-co/basic-memory/commit/65ebe5d19491e5ff047c459d799498ad5dd9cd1a))
|
||||
|
||||
- Handle memory:// url format in read_note tool
|
||||
([`e080373`](https://github.com/basicmachines-co/basic-memory/commit/e0803734e69eeb6c6d7432eea323c7a264cb8347))
|
||||
|
||||
- Remove create schema from init_db
|
||||
([`674dd1f`](https://github.com/basicmachines-co/basic-memory/commit/674dd1fd47be9e60ac17508476c62254991df288))
|
||||
|
||||
### Features
|
||||
|
||||
- Set version in var, output version at startup
|
||||
([`a91da13`](https://github.com/basicmachines-co/basic-memory/commit/a91da1396710e62587df1284da00137d156fc05e))
|
||||
|
||||
|
||||
## v0.2.20 (2025-02-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix installer artifact
|
||||
([`8de84c0`](https://github.com/basicmachines-co/basic-memory/commit/8de84c0221a1ee32780aa84dac4d3ea60895e05c))
|
||||
|
||||
|
||||
## v0.2.19 (2025-02-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Get app artifact for installer
|
||||
([`fe8c3d8`](https://github.com/basicmachines-co/basic-memory/commit/fe8c3d87b003166252290a87cbe958301cccf797))
|
||||
|
||||
|
||||
## v0.2.18 (2025-02-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Don't zip app on release
|
||||
([`8664c57`](https://github.com/basicmachines-co/basic-memory/commit/8664c57bb331d7f3f7e0239acb5386c7a3c6144e))
|
||||
|
||||
|
||||
## v0.2.17 (2025-02-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix app zip in installer release
|
||||
([`8fa197e`](https://github.com/basicmachines-co/basic-memory/commit/8fa197e2ec8a1b6caaf6dbb39c3c6626bba23e2e))
|
||||
|
||||
|
||||
## v0.2.16 (2025-02-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Debug inspect build on ci
|
||||
([`1d6054d`](https://github.com/basicmachines-co/basic-memory/commit/1d6054d30a477a4e6a5d6ac885632e50c01945d3))
|
||||
|
||||
|
||||
## v0.2.15 (2025-02-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
.PHONY: install test lint clean format type-check installer-mac installer-win
|
||||
.PHONY: install test lint clean format type-check installer-mac installer-win check
|
||||
|
||||
install:
|
||||
pip install -e ".[dev]"
|
||||
|
||||
test:
|
||||
pytest -p pytest_mock -v
|
||||
uv run pytest -p pytest_mock -v
|
||||
|
||||
lint:
|
||||
ruff check . --fix
|
||||
@@ -32,6 +32,7 @@ run-dev:
|
||||
|
||||
# Build app installer
|
||||
installer-mac:
|
||||
cd installer && chmod +x make_icons.sh && ./make_icons.sh
|
||||
cd installer && uv run python setup.py bdist_mac
|
||||
|
||||
installer-win:
|
||||
@@ -40,3 +41,5 @@ installer-win:
|
||||
|
||||
update-deps:
|
||||
uv lock f--upgrade
|
||||
|
||||
check: lint format type-check test
|
||||
@@ -253,6 +253,92 @@ Basic Memory is built on some key ideas:
|
||||
- Simple text patterns can capture rich meaning
|
||||
- Local-first doesn't mean feature-poor
|
||||
|
||||
## Importing data
|
||||
|
||||
Basic memory has cli commands to import data from several formats into Markdown files
|
||||
|
||||
### Claude.ai
|
||||
|
||||
First, request an export of your data from your Claude account. The data will be emailed to you in several files,
|
||||
including
|
||||
`conversations.json` and `projects.json`.
|
||||
|
||||
Import Claude.ai conversation data
|
||||
|
||||
```bash
|
||||
basic-memory import claude conversations
|
||||
```
|
||||
|
||||
The conversations will be turned into Markdown files and placed in the "conversations" folder by default (this can be
|
||||
changed with the --folder arg).
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
Importing chats from conversations.json...writing to .../basic-memory
|
||||
Reading chat data... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%
|
||||
╭────────────────────────────╮
|
||||
│ Import complete! │
|
||||
│ │
|
||||
│ Imported 307 conversations │
|
||||
│ Containing 7769 messages │
|
||||
╰────────────────────────────╯
|
||||
```
|
||||
|
||||
Next, you can run the `sync` command to import the data into basic-memory
|
||||
|
||||
```bash
|
||||
basic-memory sync
|
||||
```
|
||||
|
||||
You can also import project data from Claude.ai
|
||||
|
||||
```bash
|
||||
➜ basic-memory import claude projects
|
||||
Importing projects from projects.json...writing to .../basic-memory/projects
|
||||
Reading project data... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%
|
||||
╭────────────────────────────────╮
|
||||
│ Import complete! │
|
||||
│ │
|
||||
│ Imported 101 project documents │
|
||||
│ Imported 32 prompt templates │
|
||||
╰────────────────────────────────╯
|
||||
|
||||
Run 'basic-memory sync' to index the new files.
|
||||
```
|
||||
|
||||
### Chat Gpt
|
||||
|
||||
```bash
|
||||
➜ basic-memory import chatgpt
|
||||
Importing chats from conversations.json...writing to .../basic-memory/conversations
|
||||
|
||||
Reading chat data... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%
|
||||
╭────────────────────────────╮
|
||||
│ Import complete! │
|
||||
│ │
|
||||
│ Imported 198 conversations │
|
||||
│ Containing 11777 messages │
|
||||
╰────────────────────────────╯
|
||||
|
||||
|
||||
```
|
||||
|
||||
### Memory json
|
||||
|
||||
```bash
|
||||
➜ basic-memory import memory-json
|
||||
Importing from memory.json...writing to .../basic-memory
|
||||
Reading memory.json... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%
|
||||
Creating entities... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%
|
||||
╭──────────────────────╮
|
||||
│ Import complete! │
|
||||
│ │
|
||||
│ Created 126 entities │
|
||||
│ Added 252 relations │
|
||||
╰──────────────────────╯
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
# Use forward slashes (/) also on windows to provide an os agnostic path
|
||||
script_location = src/basic_memory/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to migrations/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
# version_path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
version_path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
+15
-5
@@ -7,10 +7,20 @@ This installer configures Basic Memory to work with Claude Desktop.
|
||||
1. Download the latest installer from the [releases page](https://github.com/basicmachines-co/basic-memory/releases)
|
||||
2. Unzip the downloaded file
|
||||
3. Since the app is currently unsigned, you'll need to:
|
||||
- Right-click (or Control-click) the app
|
||||
- Select "Open" from the context menu
|
||||
- Click "Open" in the warning dialog
|
||||
4. Follow the installation instructions
|
||||
|
||||
On your Mac, choose Apple menu > System Settings, then click Privacy & Security in the sidebar. (You may need to
|
||||
scroll down.)
|
||||
|
||||
Go to Security, then click Open.
|
||||
|
||||
Click Open Anyway.
|
||||
|
||||
This button is available for about an hour after you try to open the app.
|
||||
|
||||
Enter your login password, then click OK.
|
||||
|
||||
https://support.apple.com/guide/mac-help/apple-cant-check-app-for-malicious-software-mchleab3a043/mac
|
||||
|
||||
5. Restart Claude Desktop
|
||||
|
||||
The warning only appears the first time you open the app. Future updates will include proper code signing.
|
||||
The warning only appears the first time you open the app. Future updates will include proper code signing.
|
||||
|
||||
@@ -8,6 +8,7 @@ if sys.platform == "darwin":
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
|
||||
def ensure_uv_installed():
|
||||
"""Check if uv is installed, install if not."""
|
||||
try:
|
||||
@@ -48,7 +49,14 @@ def update_claude_config():
|
||||
config = {"mcpServers": {}}
|
||||
|
||||
# Add/update basic-memory config
|
||||
config["mcpServers"]["basic-memory"] = {"command": "uvx", "args": ["basic-memory", "mcp"]}
|
||||
config["mcpServers"]["basic-memory"] = {
|
||||
"command": "uvx",
|
||||
"args": ["basic-memory@latest", "mcp"],
|
||||
"env": {
|
||||
"BASIC_MEMORY_ENV": "user",
|
||||
"LOGFIRE_TOKEN": "n2Fpvn34LjKYq8TdF1ZrXMgdBPXGn4HfXy6tYghZ55dB",
|
||||
},
|
||||
}
|
||||
|
||||
# Write back config
|
||||
config_path.write_text(json.dumps(config, indent=2))
|
||||
|
||||
+11
-13
@@ -4,27 +4,24 @@ import sys
|
||||
# Build options for all platforms
|
||||
build_exe_options = {
|
||||
"packages": ["json", "pathlib"],
|
||||
"excludes": [],
|
||||
"excludes": ["unittest", "pydoc", "test"],
|
||||
}
|
||||
|
||||
# Platform-specific options
|
||||
if sys.platform == "win32":
|
||||
base = "Win32GUI" # Use GUI base for Windows
|
||||
build_exe_options.update({
|
||||
"include_msvcr": True,
|
||||
})
|
||||
build_exe_options.update(
|
||||
{
|
||||
"include_msvcr": True,
|
||||
}
|
||||
)
|
||||
target_name = "Basic Memory Installer.exe"
|
||||
else: # darwin
|
||||
base = None # Don't use GUI base for macOS
|
||||
target_name = "Basic Memory Installer"
|
||||
|
||||
executables = [
|
||||
Executable(
|
||||
script="installer.py",
|
||||
target_name=target_name,
|
||||
base=base,
|
||||
icon="Basic.icns"
|
||||
)
|
||||
Executable(script="installer.py", target_name=target_name, base=base, icon="Basic.icns")
|
||||
]
|
||||
|
||||
setup(
|
||||
@@ -35,8 +32,9 @@ setup(
|
||||
"build_exe": build_exe_options,
|
||||
"bdist_mac": {
|
||||
"bundle_name": "Basic Memory Installer",
|
||||
"iconfile": "Basic.icns"
|
||||
}
|
||||
"iconfile": "Basic.icns",
|
||||
"codesign_identity": "-", # Force ad-hoc signing
|
||||
},
|
||||
},
|
||||
executables=executables,
|
||||
)
|
||||
)
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "basic-memory"
|
||||
version = "0.2.15"
|
||||
version = "0.7.0"
|
||||
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12.1"
|
||||
@@ -29,6 +29,7 @@ dependencies = [
|
||||
"fastapi[standard]>=0.115.8",
|
||||
"alembic>=1.14.1",
|
||||
"qasync>=0.27.1",
|
||||
"logfire[fastapi,httpx,sqlalchemy,sqlite3]>=3.6.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -84,7 +85,9 @@ pythonVersion = "3.12"
|
||||
|
||||
|
||||
[tool.semantic_release]
|
||||
version_variable = "src/basic_memory/__init__.py:__version__"
|
||||
version_variables = [
|
||||
"src/basic_memory/__init__.py:__version__",
|
||||
]
|
||||
version_toml = [
|
||||
"pyproject.toml:project.version",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
__version__ = "0.0.1"
|
||||
__version__ = "0.7.0"
|
||||
|
||||
+10
-24
@@ -2,42 +2,24 @@
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import logfire
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.exception_handlers import http_exception_handler
|
||||
from loguru import logger
|
||||
|
||||
import basic_memory
|
||||
from basic_memory import db
|
||||
from basic_memory.config import config as app_config
|
||||
from basic_memory.api.routers import knowledge, search, memory, resource
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
from basic_memory.db import DatabaseType
|
||||
from basic_memory.repository.search_repository import SearchRepository
|
||||
|
||||
|
||||
async def run_migrations(): # pragma: no cover
|
||||
"""Run any pending alembic migrations."""
|
||||
logger.info("Running database migrations...")
|
||||
try:
|
||||
config = Config("alembic.ini")
|
||||
command.upgrade(config, "head")
|
||||
logger.info("Migrations completed successfully")
|
||||
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
app_config.database_path, DatabaseType.FILESYSTEM
|
||||
)
|
||||
await SearchRepository(session_maker).init_search_index()
|
||||
except Exception as e:
|
||||
logger.error(f"Error running migrations: {e}")
|
||||
raise
|
||||
from basic_memory.utils import setup_logging
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # pragma: no cover
|
||||
"""Lifecycle manager for the FastAPI app."""
|
||||
logger.info("Starting Basic Memory API")
|
||||
await run_migrations()
|
||||
setup_logging(log_file=".basic-memory/basic-memory.log")
|
||||
logger.info(f"Starting Basic Memory API {basic_memory.__version__}")
|
||||
await db.run_migrations(app_config)
|
||||
yield
|
||||
logger.info("Shutting down Basic Memory API")
|
||||
await db.shutdown_db()
|
||||
@@ -51,6 +33,10 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
if app_config != "test":
|
||||
logfire.instrument_fastapi(app)
|
||||
|
||||
|
||||
# Include routers
|
||||
app.include_router(knowledge.router)
|
||||
app.include_router(search.router)
|
||||
|
||||
@@ -94,11 +94,8 @@ async def get_entity(
|
||||
try:
|
||||
entity = await entity_service.get_by_permalink(permalink)
|
||||
result = EntityResponse.model_validate(entity)
|
||||
|
||||
logger.info(f"response: get_entity with result={result}")
|
||||
return result
|
||||
except EntityNotFoundError:
|
||||
logger.error(f"Error: Entity with {permalink} not found")
|
||||
raise HTTPException(status_code=404, detail=f"Entity with {permalink} not found")
|
||||
|
||||
|
||||
@@ -114,8 +111,6 @@ async def get_entities(
|
||||
result = EntityListResponse(
|
||||
entities=[EntityResponse.model_validate(entity) for entity in entities]
|
||||
)
|
||||
|
||||
logger.info(f"response: get_entities with result={result}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -135,7 +130,6 @@ async def delete_entity(
|
||||
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
if entity is None:
|
||||
logger.info("response: delete_entity with result=DeleteEntitiesResponse(deleted=False)")
|
||||
return DeleteEntitiesResponse(deleted=False)
|
||||
|
||||
# Delete the entity
|
||||
@@ -145,7 +139,6 @@ async def delete_entity(
|
||||
background_tasks.add_task(search_service.delete_by_permalink, entity.permalink)
|
||||
|
||||
result = DeleteEntitiesResponse(deleted=deleted)
|
||||
logger.info(f"response: delete_entity with result={result}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -166,5 +159,4 @@ async def delete_entities(
|
||||
background_tasks.add_task(search_service.delete_by_permalink, permalink)
|
||||
|
||||
result = DeleteEntitiesResponse(deleted=deleted)
|
||||
logger.info(f"response: delete_entities with result={result}")
|
||||
return result
|
||||
|
||||
@@ -24,7 +24,7 @@ from basic_memory.services.context_service import ContextResultRow
|
||||
router = APIRouter(prefix="/memory", tags=["memory"])
|
||||
|
||||
|
||||
async def to_graph_context(context, entity_repository: EntityRepository):
|
||||
async def to_graph_context(context, entity_repository: EntityRepository, page: int, page_size: int):
|
||||
# return results
|
||||
async def to_summary(item: SearchIndexRow | ContextResultRow):
|
||||
match item.type:
|
||||
@@ -66,7 +66,11 @@ async def to_graph_context(context, entity_repository: EntityRepository):
|
||||
metadata = MemoryMetadata.model_validate(context["metadata"])
|
||||
# Transform to GraphContext
|
||||
return GraphContext(
|
||||
primary_results=primary_results, related_results=related_results, metadata=metadata
|
||||
primary_results=primary_results,
|
||||
related_results=related_results,
|
||||
metadata=metadata,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@@ -77,7 +81,9 @@ async def recent(
|
||||
type: Annotated[list[SearchItemType] | None, Query()] = None,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
max_results: int = 10,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
# return all types by default
|
||||
types = (
|
||||
@@ -87,16 +93,20 @@ async def recent(
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`"
|
||||
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
# Parse timeframe
|
||||
since = parse(timeframe)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
types=types, depth=depth, since=since, max_results=max_results
|
||||
types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
return await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
return await to_graph_context(context, entity_repository=entity_repository)
|
||||
|
||||
|
||||
# get_memory_context needs to be declared last so other paths can match
|
||||
@@ -109,21 +119,27 @@ async def get_memory_context(
|
||||
uri: str,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
max_results: int = 10,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get rich context from memory:// URI."""
|
||||
# add the project name from the config to the url as the "host
|
||||
# Parse URI
|
||||
logger.debug(
|
||||
f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`"
|
||||
f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
memory_url = normalize_memory_url(uri)
|
||||
|
||||
# Parse timeframe
|
||||
since = parse(timeframe)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
memory_url, depth=depth, since=since, max_results=max_results
|
||||
memory_url, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
return await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
return await to_graph_context(context, entity_repository=entity_repository)
|
||||
|
||||
@@ -1,34 +1,124 @@
|
||||
"""Routes for getting entity content."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import FileResponse
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import ProjectConfigDep, LinkResolverDep
|
||||
from basic_memory.deps import (
|
||||
ProjectConfigDep,
|
||||
LinkResolverDep,
|
||||
SearchServiceDep,
|
||||
EntityServiceDep,
|
||||
FileServiceDep,
|
||||
)
|
||||
from basic_memory.repository.search_repository import SearchIndexRow
|
||||
from basic_memory.schemas.memory import normalize_memory_url
|
||||
from basic_memory.schemas.search import SearchQuery, SearchItemType
|
||||
|
||||
router = APIRouter(prefix="/resource", tags=["resources"])
|
||||
|
||||
|
||||
def get_entity_ids(item: SearchIndexRow) -> set[int]:
|
||||
match item.type:
|
||||
case SearchItemType.ENTITY:
|
||||
return {item.id}
|
||||
case SearchItemType.OBSERVATION:
|
||||
return {item.entity_id} # pyright: ignore [reportReturnType]
|
||||
case SearchItemType.RELATION:
|
||||
from_entity = item.from_id
|
||||
to_entity = item.to_id # pyright: ignore [reportReturnType]
|
||||
return {from_entity, to_entity} if to_entity else {from_entity} # pyright: ignore [reportReturnType]
|
||||
case _: # pragma: no cover
|
||||
raise ValueError(f"Unexpected type: {item.type}")
|
||||
|
||||
|
||||
@router.get("/{identifier:path}")
|
||||
async def get_resource_content(
|
||||
config: ProjectConfigDep,
|
||||
link_resolver: LinkResolverDep,
|
||||
search_service: SearchServiceDep,
|
||||
entity_service: EntityServiceDep,
|
||||
file_service: FileServiceDep,
|
||||
background_tasks: BackgroundTasks,
|
||||
identifier: str,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> FileResponse:
|
||||
"""Get resource content by identifier: name or permalink."""
|
||||
logger.debug(f"Getting content for permalink: {identifier}")
|
||||
logger.debug(f"Getting content for: {identifier}")
|
||||
|
||||
# Find entity by permalink
|
||||
# Find single entity by permalink
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity not found: {identifier}")
|
||||
results = [entity] if entity else []
|
||||
|
||||
file_path = Path(f"{config.home}/{entity.file_path}")
|
||||
if not file_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"File not found: {file_path}",
|
||||
# pagination for multiple results
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# search using the identifier as a permalink
|
||||
if not results:
|
||||
# if the identifier contains a wildcard, use GLOB search
|
||||
query = (
|
||||
SearchQuery(permalink_match=identifier)
|
||||
if "*" in identifier
|
||||
else SearchQuery(permalink=identifier)
|
||||
)
|
||||
return FileResponse(path=file_path)
|
||||
search_results = await search_service.search(query, limit, offset)
|
||||
if not search_results:
|
||||
raise HTTPException(status_code=404, detail=f"Resource not found: {identifier}")
|
||||
|
||||
# get the deduplicated entities related to the search results
|
||||
entity_ids = {id for result in search_results for id in get_entity_ids(result)}
|
||||
results = await entity_service.get_entities_by_id(list(entity_ids))
|
||||
|
||||
# return single response
|
||||
if len(results) == 1:
|
||||
entity = results[0]
|
||||
file_path = Path(f"{config.home}/{entity.file_path}")
|
||||
if not file_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"File not found: {file_path}",
|
||||
)
|
||||
return FileResponse(path=file_path)
|
||||
|
||||
# for multiple files, initialize a temporary file for writing the results
|
||||
with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file:
|
||||
temp_file_path = tmp_file.name
|
||||
|
||||
for result in results:
|
||||
# Read content for each entity
|
||||
content = await file_service.read_entity_content(result)
|
||||
memory_url = normalize_memory_url(result.permalink)
|
||||
modified_date = result.updated_at.isoformat()
|
||||
assert result.checksum
|
||||
checksum = result.checksum[:8]
|
||||
|
||||
# Prepare the delimited content
|
||||
response_content = f"--- {memory_url} {modified_date} {checksum}\n"
|
||||
response_content += f"\n{content}\n"
|
||||
response_content += "\n"
|
||||
|
||||
# Write content directly to the temporary file in append mode
|
||||
tmp_file.write(response_content)
|
||||
|
||||
# Ensure all content is written to disk
|
||||
tmp_file.flush()
|
||||
|
||||
# Schedule the temporary file to be deleted after the response
|
||||
background_tasks.add_task(cleanup_temp_file, temp_file_path)
|
||||
|
||||
# Return the file response
|
||||
return FileResponse(path=temp_file_path)
|
||||
|
||||
|
||||
def cleanup_temp_file(file_path: str):
|
||||
"""Delete the temporary file."""
|
||||
try:
|
||||
Path(file_path).unlink() # Deletes the file
|
||||
logger.debug(f"Temporary file deleted: {file_path}")
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error deleting temporary file {file_path}: {e}")
|
||||
|
||||
@@ -2,27 +2,35 @@
|
||||
|
||||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, Depends, BackgroundTasks
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchResponse
|
||||
from basic_memory.deps import get_search_service
|
||||
from basic_memory.deps import SearchServiceDep
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
|
||||
@router.post("/", response_model=SearchResponse)
|
||||
async def search(query: SearchQuery, search_service: SearchService = Depends(get_search_service)):
|
||||
async def search(
|
||||
query: SearchQuery,
|
||||
search_service: SearchServiceDep,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
):
|
||||
"""Search across all knowledge and documents."""
|
||||
results = await search_service.search(query)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
search_results = [SearchResult.model_validate(asdict(r)) for r in results]
|
||||
return SearchResponse(results=search_results)
|
||||
return SearchResponse(
|
||||
results=search_results,
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reindex")
|
||||
async def reindex(
|
||||
background_tasks: BackgroundTasks, search_service: SearchService = Depends(get_search_service)
|
||||
):
|
||||
async def reindex(background_tasks: BackgroundTasks, search_service: SearchServiceDep):
|
||||
"""Recreate and populate the search index."""
|
||||
await search_service.reindex_all(background_tasks=background_tasks)
|
||||
return {"status": "ok", "message": "Reindex initiated"}
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
import asyncio
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer()
|
||||
from basic_memory import db
|
||||
from basic_memory.config import config
|
||||
from basic_memory.utils import setup_logging
|
||||
|
||||
setup_logging(log_file=".basic-memory/basic-memory-cli.log", console=False) # pragma: no cover
|
||||
|
||||
asyncio.run(db.run_migrations(config))
|
||||
|
||||
app = typer.Typer(name="basic-memory")
|
||||
|
||||
import_app = typer.Typer()
|
||||
app.add_typer(import_app, name="import")
|
||||
|
||||
|
||||
claude_app = typer.Typer()
|
||||
import_app.add_typer(claude_app, name="claude")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Database management commands."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
|
||||
@@ -13,13 +15,14 @@ def reset(
|
||||
reindex: bool = typer.Option(False, "--reindex", help="Rebuild indices from filesystem"),
|
||||
): # pragma: no cover
|
||||
"""Reset database (drop all tables and recreate)."""
|
||||
if typer.confirm("This will delete all data. Are you sure?"):
|
||||
logger.info("Resetting database...")
|
||||
asyncio.run(migrations.reset_database())
|
||||
with logfire.span("reset"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
if typer.confirm("This will delete all data in your db. Are you sure?"):
|
||||
logger.info("Resetting database...")
|
||||
asyncio.run(migrations.reset_database())
|
||||
|
||||
if reindex:
|
||||
# Import and run sync
|
||||
from basic_memory.cli.commands.sync import sync
|
||||
if reindex:
|
||||
# Import and run sync
|
||||
from basic_memory.cli.commands.sync import sync
|
||||
|
||||
logger.info("Rebuilding search index from filesystem...")
|
||||
asyncio.run(sync()) # pyright: ignore
|
||||
logger.info("Rebuilding search index from filesystem...")
|
||||
sync(watch=False) # pyright: ignore
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Import command for ChatGPT conversations."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Annotated, Set, Optional
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
||||
|
||||
from basic_memory.cli.app import import_app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def clean_filename(text: str) -> str:
|
||||
"""Convert text to safe filename."""
|
||||
clean = "".join(c if c.isalnum() else "-" for c in text.lower()).strip("-")
|
||||
return clean
|
||||
|
||||
|
||||
def format_timestamp(ts: float) -> str:
|
||||
"""Format Unix timestamp for display."""
|
||||
dt = datetime.fromtimestamp(ts)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def get_message_content(message: Dict[str, Any]) -> str:
|
||||
"""Extract clean message content."""
|
||||
if not message or "content" not in message:
|
||||
return "" # pragma: no cover
|
||||
|
||||
content = message["content"]
|
||||
if content.get("content_type") == "text":
|
||||
return "\n".join(content.get("parts", []))
|
||||
elif content.get("content_type") == "code":
|
||||
return f"```{content.get('language', '')}\n{content.get('text', '')}\n```"
|
||||
return "" # pragma: no cover
|
||||
|
||||
|
||||
def traverse_messages(
|
||||
mapping: Dict[str, Any], root_id: Optional[str], seen: Set[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Traverse message tree and return messages in order."""
|
||||
messages = []
|
||||
node = mapping.get(root_id) if root_id else None
|
||||
|
||||
while node:
|
||||
if node["id"] not in seen and node.get("message"):
|
||||
seen.add(node["id"])
|
||||
messages.append(node["message"])
|
||||
|
||||
# Follow children
|
||||
children = node.get("children", [])
|
||||
for child_id in children:
|
||||
child_msgs = traverse_messages(mapping, child_id, seen)
|
||||
messages.extend(child_msgs)
|
||||
|
||||
break # Don't follow siblings
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def format_chat_markdown(
|
||||
title: str,
|
||||
mapping: Dict[str, Any],
|
||||
root_id: Optional[str],
|
||||
created_at: float,
|
||||
modified_at: float,
|
||||
) -> str:
|
||||
"""Format chat as clean markdown."""
|
||||
|
||||
# Start with title
|
||||
lines = [f"# {title}\n"]
|
||||
|
||||
# Traverse message tree
|
||||
seen_msgs = set()
|
||||
messages = traverse_messages(mapping, root_id, seen_msgs)
|
||||
|
||||
# Format each message
|
||||
for msg in messages:
|
||||
# Skip hidden messages
|
||||
if msg.get("metadata", {}).get("is_visually_hidden_from_conversation"):
|
||||
continue
|
||||
|
||||
# Get author and timestamp
|
||||
author = msg["author"]["role"].title()
|
||||
ts = format_timestamp(msg["create_time"]) if msg.get("create_time") else ""
|
||||
|
||||
# Add message header
|
||||
lines.append(f"### {author} ({ts})")
|
||||
|
||||
# Add message content
|
||||
content = get_message_content(msg)
|
||||
if content:
|
||||
lines.append(content)
|
||||
|
||||
# Add spacing
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_chat_content(folder: str, conversation: Dict[str, Any]) -> EntityMarkdown:
|
||||
"""Convert chat conversation to Basic Memory entity."""
|
||||
|
||||
# Extract timestamps
|
||||
created_at = conversation["create_time"]
|
||||
modified_at = conversation["update_time"]
|
||||
|
||||
root_id = None
|
||||
# Find root message
|
||||
for node_id, node in conversation["mapping"].items():
|
||||
if node.get("parent") is None:
|
||||
root_id = node_id
|
||||
break
|
||||
|
||||
# Generate permalink
|
||||
date_prefix = datetime.fromtimestamp(created_at).strftime("%Y%m%d")
|
||||
clean_title = clean_filename(conversation["title"])
|
||||
|
||||
# Format content
|
||||
content = format_chat_markdown(
|
||||
title=conversation["title"],
|
||||
mapping=conversation["mapping"],
|
||||
root_id=root_id,
|
||||
created_at=created_at,
|
||||
modified_at=modified_at,
|
||||
)
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": "conversation",
|
||||
"title": conversation["title"],
|
||||
"created": format_timestamp(created_at),
|
||||
"modified": format_timestamp(modified_at),
|
||||
"permalink": f"{folder}/{date_prefix}-{clean_title}",
|
||||
}
|
||||
),
|
||||
content=content,
|
||||
)
|
||||
|
||||
return entity
|
||||
|
||||
|
||||
async def process_chatgpt_json(
|
||||
json_path: Path, folder: str, markdown_processor: MarkdownProcessor
|
||||
) -> Dict[str, int]:
|
||||
"""Import conversations from ChatGPT JSON format."""
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
console=console,
|
||||
) as progress:
|
||||
read_task = progress.add_task("Reading chat data...", total=None)
|
||||
|
||||
# Read conversations
|
||||
conversations = json.loads(json_path.read_text())
|
||||
progress.update(read_task, total=len(conversations))
|
||||
|
||||
# Process each conversation
|
||||
messages_imported = 0
|
||||
chats_imported = 0
|
||||
|
||||
for chat in conversations:
|
||||
# Convert to entity
|
||||
entity = format_chat_content(folder, chat)
|
||||
|
||||
# Write file
|
||||
file_path = config.home / f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
# logger.info(f"Writing file: {file_path.absolute()}")
|
||||
await markdown_processor.write_file(file_path, entity)
|
||||
|
||||
# Count messages
|
||||
msg_count = sum(
|
||||
1
|
||||
for node in chat["mapping"].values()
|
||||
if node.get("message")
|
||||
and not node.get("message", {})
|
||||
.get("metadata", {})
|
||||
.get("is_visually_hidden_from_conversation")
|
||||
)
|
||||
|
||||
chats_imported += 1
|
||||
messages_imported += msg_count
|
||||
progress.update(read_task, advance=1)
|
||||
|
||||
return {"conversations": chats_imported, "messages": messages_imported}
|
||||
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
|
||||
|
||||
@import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")
|
||||
def import_chatgpt(
|
||||
conversations_json: Annotated[
|
||||
Path, typer.Argument(help="Path to ChatGPT conversations.json file")
|
||||
] = Path("conversations.json"),
|
||||
folder: Annotated[
|
||||
str, typer.Option(help="The folder to place the files in.")
|
||||
] = "conversations",
|
||||
):
|
||||
"""Import chat conversations from ChatGPT JSON format.
|
||||
|
||||
This command will:
|
||||
1. Read the complex tree structure of messages
|
||||
2. Convert them to linear markdown conversations
|
||||
3. Save as clean, readable markdown files
|
||||
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
|
||||
with logfire.span("import chatgpt"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
try:
|
||||
if conversations_json:
|
||||
if not conversations_json.exists():
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(
|
||||
f"\nImporting chats from {conversations_json}...writing to {base_path}"
|
||||
)
|
||||
results = asyncio.run(
|
||||
process_chatgpt_json(conversations_json, folder, markdown_processor)
|
||||
)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['conversations']} conversations\n"
|
||||
f"Containing {results['messages']} messages",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Import command for basic-memory CLI to import chat data from conversations2.json format."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Annotated
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
||||
|
||||
from basic_memory.cli.app import claude_app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def clean_filename(text: str) -> str:
|
||||
"""Convert text to safe filename."""
|
||||
# Remove invalid characters and convert spaces
|
||||
clean = "".join(c if c.isalnum() else "-" for c in text.lower()).strip("-")
|
||||
return clean
|
||||
|
||||
|
||||
def format_timestamp(ts: str) -> str:
|
||||
"""Format ISO timestamp for display."""
|
||||
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def format_chat_markdown(
|
||||
name: str, messages: List[Dict[str, Any]], created_at: str, modified_at: str, permalink: str
|
||||
) -> str:
|
||||
"""Format chat as clean markdown."""
|
||||
|
||||
# Start with frontmatter and title
|
||||
lines = [
|
||||
f"# {name}\n",
|
||||
]
|
||||
|
||||
# Add messages
|
||||
for msg in messages:
|
||||
# Format timestamp
|
||||
ts = format_timestamp(msg["created_at"])
|
||||
|
||||
# Add message header
|
||||
lines.append(f"### {msg['sender'].title()} ({ts})")
|
||||
|
||||
# Handle message content
|
||||
content = msg.get("text", "")
|
||||
if msg.get("content"):
|
||||
content = " ".join(c.get("text", "") for c in msg["content"])
|
||||
lines.append(content)
|
||||
|
||||
# Handle attachments
|
||||
attachments = msg.get("attachments", [])
|
||||
for attachment in attachments:
|
||||
if "file_name" in attachment:
|
||||
lines.append(f"\n**Attachment: {attachment['file_name']}**")
|
||||
if "extracted_content" in attachment:
|
||||
lines.append("```")
|
||||
lines.append(attachment["extracted_content"])
|
||||
lines.append("```")
|
||||
|
||||
# Add spacing between messages
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_chat_content(
|
||||
base_path: Path, name: str, messages: List[Dict[str, Any]], created_at: str, modified_at: str
|
||||
) -> EntityMarkdown:
|
||||
"""Convert chat messages to Basic Memory entity format."""
|
||||
|
||||
# Generate permalink
|
||||
date_prefix = datetime.fromisoformat(created_at.replace("Z", "+00:00")).strftime("%Y%m%d")
|
||||
clean_title = clean_filename(name)
|
||||
permalink = f"{base_path}/{date_prefix}-{clean_title}"
|
||||
|
||||
# Format content
|
||||
content = format_chat_markdown(
|
||||
name=name,
|
||||
messages=messages,
|
||||
created_at=created_at,
|
||||
modified_at=modified_at,
|
||||
permalink=permalink,
|
||||
)
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": "conversation",
|
||||
"title": name,
|
||||
"created": created_at,
|
||||
"modified": modified_at,
|
||||
"permalink": permalink,
|
||||
}
|
||||
),
|
||||
content=content,
|
||||
)
|
||||
|
||||
return entity
|
||||
|
||||
|
||||
async def process_conversations_json(
|
||||
json_path: Path, base_path: Path, markdown_processor: MarkdownProcessor
|
||||
) -> Dict[str, int]:
|
||||
"""Import chat data from conversations2.json format."""
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
console=console,
|
||||
) as progress:
|
||||
read_task = progress.add_task("Reading chat data...", total=None)
|
||||
|
||||
# Read chat data - handle array of arrays format
|
||||
data = json.loads(json_path.read_text())
|
||||
conversations = [chat for chat in data]
|
||||
progress.update(read_task, total=len(conversations))
|
||||
|
||||
# Process each conversation
|
||||
messages_imported = 0
|
||||
chats_imported = 0
|
||||
|
||||
for chat in conversations:
|
||||
# Convert to entity
|
||||
entity = format_chat_content(
|
||||
base_path=base_path,
|
||||
name=chat["name"],
|
||||
messages=chat["chat_messages"],
|
||||
created_at=chat["created_at"],
|
||||
modified_at=chat["updated_at"],
|
||||
)
|
||||
|
||||
# Write file
|
||||
file_path = Path(f"{entity.frontmatter.metadata['permalink']}.md")
|
||||
await markdown_processor.write_file(file_path, entity)
|
||||
|
||||
chats_imported += 1
|
||||
messages_imported += len(chat["chat_messages"])
|
||||
progress.update(read_task, advance=1)
|
||||
|
||||
return {"conversations": chats_imported, "messages": messages_imported}
|
||||
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
|
||||
|
||||
@claude_app.command(name="conversations", help="Import chat conversations from Claude.ai.")
|
||||
def import_claude(
|
||||
conversations_json: Annotated[
|
||||
Path, typer.Argument(..., help="Path to conversations.json file")
|
||||
] = Path("conversations.json"),
|
||||
folder: Annotated[
|
||||
str, typer.Option(help="The folder to place the files in.")
|
||||
] = "conversations",
|
||||
):
|
||||
"""Import chat conversations from conversations2.json format.
|
||||
|
||||
This command will:
|
||||
1. Read chat data and nested messages
|
||||
2. Create markdown files for each conversation
|
||||
3. Format content in clean, readable markdown
|
||||
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
|
||||
with logfire.span("import claude conversations"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
try:
|
||||
if not conversations_json.exists():
|
||||
typer.echo(f"Error: File not found: {conversations_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / folder
|
||||
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_conversations_json(conversations_json, base_path, markdown_processor)
|
||||
)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['conversations']} conversations\n"
|
||||
f"Containing {results['messages']} messages",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Import command for basic-memory CLI to import project data from Claude.ai."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Annotated, Optional
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
||||
|
||||
from basic_memory.cli.app import claude_app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def clean_filename(text: str) -> str:
|
||||
"""Convert text to safe filename."""
|
||||
clean = "".join(c if c.isalnum() else "-" for c in text.lower()).strip("-")
|
||||
return clean
|
||||
|
||||
|
||||
def format_project_markdown(project: Dict[str, Any], doc: Dict[str, Any]) -> EntityMarkdown:
|
||||
"""Format a project document as a Basic Memory entity."""
|
||||
|
||||
# Extract timestamps
|
||||
created_at = doc.get("created_at") or project["created_at"]
|
||||
modified_at = project["updated_at"]
|
||||
|
||||
# Generate clean names for organization
|
||||
project_dir = clean_filename(project["name"])
|
||||
doc_file = clean_filename(doc["filename"])
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": "project_doc",
|
||||
"title": doc["filename"],
|
||||
"created": created_at,
|
||||
"modified": modified_at,
|
||||
"permalink": f"{project_dir}/docs/{doc_file}",
|
||||
"project_name": project["name"],
|
||||
"project_uuid": project["uuid"],
|
||||
"doc_uuid": doc["uuid"],
|
||||
}
|
||||
),
|
||||
content=doc["content"],
|
||||
)
|
||||
|
||||
return entity
|
||||
|
||||
|
||||
def format_prompt_markdown(project: Dict[str, Any]) -> Optional[EntityMarkdown]:
|
||||
"""Format project prompt template as a Basic Memory entity."""
|
||||
|
||||
if not project.get("prompt_template"):
|
||||
return None
|
||||
|
||||
# Extract timestamps
|
||||
created_at = project["created_at"]
|
||||
modified_at = project["updated_at"]
|
||||
|
||||
# Generate clean project directory name
|
||||
project_dir = clean_filename(project["name"])
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
metadata={
|
||||
"type": "prompt_template",
|
||||
"title": f"Prompt Template: {project['name']}",
|
||||
"created": created_at,
|
||||
"modified": modified_at,
|
||||
"permalink": f"{project_dir}/prompt-template",
|
||||
"project_name": project["name"],
|
||||
"project_uuid": project["uuid"],
|
||||
}
|
||||
),
|
||||
content=f"# Prompt Template: {project['name']}\n\n{project['prompt_template']}",
|
||||
)
|
||||
|
||||
return entity
|
||||
|
||||
|
||||
async def process_projects_json(
|
||||
json_path: Path, base_path: Path, markdown_processor: MarkdownProcessor
|
||||
) -> Dict[str, int]:
|
||||
"""Import project data from Claude.ai projects.json format."""
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
console=console,
|
||||
) as progress:
|
||||
read_task = progress.add_task("Reading project data...", total=None)
|
||||
|
||||
# Read project data
|
||||
data = json.loads(json_path.read_text())
|
||||
progress.update(read_task, total=len(data))
|
||||
|
||||
# Track import counts
|
||||
docs_imported = 0
|
||||
prompts_imported = 0
|
||||
|
||||
# Process each project
|
||||
for project in data:
|
||||
project_dir = clean_filename(project["name"])
|
||||
|
||||
# Create project directories
|
||||
docs_dir = base_path / project_dir / "docs"
|
||||
docs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Import prompt template if it exists
|
||||
if prompt_entity := format_prompt_markdown(project):
|
||||
file_path = base_path / f"{prompt_entity.frontmatter.metadata['permalink']}.md"
|
||||
await markdown_processor.write_file(file_path, prompt_entity)
|
||||
prompts_imported += 1
|
||||
|
||||
# Import project documents
|
||||
for doc in project.get("docs", []):
|
||||
entity = format_project_markdown(project, doc)
|
||||
file_path = base_path / f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await markdown_processor.write_file(file_path, entity)
|
||||
docs_imported += 1
|
||||
|
||||
progress.update(read_task, advance=1)
|
||||
|
||||
return {"documents": docs_imported, "prompts": prompts_imported}
|
||||
|
||||
|
||||
async def get_markdown_processor() -> MarkdownProcessor:
|
||||
"""Get MarkdownProcessor instance."""
|
||||
entity_parser = EntityParser(config.home)
|
||||
return MarkdownProcessor(entity_parser)
|
||||
|
||||
|
||||
@claude_app.command(name="projects", help="Import projects from Claude.ai.")
|
||||
def import_projects(
|
||||
projects_json: Annotated[Path, typer.Argument(..., help="Path to projects.json file")] = Path(
|
||||
"projects.json"
|
||||
),
|
||||
base_folder: Annotated[
|
||||
str, typer.Option(help="The base folder to place project files in.")
|
||||
] = "projects",
|
||||
):
|
||||
"""Import project data from Claude.ai.
|
||||
|
||||
This command will:
|
||||
1. Create a directory for each project
|
||||
2. Store docs in a docs/ subdirectory
|
||||
3. Place prompt template in project root
|
||||
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
with logfire.span("import claude projects"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
try:
|
||||
if projects_json:
|
||||
if not projects_json.exists():
|
||||
typer.echo(f"Error: File not found: {projects_json}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home / base_folder if base_folder else config.home
|
||||
console.print(f"\nImporting projects from {projects_json}...writing to {base_path}")
|
||||
results = asyncio.run(
|
||||
process_projects_json(projects_json, base_path, markdown_processor)
|
||||
)
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Imported {results['documents']} project documents\n"
|
||||
f"Imported {results['prompts']} prompt templates",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
@@ -3,20 +3,20 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
from typing import Dict, Any, List, Annotated
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.app import import_app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter, Observation, Relation
|
||||
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@@ -98,9 +98,11 @@ async def get_markdown_processor() -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser)
|
||||
|
||||
|
||||
@app.command()
|
||||
def import_json(
|
||||
json_path: Path = typer.Argument(..., help="Path to memory.json file to import"),
|
||||
@import_app.command()
|
||||
def memory_json(
|
||||
json_path: Annotated[Path, typer.Argument(..., help="Path to memory.json file")] = Path(
|
||||
"memory.json"
|
||||
),
|
||||
):
|
||||
"""Import entities and relations from a memory.json file.
|
||||
|
||||
@@ -112,32 +114,33 @@ def import_json(
|
||||
After importing, run 'basic-memory sync' to index the new files.
|
||||
"""
|
||||
|
||||
if not json_path.exists():
|
||||
typer.echo(f"Error: File not found: {json_path}", err=True)
|
||||
raise typer.Exit(1)
|
||||
with logfire.span("import memory_json"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
if not json_path.exists():
|
||||
typer.echo(f"Error: File not found: {json_path}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
try:
|
||||
# Get markdown processor
|
||||
markdown_processor = asyncio.run(get_markdown_processor())
|
||||
|
||||
# Process the file
|
||||
base_path = config.home
|
||||
console.print(f"\nImporting from {json_path}...writing to {base_path}")
|
||||
results = asyncio.run(process_memory_json(json_path, base_path, markdown_processor))
|
||||
# Process the file
|
||||
base_path = config.home
|
||||
console.print(f"\nImporting from {json_path}...writing to {base_path}")
|
||||
results = asyncio.run(process_memory_json(json_path, base_path, markdown_processor))
|
||||
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Created {results['entities']} entities\n"
|
||||
f"Added {results['relations']} relations",
|
||||
expand=False,
|
||||
# Show results
|
||||
console.print(
|
||||
Panel(
|
||||
f"[green]Import complete![/green]\n\n"
|
||||
f"Created {results['entities']} entities\n"
|
||||
f"Added {results['relations']} relations",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
console.print("\nRun 'basic-memory sync' to index the new files.")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
logger.error("Import failed")
|
||||
typer.echo(f"Error during import: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -12,9 +12,9 @@ import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
|
||||
|
||||
|
||||
@app.command()
|
||||
def mcp():
|
||||
def mcp(): # pragma: no cover
|
||||
"""Run the MCP server for Claude Desktop integration."""
|
||||
home_dir = config.home
|
||||
logger.info("Starting Basic Memory MCP server")
|
||||
logger.info(f"Starting Basic Memory MCP server {basic_memory.__version__}")
|
||||
logger.info(f"Home directory: {home_dir}")
|
||||
mcp_server.run()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
from typing import Set, Dict
|
||||
|
||||
import logfire
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
@@ -25,13 +26,11 @@ async def get_file_change_scanner(
|
||||
db_type=DatabaseType.FILESYSTEM,
|
||||
) -> FileChangeScanner: # pragma: no cover
|
||||
"""Get sync service instance."""
|
||||
async with db.engine_session_factory(db_path=config.database_path, db_type=db_type) as (
|
||||
engine,
|
||||
session_maker,
|
||||
):
|
||||
entity_repository = EntityRepository(session_maker)
|
||||
file_change_scanner = FileChangeScanner(entity_repository)
|
||||
return file_change_scanner
|
||||
_, session_maker = await db.get_or_create_db(db_path=config.database_path, db_type=db_type)
|
||||
|
||||
entity_repository = EntityRepository(session_maker)
|
||||
file_change_scanner = FileChangeScanner(entity_repository)
|
||||
return file_change_scanner
|
||||
|
||||
|
||||
def add_files_to_tree(
|
||||
@@ -148,9 +147,10 @@ def status(
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
|
||||
):
|
||||
"""Show sync status between files and database."""
|
||||
try:
|
||||
sync_service = asyncio.run(get_file_change_scanner())
|
||||
asyncio.run(run_status(sync_service, verbose)) # pragma: no cover
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking status: {e}")
|
||||
raise typer.Exit(code=1) # pragma: no cover
|
||||
with logfire.span("status"): # pyright: ignore [reportGeneralTypeIssues]
|
||||
try:
|
||||
sync_service = asyncio.run(get_file_change_scanner())
|
||||
asyncio.run(run_status(sync_service, verbose)) # pragma: no cover
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking status: {e}")
|
||||
raise typer.Exit(code=1) # pragma: no cover
|
||||
|
||||
@@ -14,7 +14,6 @@ from rich.tree import Tree
|
||||
from basic_memory import db
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import config
|
||||
from basic_memory.db import DatabaseType
|
||||
from basic_memory.markdown import EntityParser
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.repository import (
|
||||
@@ -39,50 +38,50 @@ class ValidationIssue:
|
||||
error: str
|
||||
|
||||
|
||||
async def get_sync_service(db_type=DatabaseType.FILESYSTEM): # pragma: no cover
|
||||
async def get_sync_service(): # pragma: no cover
|
||||
"""Get sync service instance with all dependencies."""
|
||||
async with db.engine_session_factory(db_path=config.database_path, db_type=db_type) as (
|
||||
engine,
|
||||
session_maker,
|
||||
):
|
||||
entity_parser = EntityParser(config.home)
|
||||
markdown_processor = MarkdownProcessor(entity_parser)
|
||||
file_service = FileService(config.home, markdown_processor)
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
||||
)
|
||||
|
||||
# Initialize repositories
|
||||
entity_repository = EntityRepository(session_maker)
|
||||
observation_repository = ObservationRepository(session_maker)
|
||||
relation_repository = RelationRepository(session_maker)
|
||||
search_repository = SearchRepository(session_maker)
|
||||
entity_parser = EntityParser(config.home)
|
||||
markdown_processor = MarkdownProcessor(entity_parser)
|
||||
file_service = FileService(config.home, markdown_processor)
|
||||
|
||||
# Initialize services
|
||||
search_service = SearchService(search_repository, entity_repository, file_service)
|
||||
link_resolver = LinkResolver(entity_repository, search_service)
|
||||
# Initialize repositories
|
||||
entity_repository = EntityRepository(session_maker)
|
||||
observation_repository = ObservationRepository(session_maker)
|
||||
relation_repository = RelationRepository(session_maker)
|
||||
search_repository = SearchRepository(session_maker)
|
||||
|
||||
# Initialize scanner
|
||||
file_change_scanner = FileChangeScanner(entity_repository)
|
||||
# Initialize services
|
||||
search_service = SearchService(search_repository, entity_repository, file_service)
|
||||
link_resolver = LinkResolver(entity_repository, search_service)
|
||||
|
||||
# Initialize services
|
||||
entity_service = EntityService(
|
||||
entity_parser,
|
||||
entity_repository,
|
||||
observation_repository,
|
||||
relation_repository,
|
||||
file_service,
|
||||
link_resolver,
|
||||
)
|
||||
# Initialize scanner
|
||||
file_change_scanner = FileChangeScanner(entity_repository)
|
||||
|
||||
# Create sync service
|
||||
sync_service = SyncService(
|
||||
scanner=file_change_scanner,
|
||||
entity_service=entity_service,
|
||||
entity_parser=entity_parser,
|
||||
entity_repository=entity_repository,
|
||||
relation_repository=relation_repository,
|
||||
search_service=search_service,
|
||||
)
|
||||
# Initialize services
|
||||
entity_service = EntityService(
|
||||
entity_parser,
|
||||
entity_repository,
|
||||
observation_repository,
|
||||
relation_repository,
|
||||
file_service,
|
||||
link_resolver,
|
||||
)
|
||||
|
||||
return sync_service
|
||||
# Create sync service
|
||||
sync_service = SyncService(
|
||||
scanner=file_change_scanner,
|
||||
entity_service=entity_service,
|
||||
entity_parser=entity_parser,
|
||||
entity_repository=entity_repository,
|
||||
relation_repository=relation_repository,
|
||||
search_service=search_service,
|
||||
)
|
||||
|
||||
return sync_service
|
||||
|
||||
|
||||
def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[ValidationIssue]]:
|
||||
@@ -152,8 +151,9 @@ def display_detailed_sync_results(knowledge: SyncReport):
|
||||
console.print(knowledge_tree)
|
||||
|
||||
|
||||
async def run_sync(verbose: bool = False, watch: bool = False):
|
||||
async def run_sync(verbose: bool = False, watch: bool = False, console_status: bool = False):
|
||||
"""Run sync operation."""
|
||||
|
||||
sync_service = await get_sync_service()
|
||||
|
||||
# Start watching if requested
|
||||
@@ -164,7 +164,7 @@ async def run_sync(verbose: bool = False, watch: bool = False):
|
||||
config=config,
|
||||
)
|
||||
await watch_service.handle_changes(config.home)
|
||||
await watch_service.run() # pragma: no cover
|
||||
await watch_service.run(console_status=console_status) # pragma: no cover
|
||||
else:
|
||||
# one time sync
|
||||
knowledge_changes = await sync_service.sync(config.home)
|
||||
@@ -189,11 +189,14 @@ def sync(
|
||||
"-w",
|
||||
help="Start watching for changes after sync.",
|
||||
),
|
||||
console_status: bool = typer.Option(
|
||||
False, "--console-status", "-c", help="Show live console status"
|
||||
),
|
||||
) -> None:
|
||||
"""Sync knowledge files with the database."""
|
||||
try:
|
||||
# Run sync
|
||||
asyncio.run(run_sync(verbose=verbose, watch=watch))
|
||||
asyncio.run(run_sync(verbose=verbose, watch=watch, console_status=console_status))
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Database management commands."""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, List, Annotated
|
||||
|
||||
import typer
|
||||
from rich import print as rprint
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.mcp.tools import build_context as mcp_build_context
|
||||
from basic_memory.mcp.tools import get_entity as mcp_get_entity
|
||||
from basic_memory.mcp.tools import read_note as mcp_read_note
|
||||
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
|
||||
from basic_memory.mcp.tools import search as mcp_search
|
||||
from basic_memory.mcp.tools import write_note as mcp_write_note
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import MemoryUrl
|
||||
from basic_memory.schemas.search import SearchQuery
|
||||
|
||||
tool_app = typer.Typer()
|
||||
app.add_typer(tool_app, name="tools", help="cli versions mcp tools")
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def write_note(
|
||||
title: Annotated[str, typer.Option(help="The title of the note")],
|
||||
content: Annotated[str, typer.Option(help="The content of the note")],
|
||||
folder: Annotated[str, typer.Option(help="The folder to create the note in")],
|
||||
tags: Annotated[
|
||||
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
|
||||
] = None,
|
||||
):
|
||||
try:
|
||||
note = asyncio.run(mcp_write_note(title, content, folder, tags))
|
||||
rprint(note)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during write_note: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def read_note(identifier: str, page: int = 1, page_size: int = 10):
|
||||
try:
|
||||
note = asyncio.run(mcp_read_note(identifier, page, page_size))
|
||||
rprint(note)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during read_note: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def build_context(
|
||||
url: MemoryUrl,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
):
|
||||
try:
|
||||
context = asyncio.run(
|
||||
mcp_build_context(
|
||||
url=url,
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
)
|
||||
)
|
||||
rprint(context.model_dump())
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during build_context: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def recent_activity(
|
||||
type: Annotated[Optional[List[str]], typer.Option()] = ["entity", "observation", "relation"],
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
):
|
||||
assert type is not None, "type is required"
|
||||
if any(t not in ["entity", "observation", "relation"] for t in type): # pragma: no cover
|
||||
print("type must be one of ['entity', 'observation', 'relation']")
|
||||
raise typer.Abort()
|
||||
|
||||
try:
|
||||
context = asyncio.run(
|
||||
mcp_recent_activity(
|
||||
type=type, # pyright: ignore [reportArgumentType]
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
)
|
||||
)
|
||||
rprint(context.model_dump())
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during build_context: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def search(
|
||||
query: str,
|
||||
permalink: Annotated[bool, typer.Option("--permalink", help="Search permalink values")] = False,
|
||||
title: Annotated[bool, typer.Option("--title", help="Search title values")] = False,
|
||||
after_date: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--after_date", help="Search results after date, eg. '2d', '1 week'"),
|
||||
] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
):
|
||||
if permalink and title: # pragma: no cover
|
||||
print("Cannot search both permalink and title")
|
||||
raise typer.Abort()
|
||||
|
||||
try:
|
||||
search_query = SearchQuery(
|
||||
permalink_match=query if permalink else None,
|
||||
text=query if query else None,
|
||||
title=query if title else None,
|
||||
after_date=after_date,
|
||||
)
|
||||
results = asyncio.run(mcp_search(query=search_query, page=page, page_size=page_size))
|
||||
rprint(results.model_dump())
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during search: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def get_entity(identifier: str):
|
||||
try:
|
||||
entity = asyncio.run(mcp_get_entity(identifier=identifier))
|
||||
rprint(entity.model_dump())
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during get_entity: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
@@ -1,14 +1,20 @@
|
||||
"""Main CLI entry point for basic-memory.""" # pragma: no cover
|
||||
|
||||
from basic_memory.cli.app import app # pragma: no cover
|
||||
from basic_memory.utils import setup_logging # pragma: no cover
|
||||
|
||||
# Register commands
|
||||
from basic_memory.cli.commands import status, sync, db, import_memory_json, mcp # noqa: F401 # pragma: no cover
|
||||
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
status,
|
||||
sync,
|
||||
db,
|
||||
import_memory_json,
|
||||
mcp,
|
||||
import_claude_conversations,
|
||||
import_claude_projects,
|
||||
import_chatgpt,
|
||||
tools,
|
||||
)
|
||||
|
||||
|
||||
# Set up logging when module is imported
|
||||
setup_logging(log_file=".basic-memory/basic-memory-cli.log") # pragma: no cover
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Configuration management for basic-memory."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
@@ -8,10 +9,14 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
DATABASE_NAME = "memory.db"
|
||||
DATA_DIR_NAME = ".basic-memory"
|
||||
|
||||
Environment = Literal["test", "dev", "user"]
|
||||
|
||||
|
||||
class ProjectConfig(BaseSettings):
|
||||
"""Configuration for a specific basic-memory project."""
|
||||
|
||||
env: Environment = Field(default="dev", description="Environment name")
|
||||
|
||||
# Default to ~/basic-memory but allow override with env var: BASIC_MEMORY_HOME
|
||||
home: Path = Field(
|
||||
default_factory=lambda: Path.home() / "basic-memory",
|
||||
|
||||
+37
-28
@@ -4,6 +4,11 @@ from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
|
||||
from basic_memory.config import ProjectConfig
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
@@ -14,8 +19,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
async_scoped_session,
|
||||
)
|
||||
|
||||
from basic_memory.models import Base
|
||||
from basic_memory.models.search import CREATE_SEARCH_INDEX
|
||||
from basic_memory.repository.search_repository import SearchRepository
|
||||
|
||||
# Module level state
|
||||
_engine: Optional[AsyncEngine] = None
|
||||
@@ -35,7 +39,7 @@ class DatabaseType(Enum):
|
||||
logger.info("Using in-memory SQLite database")
|
||||
return "sqlite+aiosqlite://"
|
||||
|
||||
return f"sqlite+aiosqlite:///{db_path}"
|
||||
return f"sqlite+aiosqlite:///{db_path}" # pragma: no cover
|
||||
|
||||
|
||||
def get_scoped_session_factory(
|
||||
@@ -69,24 +73,6 @@ async def scoped_session(
|
||||
await factory.remove()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Initialize database with required tables."""
|
||||
if _session_maker is None: # pragma: no cover
|
||||
raise RuntimeError("Database session maker not initialized")
|
||||
|
||||
logger.info("Initializing database...")
|
||||
|
||||
async with scoped_session(_session_maker) as session:
|
||||
await session.execute(text("PRAGMA foreign_keys=ON"))
|
||||
conn = await session.connection()
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# recreate search index
|
||||
await session.execute(CREATE_SEARCH_INDEX)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_or_create_db(
|
||||
db_path: Path,
|
||||
db_type: DatabaseType = DatabaseType.FILESYSTEM,
|
||||
@@ -100,9 +86,6 @@ async def get_or_create_db(
|
||||
_engine = create_async_engine(db_url, connect_args={"check_same_thread": False})
|
||||
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
|
||||
|
||||
# Initialize database
|
||||
await init_db()
|
||||
|
||||
assert _engine is not None # for type checker
|
||||
assert _session_maker is not None # for type checker
|
||||
return _engine, _session_maker
|
||||
@@ -122,7 +105,6 @@ async def shutdown_db() -> None: # pragma: no cover
|
||||
async def engine_session_factory(
|
||||
db_path: Path,
|
||||
db_type: DatabaseType = DatabaseType.MEMORY,
|
||||
init: bool = True,
|
||||
) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
|
||||
"""Create engine and session factory.
|
||||
|
||||
@@ -139,9 +121,6 @@ async def engine_session_factory(
|
||||
try:
|
||||
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
|
||||
|
||||
if init:
|
||||
await init_db()
|
||||
|
||||
assert _engine is not None # for type checker
|
||||
assert _session_maker is not None # for type checker
|
||||
yield _engine, _session_maker
|
||||
@@ -150,3 +129,33 @@ async def engine_session_factory(
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
_session_maker = None
|
||||
|
||||
|
||||
async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.FILESYSTEM):
|
||||
"""Run any pending alembic migrations."""
|
||||
logger.info("Running database migrations...")
|
||||
try:
|
||||
# Get the absolute path to the alembic directory relative to this file
|
||||
alembic_dir = Path(__file__).parent / "alembic"
|
||||
config = Config()
|
||||
|
||||
# Set required Alembic config options programmatically
|
||||
config.set_main_option("script_location", str(alembic_dir))
|
||||
config.set_main_option(
|
||||
"file_template",
|
||||
"%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s",
|
||||
)
|
||||
config.set_main_option("timezone", "UTC")
|
||||
config.set_main_option("revision_environment", "false")
|
||||
config.set_main_option(
|
||||
"sqlalchemy.url", DatabaseType.get_db_url(app_config.database_path, database_type)
|
||||
)
|
||||
|
||||
command.upgrade(config, "head")
|
||||
logger.info("Migrations completed successfully")
|
||||
|
||||
_, session_maker = await get_or_create_db(app_config.database_path, database_type)
|
||||
await SearchRepository(session_maker).init_search_index()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error running migrations: {e}")
|
||||
raise
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import logfire
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
@@ -43,7 +44,10 @@ async def get_engine_factory(
|
||||
project_config: ProjectConfigDep,
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
|
||||
"""Get engine and session maker."""
|
||||
return await db.get_or_create_db(project_config.database_path)
|
||||
engine, session_maker = await db.get_or_create_db(project_config.database_path)
|
||||
if project_config.env != "test":
|
||||
logfire.instrument_sqlalchemy(engine=engine)
|
||||
return engine, session_maker
|
||||
|
||||
|
||||
EngineFactoryDep = Annotated[
|
||||
|
||||
@@ -2,7 +2,7 @@ from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
|
||||
BASE_URL = "http://test"
|
||||
BASE_URL = "memory://"
|
||||
|
||||
# Create shared async client
|
||||
client = AsyncClient(transport=ASGITransport(app=fastapi_app), base_url=BASE_URL)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Knowledge graph management tools for Basic Memory MCP server."""
|
||||
|
||||
import logfire
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post
|
||||
from basic_memory.schemas.base import Permalink
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.schemas.request import (
|
||||
GetEntitiesRequest,
|
||||
)
|
||||
@@ -16,15 +18,17 @@ from basic_memory.mcp.async_client import client
|
||||
@mcp.tool(
|
||||
description="Get complete information about a specific entity including observations and relations",
|
||||
)
|
||||
async def get_entity(permalink: Permalink) -> EntityResponse:
|
||||
async def get_entity(identifier: str) -> EntityResponse:
|
||||
"""Get a specific entity info by its permalink.
|
||||
|
||||
Args:
|
||||
permalink: Path identifier for the entity
|
||||
identifier: Path identifier for the entity
|
||||
"""
|
||||
url = f"/knowledge/entities/{permalink}"
|
||||
response = await call_get(client, url)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
with logfire.span("Getting entity", permalink=identifier): # pyright: ignore [reportGeneralTypeIssues]
|
||||
permalink = memory_url_path(identifier)
|
||||
url = f"/knowledge/entities/{permalink}"
|
||||
response = await call_get(client, url)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -39,11 +43,16 @@ async def get_entities(request: GetEntitiesRequest) -> EntityListResponse:
|
||||
Returns:
|
||||
EntityListResponse containing complete details for each requested entity
|
||||
"""
|
||||
url = "/knowledge/entities"
|
||||
response = await call_get(
|
||||
client, url, params=[("permalink", permalink) for permalink in request.permalinks]
|
||||
)
|
||||
return EntityListResponse.model_validate(response.json())
|
||||
with logfire.span("Getting multiple entities", permalink_count=len(request.permalinks)): # pyright: ignore [reportGeneralTypeIssues]
|
||||
url = "/knowledge/entities"
|
||||
response = await call_get(
|
||||
client,
|
||||
url,
|
||||
params=[
|
||||
("permalink", memory_url_path(identifier)) for identifier in request.permalinks
|
||||
],
|
||||
)
|
||||
return EntityListResponse.model_validate(response.json())
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -51,6 +60,9 @@ async def get_entities(request: GetEntitiesRequest) -> EntityListResponse:
|
||||
)
|
||||
async def delete_entities(request: DeleteEntitiesRequest) -> DeleteEntitiesResponse:
|
||||
"""Delete entities from the knowledge graph."""
|
||||
url = "/knowledge/entities/delete"
|
||||
response = await call_post(client, url, json=request.model_dump())
|
||||
return DeleteEntitiesResponse.model_validate(response.json())
|
||||
with logfire.span("Deleting entities", permalink_count=len(request.permalinks)): # pyright: ignore [reportGeneralTypeIssues]
|
||||
url = "/knowledge/entities/delete"
|
||||
|
||||
request.permalinks = [memory_url_path(permlink) for permlink in request.permalinks]
|
||||
response = await call_post(client, url, json=request.model_dump())
|
||||
return DeleteEntitiesResponse.model_validate(response.json())
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import Optional, Literal, List
|
||||
|
||||
from loguru import logger
|
||||
import logfire
|
||||
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
@@ -32,7 +33,9 @@ async def build_context(
|
||||
url: MemoryUrl,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
max_results: int = 10,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get context needed to continue a discussion.
|
||||
|
||||
@@ -44,7 +47,9 @@ async def build_context(
|
||||
url: memory:// URI pointing to discussion content (e.g. memory://specs/search)
|
||||
depth: How many relation hops to traverse (1-3 recommended for performance)
|
||||
timeframe: How far back to look. Supports natural language like "2 days ago", "last week"
|
||||
max_results: Maximum number of results to return (default: 10)
|
||||
page: Page number of results to return (default: 1)
|
||||
page_size: Number of results to return per page (default: 10)
|
||||
max_related: Maximum number of related results to return (default: 10)
|
||||
|
||||
Returns:
|
||||
GraphContext containing:
|
||||
@@ -65,14 +70,21 @@ async def build_context(
|
||||
# Research the history of a feature
|
||||
build_context("memory://features/knowledge-graph", timeframe="3 months ago")
|
||||
"""
|
||||
logger.info(f"Building context from {url}")
|
||||
url = normalize_memory_url(url)
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/memory/{memory_url_path(url)}",
|
||||
params={"depth": depth, "timeframe": timeframe, "max_results": max_results},
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
with logfire.span("Building context", url=url, depth=depth, timeframe=timeframe): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(f"Building context from {url}")
|
||||
url = normalize_memory_url(url)
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/memory/{memory_url_path(url)}",
|
||||
params={
|
||||
"depth": depth,
|
||||
"timeframe": timeframe,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"max_related": max_related,
|
||||
},
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -91,7 +103,9 @@ async def recent_activity(
|
||||
type: List[Literal["entity", "observation", "relation"]] = [],
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
max_results: int = 10,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get recent activity across the knowledge base.
|
||||
|
||||
@@ -106,7 +120,9 @@ async def recent_activity(
|
||||
- Relative: "2 days ago", "last week", "yesterday"
|
||||
- Points in time: "2024-01-01", "January 1st"
|
||||
- Standard format: "7d", "24h"
|
||||
max_results: Maximum number of results to return (default: 10)
|
||||
page: Page number of results to return (default: 1)
|
||||
page_size: Number of results to return per page (default: 10)
|
||||
max_related: Maximum number of related results to return (default: 10)
|
||||
|
||||
Returns:
|
||||
GraphContext containing:
|
||||
@@ -132,20 +148,23 @@ async def recent_activity(
|
||||
- For focused queries, consider using build_context with a specific URI
|
||||
- Max timeframe is 1 year in the past
|
||||
"""
|
||||
logger.info(
|
||||
f"Getting recent activity from {type}, depth={depth}, timeframe={timeframe}, max_results={max_results}"
|
||||
)
|
||||
params = {
|
||||
"depth": depth,
|
||||
"timeframe": timeframe,
|
||||
"max_results": max_results,
|
||||
}
|
||||
if type:
|
||||
params["type"] = type
|
||||
with logfire.span("Getting recent activity", type=type, depth=depth, timeframe=timeframe): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(
|
||||
f"Getting recent activity from {type}, depth={depth}, timeframe={timeframe}, page={page}, page_size={page_size}, max_related={max_related}"
|
||||
)
|
||||
params = {
|
||||
"depth": depth,
|
||||
"timeframe": timeframe,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"max_related": max_related,
|
||||
}
|
||||
if type:
|
||||
params["type"] = type
|
||||
|
||||
response = await call_get(
|
||||
client,
|
||||
"/memory/recent",
|
||||
params=params,
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
response = await call_get(
|
||||
client,
|
||||
"/memory/recent",
|
||||
params=params,
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
|
||||
@@ -7,97 +7,176 @@ while leveraging the underlying knowledge graph structure.
|
||||
from typing import Optional, List
|
||||
|
||||
from loguru import logger
|
||||
import logfire
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.schemas import EntityResponse, DeleteEntitiesResponse
|
||||
from basic_memory.schemas.base import Entity
|
||||
from basic_memory.mcp.tools.utils import call_get, call_put, call_delete
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Create or update a markdown note. Returns the permalink for referencing.",
|
||||
description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
|
||||
)
|
||||
async def write_note(
|
||||
title: str,
|
||||
content: str,
|
||||
folder: str,
|
||||
tags: Optional[List[str]] = None,
|
||||
verbose: bool = False,
|
||||
) -> EntityResponse | str:
|
||||
) -> str:
|
||||
"""Write a markdown note to the knowledge base.
|
||||
|
||||
The content can include semantic observations and relations using markdown syntax.
|
||||
Relations can be specified either explicitly or through inline wiki-style links:
|
||||
|
||||
Observations format:
|
||||
`- [category] Observation text #tag1 #tag2 (optional context)`
|
||||
|
||||
Examples:
|
||||
`- [design] Files are the source of truth #architecture (All state comes from files)`
|
||||
`- [tech] Using SQLite for storage #implementation`
|
||||
`- [note] Need to add error handling #todo`
|
||||
|
||||
Relations format:
|
||||
- Explicit: `- relation_type [[Entity]] (optional context)`
|
||||
- Inline: Any `[[Entity]]` reference creates a relation
|
||||
|
||||
Examples:
|
||||
`- depends_on [[Content Parser]] (Need for semantic extraction)`
|
||||
`- implements [[Search Spec]] (Initial implementation)`
|
||||
`- This feature extends [[Base Design]] and uses [[Core Utils]]`
|
||||
|
||||
Args:
|
||||
title: The title of the note
|
||||
content: Markdown content for the note
|
||||
content: Markdown content for the note, can include observations and relations
|
||||
folder: the folder where the file should be saved
|
||||
tags: Optional list of tags to categorize the note
|
||||
verbose: If True, returns full EntityResponse with semantic info
|
||||
|
||||
Returns:
|
||||
If verbose=False: Permalink that can be used to reference the note
|
||||
If verbose=True: EntityResponse with full semantic details
|
||||
|
||||
Examples:
|
||||
# Create a simple note
|
||||
write_note(
|
||||
tile="Meeting Notes: Project Planning.md",
|
||||
content="# Key Points\\n\\n- Discussed timeline\\n- Set priorities"
|
||||
folder="notes"
|
||||
)
|
||||
|
||||
# Create note with tags
|
||||
write_note(
|
||||
title="Security Review",
|
||||
content="# Findings\\n\\n1. Updated auth flow\\n2. Added rate limiting",
|
||||
folder="security",
|
||||
tags=["security", "development"]
|
||||
)
|
||||
A markdown formatted summary of the semantic content, including:
|
||||
- Creation/update status
|
||||
- File path and checksum
|
||||
- Observation counts by category
|
||||
- Relation counts (resolved/unresolved)
|
||||
- Tags if present
|
||||
"""
|
||||
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
|
||||
with logfire.span("Writing note", title=title, folder=folder): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
|
||||
|
||||
# Create the entity request
|
||||
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
|
||||
entity = Entity(
|
||||
title=title,
|
||||
folder=folder,
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
entity_metadata=metadata,
|
||||
)
|
||||
# Create the entity request
|
||||
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
|
||||
entity = Entity(
|
||||
title=title,
|
||||
folder=folder,
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
entity_metadata=metadata,
|
||||
)
|
||||
|
||||
# Use existing knowledge tool
|
||||
logger.info(f"Creating {entity.permalink}")
|
||||
url = f"/knowledge/entities/{entity.permalink}"
|
||||
response = await call_put(client, url, json=entity.model_dump())
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
return result if verbose else result.permalink
|
||||
# Create or update via knowledge API
|
||||
logger.info(f"Creating {entity.permalink}")
|
||||
url = f"/knowledge/entities/{entity.permalink}"
|
||||
response = await call_put(client, url, json=entity.model_dump())
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
|
||||
# Format semantic summary based on status code
|
||||
action = "Created" if response.status_code == 201 else "Updated"
|
||||
assert result.checksum is not None
|
||||
summary = [
|
||||
f"# {action} {result.file_path} ({result.checksum[:8]})",
|
||||
f"permalink: {result.permalink}",
|
||||
]
|
||||
|
||||
if result.observations:
|
||||
categories = {}
|
||||
for obs in result.observations:
|
||||
categories[obs.category] = categories.get(obs.category, 0) + 1
|
||||
|
||||
summary.append("\n## Observations")
|
||||
for category, count in sorted(categories.items()):
|
||||
summary.append(f"- {category}: {count}")
|
||||
|
||||
if result.relations:
|
||||
unresolved = sum(1 for r in result.relations if not r.to_id)
|
||||
resolved = len(result.relations) - unresolved
|
||||
|
||||
summary.append("\n## Relations")
|
||||
summary.append(f"- Resolved: {resolved}")
|
||||
if unresolved:
|
||||
summary.append(f"- Unresolved: {unresolved}")
|
||||
summary.append("\nUnresolved relations will be retried on next sync.")
|
||||
|
||||
if tags:
|
||||
summary.append(f"\n## Tags\n- {', '.join(tags)}")
|
||||
|
||||
return "\n".join(summary)
|
||||
|
||||
|
||||
@mcp.tool(description="Read a note's content by its title or permalink")
|
||||
async def read_note(identifier: str) -> str:
|
||||
"""Get the markdown content of a note.
|
||||
Uses the resource router to return the actual file content.
|
||||
@mcp.tool(description="Read note content by title, permalink, relation, or pattern")
|
||||
async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
|
||||
"""Get note content in unified diff format.
|
||||
|
||||
The content is returned in a unified diff inspired format:
|
||||
```
|
||||
--- memory://docs/example 2025-01-31T19:32:49 7d9f1c8b
|
||||
<document content>
|
||||
```
|
||||
|
||||
Multiple documents (from relations or pattern matches) are separated by
|
||||
additional headers.
|
||||
|
||||
Args:
|
||||
identifier: Note title or permalink
|
||||
identifier: Can be one of:
|
||||
- Note title ("Project Planning")
|
||||
- Note permalink ("docs/example")
|
||||
- Relation path ("docs/example/depends-on/other-doc")
|
||||
- Pattern match ("docs/*-architecture")
|
||||
page: the page number of results to return (default 1)
|
||||
page_size: the number of results to return per page (default 10)
|
||||
|
||||
Returns:
|
||||
The note's markdown content
|
||||
Document content in unified diff format. For single documents, returns
|
||||
just that document's content. For relations or pattern matches, returns
|
||||
multiple documents separated by unified diff headers.
|
||||
|
||||
Examples:
|
||||
# Read by title
|
||||
read_note("Meeting Notes: Project Planning")
|
||||
# Single document
|
||||
content = await read_note("Project Planning")
|
||||
|
||||
# Read by permalink
|
||||
read_note("notes/project-planning")
|
||||
content = await read_note("docs/architecture/file-first")
|
||||
|
||||
Raises:
|
||||
ValueError: If the note cannot be found
|
||||
# Follow relation
|
||||
content = await read_note("docs/architecture/depends-on/docs/content-parser")
|
||||
|
||||
# Pattern matching
|
||||
content = await read_note("docs/*-architecture") # All architecture docs
|
||||
content = await read_note("docs/*/implements/*") # Find implementations
|
||||
|
||||
Output format:
|
||||
```
|
||||
--- memory://docs/example 2025-01-31T19:32:49 7d9f1c8b
|
||||
<first document content>
|
||||
|
||||
--- memory://docs/other 2025-01-30T15:45:22 a1b2c3d4
|
||||
<second document content>
|
||||
```
|
||||
|
||||
The headers include:
|
||||
- Full memory:// URI for the document
|
||||
- Last modified timestamp
|
||||
- Content checksum
|
||||
"""
|
||||
response = await call_get(client, f"/resource/{identifier}")
|
||||
return response.text
|
||||
with logfire.span("Reading note", identifier=identifier): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(f"Reading note {identifier}")
|
||||
url = memory_url_path(identifier)
|
||||
response = await call_get(
|
||||
client, f"/resource/{url}", params={"page": page, "page_size": page_size}
|
||||
)
|
||||
return response.text
|
||||
|
||||
|
||||
@mcp.tool(description="Delete a note by title or permalink")
|
||||
@@ -117,6 +196,7 @@ async def delete_note(identifier: str) -> bool:
|
||||
# Delete by permalink
|
||||
delete_note("notes/project-planning")
|
||||
"""
|
||||
response = await call_delete(client, f"/knowledge/entities/{identifier}")
|
||||
result = DeleteEntitiesResponse.model_validate(response.json())
|
||||
return result.deleted
|
||||
with logfire.span("Deleting note", identifier=identifier): # pyright: ignore [reportGeneralTypeIssues]
|
||||
response = await call_delete(client, f"/knowledge/entities/{identifier}")
|
||||
result = DeleteEntitiesResponse.model_validate(response.json())
|
||||
return result.deleted
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Search tools for Basic Memory MCP server."""
|
||||
|
||||
import logfire
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
@@ -11,7 +12,7 @@ from basic_memory.mcp.async_client import client
|
||||
@mcp.tool(
|
||||
description="Search across all content in basic-memory, including documents and entities",
|
||||
)
|
||||
async def search(query: SearchQuery) -> SearchResponse:
|
||||
async def search(query: SearchQuery, page: int = 1, page_size: int = 10) -> SearchResponse:
|
||||
"""Search across all content in basic-memory.
|
||||
|
||||
Args:
|
||||
@@ -20,10 +21,18 @@ async def search(query: SearchQuery) -> SearchResponse:
|
||||
- types: Optional list of content types to search ("document" or "entity")
|
||||
- entity_types: Optional list of entity types to filter by
|
||||
- after_date: Optional date filter for recent content
|
||||
page: the page number of results to return (default 1)
|
||||
page_size: the number of results to return per page (default 10)
|
||||
|
||||
Returns:
|
||||
SearchResponse with search results and metadata
|
||||
"""
|
||||
logger.info(f"Searching for {query.text}")
|
||||
response = await call_post(client, "/search/", json=query.model_dump())
|
||||
return SearchResponse.model_validate(response.json())
|
||||
with logfire.span("Searching for {query}", query=query): # pyright: ignore [reportGeneralTypeIssues]
|
||||
logger.info(f"Searching for {query}")
|
||||
response = await call_post(
|
||||
client,
|
||||
"/search/",
|
||||
json=query.model_dump(),
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
return SearchResponse.model_validate(response.json())
|
||||
|
||||
@@ -68,24 +68,40 @@ class SearchRepository:
|
||||
|
||||
async def init_search_index(self):
|
||||
"""Create or recreate the search index."""
|
||||
|
||||
logger.info("Initializing search index")
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(CREATE_SEARCH_INDEX)
|
||||
await session.commit()
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(CREATE_SEARCH_INDEX)
|
||||
await session.commit()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error initializing search index: {e}")
|
||||
raise e
|
||||
|
||||
def _quote_search_term(self, term: str) -> str:
|
||||
"""Add quotes if term contains special characters.
|
||||
For FTS5, special characters and phrases need to be quoted to be treated as a single token.
|
||||
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a search term for FTS5 query.
|
||||
|
||||
Args:
|
||||
term: The search term to prepare
|
||||
is_prefix: Whether to add prefix search capability (* suffix)
|
||||
|
||||
For FTS5:
|
||||
- Special characters and phrases need to be quoted
|
||||
- Terms with spaces or special chars need quotes
|
||||
"""
|
||||
# List of special characters that need quoting
|
||||
special_chars = ["/", "*", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
|
||||
if "*" in term:
|
||||
return term
|
||||
|
||||
# List of special characters that need quoting (excluding *)
|
||||
special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
|
||||
|
||||
# Check if term contains any special characters
|
||||
if any(c in term for c in special_chars):
|
||||
# If the term already contains quotes, escape them
|
||||
needs_quotes = any(c in term for c in special_chars)
|
||||
|
||||
if needs_quotes:
|
||||
# If the term already contains quotes, escape them and add a wildcard
|
||||
term = term.replace('"', '""')
|
||||
return f'"{term}"'
|
||||
term = f'"{term}"*'
|
||||
|
||||
return term
|
||||
|
||||
async def search(
|
||||
@@ -98,6 +114,7 @@ class SearchRepository:
|
||||
after_date: Optional[datetime] = None,
|
||||
entity_types: Optional[List[str]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content with fuzzy matching."""
|
||||
conditions = []
|
||||
@@ -106,14 +123,14 @@ class SearchRepository:
|
||||
|
||||
# Handle text search for title and content
|
||||
if search_text:
|
||||
search_text = self._quote_search_term(search_text.lower().strip())
|
||||
params["text"] = f"{search_text}*"
|
||||
search_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = search_text
|
||||
conditions.append("(title MATCH :text OR content MATCH :text)")
|
||||
|
||||
# Handle title match search
|
||||
if title:
|
||||
title_text = self._quote_search_term(title.lower().strip())
|
||||
params["text"] = f"{title_text}*"
|
||||
title_text = self._prepare_search_term(title.strip())
|
||||
params["text"] = title_text
|
||||
conditions.append("title MATCH :text")
|
||||
|
||||
# Handle permalink exact search
|
||||
@@ -123,8 +140,15 @@ class SearchRepository:
|
||||
|
||||
# Handle permalink match search, supports *
|
||||
if permalink_match:
|
||||
params["permalink"] = self._quote_search_term(permalink_match)
|
||||
conditions.append("permalink MATCH :permalink")
|
||||
# Clean and prepare permalink for FTS5 GLOB match
|
||||
permalink_text = self._prepare_search_term(
|
||||
permalink_match.lower().strip(), is_prefix=False
|
||||
)
|
||||
params["permalink"] = permalink_text
|
||||
if "*" in permalink_match:
|
||||
conditions.append("permalink GLOB :permalink")
|
||||
else:
|
||||
conditions.append("permalink MATCH :permalink")
|
||||
|
||||
# Handle type filter
|
||||
if types:
|
||||
@@ -146,6 +170,7 @@ class SearchRepository:
|
||||
|
||||
# set limit on search query
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
@@ -171,9 +196,10 @@ class SearchRepository:
|
||||
WHERE {where_clause}
|
||||
ORDER BY score ASC {order_by_clause}
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
"""
|
||||
|
||||
# logger.debug(f"Search {sql} params: {params}")
|
||||
logger.debug(f"Search {sql} params: {params}")
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text(sql), params)
|
||||
rows = result.fetchall()
|
||||
@@ -199,8 +225,11 @@ class SearchRepository:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# for r in results:
|
||||
# logger.debug(f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}")
|
||||
logger.debug(f"Found {len(results)} search results")
|
||||
for r in results:
|
||||
logger.debug(
|
||||
f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -233,7 +262,7 @@ class SearchRepository:
|
||||
"""),
|
||||
search_index_row.to_insert(),
|
||||
)
|
||||
logger.debug(f"indexed permalink {search_index_row.permalink}")
|
||||
logger.debug(f"indexed row {search_index_row}")
|
||||
await session.commit()
|
||||
|
||||
async def delete_by_permalink(self, permalink: str):
|
||||
|
||||
@@ -111,3 +111,6 @@ class GraphContext(BaseModel):
|
||||
|
||||
# Context metadata
|
||||
metadata: MemoryMetadata
|
||||
|
||||
page: int = 1
|
||||
page_size: int = 1
|
||||
|
||||
@@ -51,7 +51,7 @@ class GetEntitiesRequest(BaseModel):
|
||||
discovered through search.
|
||||
"""
|
||||
|
||||
permalinks: Annotated[List[Permalink], MinLen(1)]
|
||||
permalinks: Annotated[List[Permalink], MinLen(1), MaxLen(10)]
|
||||
|
||||
|
||||
class CreateRelationsRequest(BaseModel):
|
||||
|
||||
@@ -11,6 +11,7 @@ Key Features:
|
||||
4. Bulk operations return all affected items
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, AliasPath, AliasChoices
|
||||
@@ -43,6 +44,8 @@ class ObservationResponse(Observation, SQLAlchemyModel):
|
||||
}
|
||||
"""
|
||||
|
||||
permalink: Permalink
|
||||
|
||||
|
||||
class RelationResponse(Relation, SQLAlchemyModel):
|
||||
"""Response schema for relation operations.
|
||||
@@ -59,6 +62,8 @@ class RelationResponse(Relation, SQLAlchemyModel):
|
||||
}
|
||||
"""
|
||||
|
||||
permalink: Permalink
|
||||
|
||||
from_id: Permalink = Field(
|
||||
# use the permalink from the associated Entity
|
||||
# or the from_id value
|
||||
@@ -131,9 +136,12 @@ class EntityResponse(SQLAlchemyModel):
|
||||
file_path: str
|
||||
entity_type: EntityType
|
||||
entity_metadata: Optional[Dict] = None
|
||||
checksum: Optional[str] = None
|
||||
content_type: ContentType
|
||||
observations: List[ObservationResponse] = []
|
||||
relations: List[RelationResponse] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class EntityListResponse(SQLAlchemyModel):
|
||||
|
||||
@@ -102,6 +102,8 @@ class SearchResponse(BaseModel):
|
||||
"""Wrapper for search results."""
|
||||
|
||||
results: List[SearchResult]
|
||||
current_page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# Schema for future advanced search endpoint
|
||||
|
||||
@@ -54,11 +54,13 @@ class ContextService:
|
||||
types: Optional[List[SearchItemType]] = None,
|
||||
depth: int = 1,
|
||||
since: Optional[datetime] = None,
|
||||
max_results: int = 10,
|
||||
limit=10,
|
||||
offset=0,
|
||||
max_related: int = 10,
|
||||
):
|
||||
"""Build rich context from a memory:// URI."""
|
||||
logger.debug(
|
||||
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' max_results: '{max_results}'"
|
||||
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
|
||||
)
|
||||
|
||||
if memory_url:
|
||||
@@ -66,15 +68,21 @@ class ContextService:
|
||||
# Pattern matching - use search
|
||||
if "*" in path:
|
||||
logger.debug(f"Pattern search for '{path}'")
|
||||
primary = await self.search_repository.search(permalink_match=path)
|
||||
primary = await self.search_repository.search(
|
||||
permalink_match=path, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
# Direct lookup for exact path
|
||||
else:
|
||||
logger.debug(f"Direct lookup for '{path}'")
|
||||
primary = await self.search_repository.search(permalink=path)
|
||||
primary = await self.search_repository.search(
|
||||
permalink=path, limit=limit, offset=offset
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Build context for '{types}'")
|
||||
primary = await self.search_repository.search(types=types, after_date=since)
|
||||
primary = await self.search_repository.search(
|
||||
types=types, after_date=since, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
# Get type_id pairs for traversal
|
||||
|
||||
@@ -83,7 +91,7 @@ class ContextService:
|
||||
|
||||
# Find related content
|
||||
related = await self.find_related(
|
||||
type_id_pairs, max_depth=depth, since=since, max_results=max_results
|
||||
type_id_pairs, max_depth=depth, since=since, max_results=max_related
|
||||
)
|
||||
logger.debug(f"Found {len(related)} related results")
|
||||
for r in related:
|
||||
|
||||
@@ -185,6 +185,11 @@ class EntityService(BaseService[EntityModel]):
|
||||
raise EntityNotFoundError(f"Entity not found: {permalink}")
|
||||
return db_entity
|
||||
|
||||
async def get_entities_by_id(self, ids: List[int]) -> Sequence[EntityModel]:
|
||||
"""Get specific entities and their relationships."""
|
||||
logger.debug(f"Getting entities: {ids}")
|
||||
return await self.repository.find_by_ids(ids)
|
||||
|
||||
async def get_entities_by_permalinks(self, permalinks: List[str]) -> Sequence[EntityModel]:
|
||||
"""Get specific nodes and their relationships."""
|
||||
logger.debug(f"Getting entities permalinks: {permalinks}")
|
||||
|
||||
@@ -16,9 +16,10 @@ class LinkResolver:
|
||||
|
||||
Uses a combination of exact matching and search-based resolution:
|
||||
1. Try exact permalink match (fastest)
|
||||
2. Try exact title match
|
||||
3. Fall back to search for fuzzy matching
|
||||
4. Generate new permalink if no match found
|
||||
2. Try permalink pattern match (for wildcards)
|
||||
3. Try exact title match
|
||||
4. Fall back to search for fuzzy matching
|
||||
5. Generate new permalink if no match found
|
||||
"""
|
||||
|
||||
def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
|
||||
@@ -45,8 +46,8 @@ class LinkResolver:
|
||||
logger.debug(f"Found title match: {entity.title}")
|
||||
return entity
|
||||
|
||||
if use_search:
|
||||
# 3. Fall back to search for fuzzy matching on title if specified
|
||||
if use_search and "*" not in clean_text:
|
||||
# 3. Fall back to search for fuzzy matching on title
|
||||
results = await self.search_service.search(
|
||||
query=SearchQuery(title=clean_text, types=[SearchItemType.ENTITY]),
|
||||
)
|
||||
@@ -59,7 +60,7 @@ class LinkResolver:
|
||||
)
|
||||
return await self.entity_repository.get_by_permalink(best_match.permalink)
|
||||
|
||||
# if we couldn't find anything then return None
|
||||
# if we couldn't find anything then return None
|
||||
return None
|
||||
|
||||
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
|
||||
@@ -87,7 +88,7 @@ class LinkResolver:
|
||||
|
||||
return text, alias
|
||||
|
||||
def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> Entity:
|
||||
def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> SearchIndexRow:
|
||||
"""Select best match from search results.
|
||||
|
||||
Uses multiple criteria:
|
||||
|
||||
@@ -51,7 +51,7 @@ class SearchService:
|
||||
|
||||
logger.info("Reindex complete")
|
||||
|
||||
async def search(self, query: SearchQuery) -> List[SearchIndexRow]:
|
||||
async def search(self, query: SearchQuery, limit=10, offset=0) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content.
|
||||
|
||||
Supports three modes:
|
||||
@@ -75,7 +75,7 @@ class SearchService:
|
||||
else None
|
||||
)
|
||||
|
||||
# permalink search
|
||||
# search
|
||||
results = await self.repository.search(
|
||||
search_text=query.text,
|
||||
permalink=query.permalink,
|
||||
@@ -84,6 +84,8 @@ class SearchService:
|
||||
types=query.types,
|
||||
entity_types=query.entity_types,
|
||||
after_date=after_date,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
import logfire
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
@@ -61,105 +62,113 @@ class SyncService:
|
||||
|
||||
async def sync(self, directory: Path) -> SyncReport:
|
||||
"""Sync knowledge files with database."""
|
||||
changes = await self.scanner.find_knowledge_changes(directory)
|
||||
logger.info(f"Found {changes.total_changes} knowledge changes")
|
||||
|
||||
# Handle moves first
|
||||
for old_path, new_path in changes.moves.items():
|
||||
logger.debug(f"Moving entity: {old_path} -> {new_path}")
|
||||
entity = await self.entity_repository.get_by_file_path(old_path)
|
||||
if entity:
|
||||
# Update file_path but keep the same permalink for link stability
|
||||
updated = await self.entity_repository.update(
|
||||
entity.id, {"file_path": new_path, "checksum": changes.checksums[new_path]}
|
||||
)
|
||||
# update search index
|
||||
if updated:
|
||||
await self.search_service.index_entity(updated)
|
||||
with logfire.span("sync", directory=directory): # pyright: ignore [reportGeneralTypeIssues]
|
||||
changes = await self.scanner.find_knowledge_changes(directory)
|
||||
logger.info(f"Found {changes.total_changes} knowledge changes")
|
||||
|
||||
# Handle deletions next
|
||||
# remove rows from db for files no longer present
|
||||
for path in changes.deleted:
|
||||
await self.handle_entity_deletion(path)
|
||||
# Handle moves first
|
||||
for old_path, new_path in changes.moves.items():
|
||||
logger.debug(f"Moving entity: {old_path} -> {new_path}")
|
||||
entity = await self.entity_repository.get_by_file_path(old_path)
|
||||
if entity:
|
||||
# Update file_path but keep the same permalink for link stability
|
||||
updated = await self.entity_repository.update(
|
||||
entity.id, {"file_path": new_path, "checksum": changes.checksums[new_path]}
|
||||
)
|
||||
# update search index
|
||||
if updated:
|
||||
await self.search_service.index_entity(updated)
|
||||
|
||||
# Parse files that need updating
|
||||
parsed_entities: Dict[str, EntityMarkdown] = {}
|
||||
# Handle deletions next
|
||||
# remove rows from db for files no longer present
|
||||
for path in changes.deleted:
|
||||
await self.handle_entity_deletion(path)
|
||||
|
||||
for path in [*changes.new, *changes.modified]:
|
||||
entity_markdown = await self.entity_parser.parse_file(directory / path)
|
||||
parsed_entities[path] = entity_markdown
|
||||
# Parse files that need updating
|
||||
parsed_entities: Dict[str, EntityMarkdown] = {}
|
||||
|
||||
# First pass: Create/update entities
|
||||
# entities will have a null checksum to indicate they are not complete
|
||||
for path, entity_markdown in parsed_entities.items():
|
||||
# Get unique permalink and update markdown if needed
|
||||
permalink = await self.entity_service.resolve_permalink(
|
||||
Path(path), markdown=entity_markdown
|
||||
)
|
||||
for path in [*changes.new, *changes.modified]:
|
||||
entity_markdown = await self.entity_parser.parse_file(directory / path)
|
||||
parsed_entities[path] = entity_markdown
|
||||
|
||||
if permalink != entity_markdown.frontmatter.permalink:
|
||||
# Add/update permalink in frontmatter
|
||||
logger.info(f"Adding permalink '{permalink}' to file: {path}")
|
||||
|
||||
# update markdown
|
||||
entity_markdown.frontmatter.metadata["permalink"] = permalink
|
||||
|
||||
# update file frontmatter
|
||||
updated_checksum = await file_utils.update_frontmatter(
|
||||
directory / path, {"permalink": permalink}
|
||||
# First pass: Create/update entities
|
||||
# entities will have a null checksum to indicate they are not complete
|
||||
for path, entity_markdown in parsed_entities.items():
|
||||
# Get unique permalink and update markdown if needed
|
||||
permalink = await self.entity_service.resolve_permalink(
|
||||
Path(path), markdown=entity_markdown
|
||||
)
|
||||
|
||||
# Update checksum in changes report since file was modified
|
||||
changes.checksums[path] = updated_checksum
|
||||
if permalink != entity_markdown.frontmatter.permalink:
|
||||
# Add/update permalink in frontmatter
|
||||
logger.info(f"Adding permalink '{permalink}' to file: {path}")
|
||||
|
||||
# if the file is new, create an entity
|
||||
if path in changes.new:
|
||||
# Create entity with final permalink
|
||||
logger.debug(f"Creating new entity_markdown: {path}")
|
||||
await self.entity_service.create_entity_from_markdown(Path(path), entity_markdown)
|
||||
# otherwise we need to update the entity and observations
|
||||
else:
|
||||
logger.debug(f"Updating entity_markdown: {path}")
|
||||
await self.entity_service.update_entity_and_observations(
|
||||
# update markdown
|
||||
entity_markdown.frontmatter.metadata["permalink"] = permalink
|
||||
|
||||
# update file frontmatter
|
||||
updated_checksum = await file_utils.update_frontmatter(
|
||||
directory / path, {"permalink": permalink}
|
||||
)
|
||||
|
||||
# Update checksum in changes report since file was modified
|
||||
changes.checksums[path] = updated_checksum
|
||||
|
||||
# if the file is new, create an entity
|
||||
if path in changes.new:
|
||||
# Create entity with final permalink
|
||||
logger.debug(f"Creating new entity_markdown: {path}")
|
||||
await self.entity_service.create_entity_from_markdown(
|
||||
Path(path), entity_markdown
|
||||
)
|
||||
# otherwise we need to update the entity and observations
|
||||
else:
|
||||
logger.debug(f"Updating entity_markdown: {path}")
|
||||
await self.entity_service.update_entity_and_observations(
|
||||
Path(path), entity_markdown
|
||||
)
|
||||
|
||||
# Second pass
|
||||
for path, entity_markdown in parsed_entities.items():
|
||||
logger.debug(f"Updating relations for: {path}")
|
||||
|
||||
# Process relations
|
||||
checksum = changes.checksums[path]
|
||||
entity = await self.entity_service.update_entity_relations(
|
||||
Path(path), entity_markdown
|
||||
)
|
||||
|
||||
# Second pass
|
||||
for path, entity_markdown in parsed_entities.items():
|
||||
logger.debug(f"Updating relations for: {path}")
|
||||
# add to search index
|
||||
await self.search_service.index_entity(entity)
|
||||
|
||||
# Process relations
|
||||
checksum = changes.checksums[path]
|
||||
entity = await self.entity_service.update_entity_relations(Path(path), entity_markdown)
|
||||
# Set final checksum to mark sync complete
|
||||
await self.entity_repository.update(entity.id, {"checksum": checksum})
|
||||
|
||||
# add to search index
|
||||
await self.search_service.index_entity(entity)
|
||||
|
||||
# Set final checksum to mark sync complete
|
||||
await self.entity_repository.update(entity.id, {"checksum": checksum})
|
||||
|
||||
# Third pass: Try to resolve any forward references
|
||||
logger.debug("Attempting to resolve forward references")
|
||||
for relation in await self.relation_repository.find_unresolved_relations():
|
||||
target_entity = await self.entity_service.link_resolver.resolve_link(relation.to_name)
|
||||
# check we found a link that is not the source
|
||||
if target_entity and target_entity.id != relation.from_id:
|
||||
logger.debug(
|
||||
f"Resolved forward reference: {relation.to_name} -> {target_entity.permalink}"
|
||||
# Third pass: Try to resolve any forward references
|
||||
logger.debug("Attempting to resolve forward references")
|
||||
for relation in await self.relation_repository.find_unresolved_relations():
|
||||
target_entity = await self.entity_service.link_resolver.resolve_link(
|
||||
relation.to_name
|
||||
)
|
||||
|
||||
try:
|
||||
await self.relation_repository.update(
|
||||
relation.id,
|
||||
{
|
||||
"to_id": target_entity.id,
|
||||
"to_name": target_entity.title, # Update to actual title
|
||||
},
|
||||
# check we found a link that is not the source
|
||||
if target_entity and target_entity.id != relation.from_id:
|
||||
logger.debug(
|
||||
f"Resolved forward reference: {relation.to_name} -> {target_entity.permalink}"
|
||||
)
|
||||
except IntegrityError:
|
||||
logger.debug(f"Ignoring duplicate relation {relation}")
|
||||
|
||||
# update search index
|
||||
await self.search_service.index_entity(target_entity)
|
||||
try:
|
||||
await self.relation_repository.update(
|
||||
relation.id,
|
||||
{
|
||||
"to_id": target_entity.id,
|
||||
"to_name": target_entity.title, # Update to actual title
|
||||
},
|
||||
)
|
||||
except IntegrityError:
|
||||
logger.debug(f"Ignoring duplicate relation {relation}")
|
||||
|
||||
return changes
|
||||
# update search index
|
||||
await self.search_service.index_entity(target_entity)
|
||||
|
||||
return changes
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""Types and utilities for file sync."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Set, Dict, Optional
|
||||
|
||||
from watchfiles import Change
|
||||
|
||||
from typing import Set, Dict
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -9,8 +9,11 @@ from typing import Optional, Union
|
||||
from loguru import logger
|
||||
from unidecode import unidecode
|
||||
|
||||
import basic_memory
|
||||
from basic_memory.config import config
|
||||
|
||||
import logfire
|
||||
|
||||
|
||||
def generate_permalink(file_path: Union[Path, str]) -> str:
|
||||
"""Generate a stable permalink from a file path.
|
||||
@@ -61,19 +64,42 @@ def generate_permalink(file_path: Union[Path, str]) -> str:
|
||||
return "/".join(clean_segments)
|
||||
|
||||
|
||||
def setup_logging(home_dir: Path = config.home, log_file: Optional[str] = None) -> None:
|
||||
def setup_logging(
|
||||
home_dir: Path = config.home, log_file: Optional[str] = None, console: bool = True
|
||||
) -> None: # pragma: no cover
|
||||
"""
|
||||
Configure logging for the application.
|
||||
:param home_dir: the root directory for the application
|
||||
:param log_file: the name of the log file to write to
|
||||
:param app: the fastapi application instance
|
||||
:param console: whether to log to the console
|
||||
"""
|
||||
|
||||
# Remove default handler and any existing handlers
|
||||
logger.remove()
|
||||
|
||||
# Add file handler
|
||||
if log_file:
|
||||
# Add file handler if we are not running tests
|
||||
if log_file and config.env != "test":
|
||||
# enable pydantic logfire
|
||||
logfire.configure(
|
||||
code_source=logfire.CodeSource(
|
||||
repository="https://github.com/basicmachines-co/basic-memory",
|
||||
revision=basic_memory.__version__,
|
||||
root_path="/src/basic_memory",
|
||||
),
|
||||
environment=config.env,
|
||||
console=False,
|
||||
)
|
||||
logger.configure(handlers=[logfire.loguru_handler()])
|
||||
|
||||
# instrument code spans
|
||||
logfire.instrument_sqlite3()
|
||||
logfire.instrument_httpx()
|
||||
|
||||
# setup logger
|
||||
log_path = home_dir / log_file
|
||||
logger.add(
|
||||
str(log_path), # loguru expects a string path
|
||||
str(log_path),
|
||||
level=config.log_level,
|
||||
rotation="100 MB",
|
||||
retention="10 days",
|
||||
@@ -85,3 +111,5 @@ def setup_logging(home_dir: Path = config.home, log_file: Optional[str] = None)
|
||||
|
||||
# Add stderr handler
|
||||
logger.add(sys.stderr, level=config.log_level, backtrace=True, diagnose=True, colorize=True)
|
||||
|
||||
logger.info(f"ENV: '{config.env}' Log level: '{config.log_level}' Logging to {log_file}")
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from basic_memory.config import config
|
||||
|
||||
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
|
||||
config.env = "test"
|
||||
@@ -26,6 +26,25 @@ async def test_get_memory_context(client, test_graph):
|
||||
assert context.metadata.total_results == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_pagination(client, test_graph):
|
||||
"""Test getting context from memory URL."""
|
||||
response = await client.get("/memory/test/root?page=1&page_size=1")
|
||||
assert response.status_code == 200
|
||||
|
||||
context = GraphContext(**response.json())
|
||||
assert len(context.primary_results) == 1
|
||||
assert context.primary_results[0].permalink == "test/root"
|
||||
assert len(context.related_results) > 0
|
||||
|
||||
# Verify metadata
|
||||
assert context.metadata.uri == "test/root"
|
||||
assert context.metadata.depth == 1 # default depth
|
||||
# assert context.metadata["timeframe"] == "7d" # default timeframe
|
||||
assert isinstance(context.metadata.generated_at, datetime)
|
||||
assert context.metadata.total_results == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_pattern(client, test_graph):
|
||||
"""Test getting context with pattern matching."""
|
||||
@@ -91,6 +110,17 @@ async def test_recent_activity(client, test_graph):
|
||||
assert len(context.related_results) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_activity_pagination(client, test_graph):
|
||||
"""Test handling of paths."""
|
||||
response = await client.get("/memory/recent?page=1&page_size=1")
|
||||
assert response.status_code == 200
|
||||
|
||||
context = GraphContext(**response.json())
|
||||
assert len(context.primary_results) == 1
|
||||
assert len(context.related_results) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_activity_by_type(client, test_graph):
|
||||
"""Test handling of non-existent paths."""
|
||||
|
||||
@@ -5,6 +5,8 @@ from datetime import datetime, timezone
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.schemas import EntityResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_content(client, test_config, entity_repository):
|
||||
@@ -35,6 +37,35 @@ async def test_get_resource_content(client, test_config, entity_repository):
|
||||
assert response.text == content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_pagination(client, test_config, entity_repository):
|
||||
"""Test getting content by permalink with pagination."""
|
||||
# Create a test file
|
||||
content = "# Test Content\n\nThis is a test file."
|
||||
test_file = Path(test_config.home) / "test" / "test.md"
|
||||
test_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
test_file.write_text(content)
|
||||
|
||||
# Create entity referencing the file
|
||||
entity = await entity_repository.create(
|
||||
{
|
||||
"title": "Test Entity",
|
||||
"entity_type": "test",
|
||||
"permalink": "test/test",
|
||||
"file_path": "test/test.md", # Relative to config.home
|
||||
"content_type": "text/markdown",
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
)
|
||||
|
||||
# Test getting the content
|
||||
response = await client.get(f"/resource/{entity.permalink}", params={"page": 1, "page_size": 1})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert response.text == content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_by_title(client, test_config, entity_repository):
|
||||
"""Test getting content by permalink."""
|
||||
@@ -67,7 +98,7 @@ async def test_get_resource_missing_entity(client):
|
||||
"""Test 404 when entity doesn't exist."""
|
||||
response = await client.get("/resource/does/not/exist")
|
||||
assert response.status_code == 404
|
||||
assert "Entity not found" in response.json()["detail"]
|
||||
assert "Resource not found" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -89,3 +120,186 @@ async def test_get_resource_missing_file(client, test_config, entity_repository)
|
||||
response = await client.get(f"/resource/{entity.permalink}")
|
||||
assert response.status_code == 404
|
||||
assert "File not found" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_observation(client, test_config, entity_repository):
|
||||
"""Test getting content by observation permalink."""
|
||||
# Create entity
|
||||
content = "# Test Content\n\n- [note] an observation."
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity = EntityResponse(**entity_response)
|
||||
|
||||
assert len(entity.observations) == 1
|
||||
observation = entity.observations[0]
|
||||
|
||||
# Test getting the content via the observation
|
||||
response = await client.get(f"/resource/{observation.permalink}")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert (
|
||||
"""
|
||||
---
|
||||
title: Test Entity
|
||||
type: test
|
||||
permalink: test/test-entity
|
||||
---
|
||||
|
||||
# Test Content
|
||||
|
||||
- [note] an observation.
|
||||
""".strip()
|
||||
in response.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_entities(client, test_config, entity_repository):
|
||||
"""Test getting content by permalink match."""
|
||||
# Create entity
|
||||
content1 = "# Test Content\n"
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content1}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity1 = EntityResponse(**entity_response)
|
||||
|
||||
content2 = "# Related Content\n- links to [[Test Entity]]"
|
||||
data = {
|
||||
"title": "Related Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content2}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity2 = EntityResponse(**entity_response)
|
||||
|
||||
assert len(entity2.relations) == 1
|
||||
|
||||
# Test getting the content via the relation
|
||||
response = await client.get("/resource/test/*")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert (
|
||||
f"""
|
||||
--- memory://test/test-entity {entity1.updated_at.isoformat()} {entity1.checksum[:8]}
|
||||
|
||||
# Test Content
|
||||
|
||||
--- memory://test/related-entity {entity2.updated_at.isoformat()} {entity2.checksum[:8]}
|
||||
|
||||
# Related Content
|
||||
- links to [[Test Entity]]
|
||||
|
||||
""".strip()
|
||||
in response.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_entities_pagination(client, test_config, entity_repository):
|
||||
"""Test getting content by permalink match."""
|
||||
# Create entity
|
||||
content1 = "# Test Content\n"
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content1}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity1 = EntityResponse(**entity_response)
|
||||
assert entity1
|
||||
|
||||
content2 = "# Related Content\n- links to [[Test Entity]]"
|
||||
data = {
|
||||
"title": "Related Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content2}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity2 = EntityResponse(**entity_response)
|
||||
|
||||
assert len(entity2.relations) == 1
|
||||
|
||||
# Test getting second result
|
||||
response = await client.get("/resource/test/*", params={"page": 2, "page_size": 1})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert (
|
||||
"""
|
||||
---
|
||||
title: Related Entity
|
||||
type: test
|
||||
permalink: test/related-entity
|
||||
---
|
||||
|
||||
# Related Content
|
||||
- links to [[Test Entity]]
|
||||
""".strip()
|
||||
in response.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_relation(client, test_config, entity_repository):
|
||||
"""Test getting content by relation permalink."""
|
||||
# Create entity
|
||||
content1 = "# Test Content\n"
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content1}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity1 = EntityResponse(**entity_response)
|
||||
|
||||
content2 = "# Related Content\n- links to [[Test Entity]]"
|
||||
data = {
|
||||
"title": "Related Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content2}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity2 = EntityResponse(**entity_response)
|
||||
|
||||
assert len(entity2.relations) == 1
|
||||
relation = entity2.relations[0]
|
||||
|
||||
# Test getting the content via the relation
|
||||
response = await client.get(f"/resource/{relation.permalink}")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert (
|
||||
f"""
|
||||
--- memory://test/test-entity {entity1.updated_at.isoformat()} {entity1.checksum[:8]}
|
||||
|
||||
# Test Content
|
||||
|
||||
--- memory://test/related-entity {entity2.updated_at.isoformat()} {entity2.checksum[:8]}
|
||||
|
||||
# Related Content
|
||||
- links to [[Test Entity]]
|
||||
|
||||
""".strip()
|
||||
in response.text
|
||||
)
|
||||
|
||||
@@ -35,6 +35,18 @@ async def test_search_basic(client, indexed_entity):
|
||||
assert found, "Expected to find indexed entity in results"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_basic_pagination(client, indexed_entity):
|
||||
"""Test basic text search."""
|
||||
response = await client.post("/search/?page=3&page_size=1", json={"text": "search"})
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) == 1
|
||||
|
||||
assert search_results.current_page == 3
|
||||
assert search_results.page_size == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_type_filter(client, indexed_entity):
|
||||
"""Test search with type filter."""
|
||||
@@ -92,28 +104,6 @@ async def test_search_with_date_filter(client, indexed_entity):
|
||||
assert len(search_results.results) == 0
|
||||
|
||||
|
||||
@pytest.mark.skip("search scoring is not implemented yet")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_scoring(client, indexed_entity):
|
||||
"""Test search result scoring."""
|
||||
# Exact match should score higher
|
||||
exact_response = await client.post("/search/", json={"text": "TestComponent"})
|
||||
|
||||
# Partial match should score lower
|
||||
partial_response = await client.post("/search/", json={"text": "test"})
|
||||
|
||||
assert exact_response.status_code == 200
|
||||
assert partial_response.status_code == 200
|
||||
|
||||
exact_result = SearchResponse.model_validate(exact_response.json())
|
||||
partial_result = SearchResponse.model_validate(partial_response.json())
|
||||
|
||||
exact_score = exact_result.results[0].score
|
||||
partial_score = partial_result.results[0].score
|
||||
|
||||
assert exact_score > partial_score
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_empty(search_service, client):
|
||||
"""Test search with no matches."""
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Tests for the Basic Memory CLI tools."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
from basic_memory.cli.commands.tools import tool_app
|
||||
from basic_memory.schemas.response import EntityResponse
|
||||
from basic_memory.schemas.search import SearchResponse
|
||||
from basic_memory.schemas.memory import GraphContext
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_write_note():
|
||||
with patch("basic_memory.cli.commands.tools.mcp_write_note", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = "Created test/note.md (abc123)\npermalink: test/note"
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_read_note():
|
||||
with patch("basic_memory.cli.commands.tools.mcp_read_note", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = "--- memory://test/note 2025-01 abc123\nTest content"
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_search():
|
||||
with patch("basic_memory.cli.commands.tools.mcp_search", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = SearchResponse(results=[], current_page=1, page_size=10)
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_build_context():
|
||||
with patch("basic_memory.cli.commands.tools.mcp_build_context", new_callable=AsyncMock) as mock:
|
||||
now = datetime.now(timezone.utc)
|
||||
mock.return_value = GraphContext(
|
||||
primary_results=[],
|
||||
related_results=[],
|
||||
metadata={
|
||||
"uri": "test/*",
|
||||
"depth": 1,
|
||||
"timeframe": "7d",
|
||||
"generated_at": now,
|
||||
"total_results": 0,
|
||||
"total_relations": 0,
|
||||
},
|
||||
)
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_recent_activity():
|
||||
with patch(
|
||||
"basic_memory.cli.commands.tools.mcp_recent_activity", new_callable=AsyncMock
|
||||
) as mock:
|
||||
now = datetime.now(timezone.utc)
|
||||
mock.return_value = GraphContext(
|
||||
primary_results=[],
|
||||
related_results=[],
|
||||
metadata={
|
||||
"uri": None,
|
||||
"types": ["entity", "observation"],
|
||||
"depth": 1,
|
||||
"timeframe": "7d",
|
||||
"generated_at": now,
|
||||
"total_results": 0,
|
||||
"total_relations": 0,
|
||||
},
|
||||
)
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_entity():
|
||||
with patch("basic_memory.cli.commands.tools.mcp_get_entity", new_callable=AsyncMock) as mock:
|
||||
now = datetime.now(timezone.utc)
|
||||
mock.return_value = EntityResponse(
|
||||
permalink="test/entity",
|
||||
title="Test Entity",
|
||||
file_path="test/entity.md",
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
observations=[],
|
||||
relations=[],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
yield mock
|
||||
|
||||
|
||||
def test_write_note(mock_write_note):
|
||||
"""Test write_note command with basic arguments."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
[
|
||||
"write-note",
|
||||
"--title",
|
||||
"Test Note",
|
||||
"--content",
|
||||
"Test content",
|
||||
"--folder",
|
||||
"test",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_write_note.assert_awaited_once_with("Test Note", "Test content", "test", None)
|
||||
|
||||
|
||||
def test_write_note_with_tags(mock_write_note):
|
||||
"""Test write_note command with tags."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
[
|
||||
"write-note",
|
||||
"--title",
|
||||
"Test Note",
|
||||
"--content",
|
||||
"Test content",
|
||||
"--folder",
|
||||
"test",
|
||||
"--tags",
|
||||
"tag1",
|
||||
"--tags",
|
||||
"tag2",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_write_note.assert_awaited_once_with("Test Note", "Test content", "test", ["tag1", "tag2"])
|
||||
|
||||
|
||||
def test_read_note(mock_read_note):
|
||||
"""Test read_note command."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["read-note", "test/note"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_read_note.assert_awaited_once_with("test/note", 1, 10)
|
||||
|
||||
|
||||
def test_read_note_with_pagination(mock_read_note):
|
||||
"""Test read_note command with pagination."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["read-note", "test/note", "--page", "2", "--page-size", "5"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_read_note.assert_awaited_once_with("test/note", 2, 5)
|
||||
|
||||
|
||||
def test_search_basic(mock_search):
|
||||
"""Test basic search command."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["search", "test query"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_search.assert_awaited_once()
|
||||
args = mock_search.await_args[1]
|
||||
assert args["query"].text == "test query"
|
||||
|
||||
|
||||
def test_search_permalink(mock_search):
|
||||
"""Test search with permalink flag."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["search", "test/*", "--permalink"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_search.assert_awaited_once()
|
||||
args = mock_search.await_args[1]
|
||||
assert args["query"].permalink_match == "test/*"
|
||||
|
||||
|
||||
def test_search_title(mock_search):
|
||||
"""Test search with title flag."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["search", "test", "--title"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_search.assert_awaited_once()
|
||||
args = mock_search.await_args[1]
|
||||
assert args["query"].title == "test"
|
||||
|
||||
|
||||
def test_search_with_pagination(mock_search):
|
||||
"""Test search with pagination."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["search", "test", "--page", "2", "--page-size", "5"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_search.assert_awaited_once()
|
||||
args = mock_search.await_args[1]
|
||||
assert args["page"] == 2
|
||||
assert args["page_size"] == 5
|
||||
|
||||
|
||||
def test_build_context(mock_build_context):
|
||||
"""Test build_context command."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["build-context", "memory://test/*"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_build_context.assert_awaited_once_with(
|
||||
url="memory://test/*", depth=1, timeframe="7d", page=1, page_size=10, max_related=10
|
||||
)
|
||||
|
||||
|
||||
def test_build_context_with_options(mock_build_context):
|
||||
"""Test build_context command with all options."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
[
|
||||
"build-context",
|
||||
"memory://test/*",
|
||||
"--depth",
|
||||
"2",
|
||||
"--timeframe",
|
||||
"1d",
|
||||
"--page",
|
||||
"2",
|
||||
"--page-size",
|
||||
"5",
|
||||
"--max-related",
|
||||
"20",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_build_context.assert_awaited_once_with(
|
||||
url="memory://test/*", depth=2, timeframe="1d", page=2, page_size=5, max_related=20
|
||||
)
|
||||
|
||||
|
||||
def test_get_entity(mock_get_entity):
|
||||
"""Test get_entity command."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["get-entity", "test/entity"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_get_entity.assert_awaited_once_with(identifier="test/entity")
|
||||
|
||||
|
||||
def test_recent_activity(mock_recent_activity):
|
||||
"""Test recent_activity command with defaults."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
["recent-activity"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_recent_activity.assert_awaited_once_with(
|
||||
type=["entity", "observation", "relation"],
|
||||
depth=1,
|
||||
timeframe="7d",
|
||||
page=1,
|
||||
page_size=10,
|
||||
max_related=10,
|
||||
)
|
||||
|
||||
|
||||
def test_recent_activity_with_options(mock_recent_activity):
|
||||
"""Test recent_activity command with options."""
|
||||
result = runner.invoke(
|
||||
tool_app,
|
||||
[
|
||||
"recent-activity",
|
||||
"--type",
|
||||
"entity",
|
||||
"--type",
|
||||
"observation",
|
||||
"--depth",
|
||||
"2",
|
||||
"--timeframe",
|
||||
"1d",
|
||||
"--page",
|
||||
"2",
|
||||
"--page-size",
|
||||
"5",
|
||||
"--max-related",
|
||||
"20",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_recent_activity.assert_awaited_once_with(
|
||||
type=["entity", "observation"], depth=2, timeframe="1d", page=2, page_size=5, max_related=20
|
||||
)
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Tests for import_chatgpt command."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.app import import_app, app
|
||||
from basic_memory.cli.commands import import_chatgpt
|
||||
from basic_memory.config import config
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
|
||||
# Set up CLI runner
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_conversation():
|
||||
"""Sample ChatGPT conversation data for testing."""
|
||||
return {
|
||||
"title": "Test Conversation",
|
||||
"create_time": 1736616594.24054, # Example timestamp
|
||||
"update_time": 1736616603.164995,
|
||||
"mapping": {
|
||||
"root": {"id": "root", "message": None, "parent": None, "children": ["msg1"]},
|
||||
"msg1": {
|
||||
"id": "msg1",
|
||||
"message": {
|
||||
"id": "msg1",
|
||||
"author": {"role": "user", "name": None, "metadata": {}},
|
||||
"create_time": 1736616594.24054,
|
||||
"content": {"content_type": "text", "parts": ["Hello, this is a test message"]},
|
||||
"status": "finished_successfully",
|
||||
"metadata": {},
|
||||
},
|
||||
"parent": "root",
|
||||
"children": ["msg2"],
|
||||
},
|
||||
"msg2": {
|
||||
"id": "msg2",
|
||||
"message": {
|
||||
"id": "msg2",
|
||||
"author": {"role": "assistant", "name": None, "metadata": {}},
|
||||
"create_time": 1736616603.164995,
|
||||
"content": {"content_type": "text", "parts": ["This is a test response"]},
|
||||
"status": "finished_successfully",
|
||||
"metadata": {},
|
||||
},
|
||||
"parent": "msg1",
|
||||
"children": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_conversation_with_code():
|
||||
"""Sample conversation with code block."""
|
||||
conversation = {
|
||||
"title": "Code Test",
|
||||
"create_time": 1736616594.24054,
|
||||
"update_time": 1736616603.164995,
|
||||
"mapping": {
|
||||
"root": {"id": "root", "message": None, "parent": None, "children": ["msg1"]},
|
||||
"msg1": {
|
||||
"id": "msg1",
|
||||
"message": {
|
||||
"id": "msg1",
|
||||
"author": {"role": "assistant", "name": None, "metadata": {}},
|
||||
"create_time": 1736616594.24054,
|
||||
"content": {
|
||||
"content_type": "code",
|
||||
"language": "python",
|
||||
"text": "def hello():\n print('Hello world!')",
|
||||
},
|
||||
"status": "finished_successfully",
|
||||
"metadata": {},
|
||||
},
|
||||
"parent": "root",
|
||||
"children": [],
|
||||
},
|
||||
"msg2": {
|
||||
"id": "msg2",
|
||||
"message": {
|
||||
"id": "msg2",
|
||||
"author": {"role": "assistant", "name": None, "metadata": {}},
|
||||
"create_time": 1736616594.24054,
|
||||
"status": "finished_successfully",
|
||||
"metadata": {},
|
||||
},
|
||||
"parent": "root",
|
||||
"children": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
return conversation
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_conversation_with_hidden():
|
||||
"""Sample conversation with hidden messages."""
|
||||
conversation = {
|
||||
"title": "Hidden Test",
|
||||
"create_time": 1736616594.24054,
|
||||
"update_time": 1736616603.164995,
|
||||
"mapping": {
|
||||
"root": {
|
||||
"id": "root",
|
||||
"message": None,
|
||||
"parent": None,
|
||||
"children": ["visible", "hidden"],
|
||||
},
|
||||
"visible": {
|
||||
"id": "visible",
|
||||
"message": {
|
||||
"id": "visible",
|
||||
"author": {"role": "user", "name": None, "metadata": {}},
|
||||
"create_time": 1736616594.24054,
|
||||
"content": {"content_type": "text", "parts": ["Visible message"]},
|
||||
"status": "finished_successfully",
|
||||
"metadata": {},
|
||||
},
|
||||
"parent": "root",
|
||||
"children": [],
|
||||
},
|
||||
"hidden": {
|
||||
"id": "hidden",
|
||||
"message": {
|
||||
"id": "hidden",
|
||||
"author": {"role": "system", "name": None, "metadata": {}},
|
||||
"create_time": 1736616594.24054,
|
||||
"content": {"content_type": "text", "parts": ["Hidden message"]},
|
||||
"status": "finished_successfully",
|
||||
"metadata": {"is_visually_hidden_from_conversation": True},
|
||||
},
|
||||
"parent": "root",
|
||||
"children": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
return conversation
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_chatgpt_json(tmp_path, sample_conversation):
|
||||
"""Create a sample ChatGPT JSON file."""
|
||||
json_file = tmp_path / "conversations.json"
|
||||
with open(json_file, "w") as f:
|
||||
json.dump([sample_conversation], f)
|
||||
return json_file
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_chatgpt_json(tmp_path, sample_chatgpt_json):
|
||||
"""Test importing conversations from JSON."""
|
||||
entity_parser = EntityParser(tmp_path)
|
||||
processor = MarkdownProcessor(entity_parser)
|
||||
|
||||
results = await import_chatgpt.process_chatgpt_json(sample_chatgpt_json, tmp_path, processor)
|
||||
|
||||
assert results["conversations"] == 1
|
||||
assert results["messages"] == 2
|
||||
|
||||
# Check conversation file exists
|
||||
conv_path = tmp_path / "20250111-test-conversation.md"
|
||||
assert conv_path.exists()
|
||||
|
||||
# Check content formatting
|
||||
content = conv_path.read_text()
|
||||
assert "# Test Conversation" in content
|
||||
assert "### User" in content
|
||||
assert "Hello, this is a test message" in content
|
||||
assert "### Assistant" in content
|
||||
assert "This is a test response" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_code_blocks(tmp_path, sample_conversation_with_code):
|
||||
"""Test handling of code blocks."""
|
||||
entity_parser = EntityParser(tmp_path)
|
||||
processor = MarkdownProcessor(entity_parser)
|
||||
|
||||
# Create test file
|
||||
json_file = tmp_path / "code_test.json"
|
||||
with open(json_file, "w") as f:
|
||||
json.dump([sample_conversation_with_code], f)
|
||||
|
||||
await import_chatgpt.process_chatgpt_json(json_file, tmp_path, processor)
|
||||
|
||||
# Check content
|
||||
conv_path = tmp_path / "20250111-code-test.md"
|
||||
content = conv_path.read_text()
|
||||
assert "```python" in content
|
||||
assert "def hello():" in content
|
||||
assert "```" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hidden_messages(tmp_path, sample_conversation_with_hidden):
|
||||
"""Test handling of hidden messages."""
|
||||
entity_parser = EntityParser(tmp_path)
|
||||
processor = MarkdownProcessor(entity_parser)
|
||||
|
||||
# Create test file
|
||||
json_file = tmp_path / "hidden_test.json"
|
||||
with open(json_file, "w") as f:
|
||||
json.dump([sample_conversation_with_hidden], f)
|
||||
|
||||
results = await import_chatgpt.process_chatgpt_json(json_file, tmp_path, processor)
|
||||
|
||||
# Should only count visible messages
|
||||
assert results["messages"] == 1
|
||||
|
||||
# Check content
|
||||
conv_path = tmp_path / "20250111-hidden-test.md"
|
||||
content = conv_path.read_text()
|
||||
assert "Visible message" in content
|
||||
assert "Hidden message" not in content
|
||||
|
||||
|
||||
def test_import_chatgpt_command_file_not_found(tmp_path):
|
||||
"""Test error handling for nonexistent file."""
|
||||
nonexistent = tmp_path / "nonexistent.json"
|
||||
result = runner.invoke(app, ["import", "chatgpt", str(nonexistent)])
|
||||
assert result.exit_code == 1
|
||||
assert "File not found" in result.output
|
||||
|
||||
|
||||
def test_import_chatgpt_command_success(tmp_path, sample_chatgpt_json, monkeypatch):
|
||||
"""Test successful conversation import via command."""
|
||||
# Set up test environment
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
# Run import
|
||||
result = runner.invoke(import_app, ["chatgpt", str(sample_chatgpt_json)])
|
||||
assert result.exit_code == 0
|
||||
assert "Import complete" in result.output
|
||||
assert "Imported 1 conversations" in result.output
|
||||
assert "Containing 2 messages" in result.output
|
||||
|
||||
|
||||
def test_import_chatgpt_command_invalid_json(tmp_path):
|
||||
"""Test error handling for invalid JSON."""
|
||||
# Create invalid JSON file
|
||||
invalid_file = tmp_path / "invalid.json"
|
||||
invalid_file.write_text("not json")
|
||||
|
||||
result = runner.invoke(import_app, ["chatgpt", str(invalid_file)])
|
||||
assert result.exit_code == 1
|
||||
assert "Error during import" in result.output
|
||||
|
||||
|
||||
def test_import_chatgpt_with_custom_folder(tmp_path, sample_chatgpt_json, monkeypatch):
|
||||
"""Test import with custom conversations folder."""
|
||||
# Set up test environment
|
||||
config.home = tmp_path
|
||||
conversations_folder = "chats"
|
||||
|
||||
# Run import
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"import",
|
||||
"chatgpt",
|
||||
str(sample_chatgpt_json),
|
||||
"--folder",
|
||||
conversations_folder,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Check files in custom folder
|
||||
conv_path = tmp_path / conversations_folder / "20250111-test-conversation.md"
|
||||
assert conv_path.exists()
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Tests for import_claude command (chat conversations)."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands import import_claude_conversations as import_claude
|
||||
from basic_memory.config import config
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
|
||||
# Set up CLI runner
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_conversation():
|
||||
"""Sample conversation data for testing."""
|
||||
return {
|
||||
"uuid": "test-uuid",
|
||||
"name": "Test Conversation",
|
||||
"created_at": "2025-01-05T20:55:32.499880+00:00",
|
||||
"updated_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
"chat_messages": [
|
||||
{
|
||||
"uuid": "msg-1",
|
||||
"text": "Hello, this is a test",
|
||||
"sender": "human",
|
||||
"created_at": "2025-01-05T20:55:32.499880+00:00",
|
||||
"content": [{"type": "text", "text": "Hello, this is a test"}],
|
||||
},
|
||||
{
|
||||
"uuid": "msg-2",
|
||||
"text": "Response to test",
|
||||
"sender": "assistant",
|
||||
"created_at": "2025-01-05T20:55:40.123456+00:00",
|
||||
"content": [{"type": "text", "text": "Response to test"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_conversations_json(tmp_path, sample_conversation):
|
||||
"""Create a sample conversations.json file."""
|
||||
json_file = tmp_path / "conversations.json"
|
||||
with open(json_file, "w") as f:
|
||||
json.dump([sample_conversation], f)
|
||||
return json_file
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_chat_json(tmp_path, sample_conversations_json):
|
||||
"""Test importing conversations from JSON."""
|
||||
entity_parser = EntityParser(tmp_path)
|
||||
processor = MarkdownProcessor(entity_parser)
|
||||
|
||||
results = await import_claude.process_conversations_json(
|
||||
sample_conversations_json, tmp_path, processor
|
||||
)
|
||||
|
||||
assert results["conversations"] == 1
|
||||
assert results["messages"] == 2
|
||||
|
||||
# Check conversation file
|
||||
conv_path = tmp_path / "20250105-test-conversation.md"
|
||||
assert conv_path.exists()
|
||||
content = conv_path.read_text()
|
||||
|
||||
# Check content formatting
|
||||
assert "### Human" in content
|
||||
assert "Hello, this is a test" in content
|
||||
assert "### Assistant" in content
|
||||
assert "Response to test" in content
|
||||
|
||||
|
||||
def test_import_conversations_command_file_not_found(tmp_path):
|
||||
"""Test error handling for nonexistent file."""
|
||||
nonexistent = tmp_path / "nonexistent.json"
|
||||
result = runner.invoke(app, ["import", "claude", "conversations", str(nonexistent)])
|
||||
assert result.exit_code == 1
|
||||
assert "File not found" in result.output
|
||||
|
||||
|
||||
def test_import_conversations_command_success(tmp_path, sample_conversations_json, monkeypatch):
|
||||
"""Test successful conversation import via command."""
|
||||
# Set up test environment
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
# Run import
|
||||
result = runner.invoke(
|
||||
app, ["import", "claude", "conversations", str(sample_conversations_json)]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Import complete" in result.output
|
||||
assert "Imported 1 conversations" in result.output
|
||||
assert "Containing 2 messages" in result.output
|
||||
|
||||
|
||||
def test_import_conversations_command_invalid_json(tmp_path):
|
||||
"""Test error handling for invalid JSON."""
|
||||
# Create invalid JSON file
|
||||
invalid_file = tmp_path / "invalid.json"
|
||||
invalid_file.write_text("not json")
|
||||
|
||||
result = runner.invoke(app, ["import", "claude", "conversations", str(invalid_file)])
|
||||
assert result.exit_code == 1
|
||||
assert "Error during import" in result.output
|
||||
|
||||
|
||||
def test_import_conversations_with_custom_folder(tmp_path, sample_conversations_json, monkeypatch):
|
||||
"""Test import with custom conversations folder."""
|
||||
# Set up test environment
|
||||
config.home = tmp_path
|
||||
conversations_folder = "chats"
|
||||
|
||||
# Run import
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"import",
|
||||
"claude",
|
||||
"conversations",
|
||||
str(sample_conversations_json),
|
||||
"--folder",
|
||||
conversations_folder,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Check files in custom folder
|
||||
conv_path = tmp_path / conversations_folder / "20250105-test-conversation.md"
|
||||
assert conv_path.exists()
|
||||
|
||||
|
||||
def test_import_conversation_with_attachments(tmp_path):
|
||||
"""Test importing conversation with attachments."""
|
||||
# Create conversation with attachments
|
||||
conversation = {
|
||||
"uuid": "test-uuid",
|
||||
"name": "Test With Attachments",
|
||||
"created_at": "2025-01-05T20:55:32.499880+00:00",
|
||||
"updated_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
"chat_messages": [
|
||||
{
|
||||
"uuid": "msg-1",
|
||||
"text": "Here's a file",
|
||||
"sender": "human",
|
||||
"created_at": "2025-01-05T20:55:32.499880+00:00",
|
||||
"content": [{"type": "text", "text": "Here's a file"}],
|
||||
"attachments": [
|
||||
{"file_name": "test.txt", "extracted_content": "Test file content"}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
json_file = tmp_path / "with_attachments.json"
|
||||
with open(json_file, "w") as f:
|
||||
json.dump([conversation], f)
|
||||
|
||||
# Set up environment
|
||||
config.home = tmp_path
|
||||
|
||||
# Run import
|
||||
result = runner.invoke(app, ["import", "claude", "conversations", str(json_file)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Check attachment formatting
|
||||
conv_path = tmp_path / "conversations/20250105-test-with-attachments.md"
|
||||
content = conv_path.read_text()
|
||||
assert "**Attachment: test.txt**" in content
|
||||
assert "```" in content
|
||||
assert "Test file content" in content
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Tests for import_claude_projects command."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands import import_claude_projects
|
||||
from basic_memory.config import config
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
|
||||
# Set up CLI runner
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_project():
|
||||
"""Sample project data for testing."""
|
||||
return {
|
||||
"uuid": "test-uuid",
|
||||
"name": "Test Project",
|
||||
"created_at": "2025-01-05T20:55:32.499880+00:00",
|
||||
"updated_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
"prompt_template": "# Test Prompt\n\nThis is a test prompt.",
|
||||
"docs": [
|
||||
{
|
||||
"uuid": "doc-uuid-1",
|
||||
"filename": "Test Document",
|
||||
"content": "# Test Document\n\nThis is test content.",
|
||||
"created_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
},
|
||||
{
|
||||
"uuid": "doc-uuid-2",
|
||||
"filename": "Another Document",
|
||||
"content": "# Another Document\n\nMore test content.",
|
||||
"created_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_projects_json(tmp_path, sample_project):
|
||||
"""Create a sample projects.json file."""
|
||||
json_file = tmp_path / "projects.json"
|
||||
with open(json_file, "w") as f:
|
||||
json.dump([sample_project], f)
|
||||
return json_file
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_projects_json(tmp_path, sample_projects_json):
|
||||
"""Test importing projects from JSON."""
|
||||
entity_parser = EntityParser(tmp_path)
|
||||
processor = MarkdownProcessor(entity_parser)
|
||||
|
||||
results = await import_claude_projects.process_projects_json(
|
||||
sample_projects_json, tmp_path, processor
|
||||
)
|
||||
|
||||
assert results["documents"] == 2
|
||||
assert results["prompts"] == 1
|
||||
|
||||
# Check project directory structure
|
||||
project_dir = tmp_path / "test-project"
|
||||
assert project_dir.exists()
|
||||
assert (project_dir / "docs").exists()
|
||||
assert (project_dir / "prompt-template.md").exists()
|
||||
|
||||
# Check document files
|
||||
doc1 = project_dir / "docs/test-document.md"
|
||||
assert doc1.exists()
|
||||
content1 = doc1.read_text()
|
||||
assert "# Test Document" in content1
|
||||
assert "This is test content" in content1
|
||||
|
||||
# Check prompt template
|
||||
prompt = project_dir / "prompt-template.md"
|
||||
assert prompt.exists()
|
||||
prompt_content = prompt.read_text()
|
||||
assert "# Test Prompt" in prompt_content
|
||||
assert "This is a test prompt" in prompt_content
|
||||
|
||||
|
||||
def test_import_projects_command_file_not_found(tmp_path):
|
||||
"""Test error handling for nonexistent file."""
|
||||
nonexistent = tmp_path / "nonexistent.json"
|
||||
result = runner.invoke(app, ["import", "claude", "projects", str(nonexistent)])
|
||||
assert result.exit_code == 1
|
||||
assert "File not found" in result.output
|
||||
|
||||
|
||||
def test_import_projects_command_success(tmp_path, sample_projects_json, monkeypatch):
|
||||
"""Test successful project import via command."""
|
||||
# Set up test environment
|
||||
config.home = tmp_path
|
||||
|
||||
# Run import
|
||||
result = runner.invoke(app, ["import", "claude", "projects", str(sample_projects_json)])
|
||||
assert result.exit_code == 0
|
||||
assert "Import complete" in result.output
|
||||
assert "Imported 2 project documents" in result.output
|
||||
assert "Imported 1 prompt templates" in result.output
|
||||
|
||||
|
||||
def test_import_projects_command_invalid_json(tmp_path):
|
||||
"""Test error handling for invalid JSON."""
|
||||
# Create invalid JSON file
|
||||
invalid_file = tmp_path / "invalid.json"
|
||||
invalid_file.write_text("not json")
|
||||
|
||||
result = runner.invoke(app, ["import", "claude", "projects", str(invalid_file)])
|
||||
assert result.exit_code == 1
|
||||
assert "Error during import" in result.output
|
||||
|
||||
|
||||
def test_import_projects_with_base_folder(tmp_path, sample_projects_json, monkeypatch):
|
||||
"""Test import with custom base folder."""
|
||||
# Set up test environment
|
||||
config.home = tmp_path
|
||||
base_folder = "claude-exports"
|
||||
|
||||
# Run import
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"import",
|
||||
"claude",
|
||||
"projects",
|
||||
str(sample_projects_json),
|
||||
"--base-folder",
|
||||
base_folder,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Check files in base folder
|
||||
project_dir = tmp_path / base_folder / "test-project"
|
||||
assert project_dir.exists()
|
||||
assert (project_dir / "docs").exists()
|
||||
assert (project_dir / "prompt-template.md").exists()
|
||||
|
||||
|
||||
def test_import_project_without_prompt(tmp_path):
|
||||
"""Test importing project without prompt template."""
|
||||
# Create project without prompt
|
||||
project = {
|
||||
"uuid": "test-uuid",
|
||||
"name": "No Prompt Project",
|
||||
"created_at": "2025-01-05T20:55:32.499880+00:00",
|
||||
"updated_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
"docs": [
|
||||
{
|
||||
"uuid": "doc-uuid-1",
|
||||
"filename": "Test Document",
|
||||
"content": "# Test Document\n\nContent.",
|
||||
"created_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
json_file = tmp_path / "no_prompt.json"
|
||||
with open(json_file, "w") as f:
|
||||
json.dump([project], f)
|
||||
|
||||
# Set up environment
|
||||
config.home = tmp_path
|
||||
|
||||
# Run import
|
||||
result = runner.invoke(app, ["import", "claude", "projects", str(json_file)])
|
||||
assert result.exit_code == 0
|
||||
assert "Imported 1 project documents" in result.output
|
||||
assert "Imported 0 prompt templates" in result.output
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.app import import_app
|
||||
from basic_memory.cli.commands import import_memory_json
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
|
||||
@@ -72,7 +72,7 @@ async def test_get_markdown_processor(tmp_path, monkeypatch):
|
||||
def test_import_json_command_file_not_found(tmp_path):
|
||||
"""Test error handling for nonexistent file."""
|
||||
nonexistent = tmp_path / "nonexistent.json"
|
||||
result = runner.invoke(app, ["import-json", str(nonexistent)])
|
||||
result = runner.invoke(import_app, ["memory-json", str(nonexistent)])
|
||||
assert result.exit_code == 1
|
||||
assert "File not found" in result.output
|
||||
|
||||
@@ -83,7 +83,7 @@ def test_import_json_command_success(tmp_path, sample_json_file, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
# Run import
|
||||
result = runner.invoke(app, ["import-json", str(sample_json_file)])
|
||||
result = runner.invoke(import_app, ["memory-json", str(sample_json_file)])
|
||||
assert result.exit_code == 0
|
||||
assert "Import complete" in result.output
|
||||
assert "Created 1 entities" in result.output
|
||||
@@ -96,7 +96,7 @@ def test_import_json_command_invalid_json(tmp_path):
|
||||
invalid_file = tmp_path / "invalid.json"
|
||||
invalid_file.write_text("not json")
|
||||
|
||||
result = runner.invoke(app, ["import-json", str(invalid_file)])
|
||||
result = runner.invoke(import_app, ["memory-json", str(invalid_file)])
|
||||
assert result.exit_code == 1
|
||||
assert "Error during import" in result.output
|
||||
|
||||
@@ -129,6 +129,6 @@ def test_import_json_command_handle_old_format(tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
# Run import
|
||||
result = runner.invoke(app, ["import-json", str(json_file)])
|
||||
result = runner.invoke(import_app, ["memory-json", str(json_file)])
|
||||
assert result.exit_code == 0
|
||||
assert "Import complete" in result.output
|
||||
|
||||
+2
-26
@@ -7,7 +7,6 @@ from datetime import datetime, timezone
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from loguru import logger
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine, async_sessionmaker
|
||||
|
||||
from basic_memory import db
|
||||
@@ -59,10 +58,8 @@ async def engine_factory(
|
||||
async with db.engine_session_factory(
|
||||
db_path=test_config.database_path, db_type=DatabaseType.MEMORY
|
||||
) as (engine, session_maker):
|
||||
# Initialize database
|
||||
async with db.scoped_session(session_maker) as session:
|
||||
await session.execute(text("PRAGMA foreign_keys=ON"))
|
||||
conn = await session.connection()
|
||||
# Create all tables for the DB the engine is connected to
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine, session_maker
|
||||
@@ -311,27 +308,6 @@ async def test_graph(
|
||||
for entity in entities:
|
||||
await search_service.index_entity(entity)
|
||||
|
||||
# search_content = await entity_repository.execute_query(text("select * from search_index"),
|
||||
# use_query_options=False)
|
||||
# for row in search_content:
|
||||
# print(row)
|
||||
# print("relation:")
|
||||
# search_content = await entity_repository.execute_query(text("select * from search_index where type = 'relation'"),
|
||||
# use_query_options=False)
|
||||
# for row in search_content:
|
||||
# print(row)
|
||||
#
|
||||
# # In test_graph fixture after creating everything:
|
||||
# print("Entities:")
|
||||
# entities = await entity_repository.find_all()
|
||||
# for e in entities:
|
||||
# print(f"- {e.title} (id={e.id})")
|
||||
#
|
||||
# print("\nRelations:")
|
||||
# relations = await relation_repository.find_all()
|
||||
# for r in relations:
|
||||
# print(f"- {r.from_id} -> {r.to_id} ({r.relation_type})")
|
||||
|
||||
return {
|
||||
"root": root,
|
||||
"connected1": connected_1,
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Tests for get_entity MCP tool."""
|
||||
|
||||
import pytest
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.mcp.tools import notes
|
||||
from basic_memory.mcp.tools.knowledge import get_entity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_basic_entity(client):
|
||||
"""Test retrieving a basic entity."""
|
||||
# First create an entity
|
||||
permalink = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="""
|
||||
# Test\nThis is a test note
|
||||
- [note] First observation
|
||||
""",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
|
||||
assert permalink # Got a valid permalink
|
||||
|
||||
# Get the entity without content
|
||||
entity = await get_entity(permalink)
|
||||
|
||||
# Verify entity details
|
||||
assert entity.file_path == "test/Test Note.md"
|
||||
assert entity.entity_type == "note"
|
||||
assert entity.permalink == "test/test-note"
|
||||
|
||||
# Check observations
|
||||
assert len(entity.observations) == 1
|
||||
obs = entity.observations[0]
|
||||
assert obs.content == "First observation"
|
||||
assert obs.category == "note"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_entity(client):
|
||||
"""Test attempting to get a non-existent entity."""
|
||||
with pytest.raises(ToolError):
|
||||
await get_entity("test/nonexistent")
|
||||
@@ -13,7 +13,7 @@ from basic_memory.schemas.delete import DeleteEntitiesRequest
|
||||
async def test_get_single_entity(client):
|
||||
"""Test retrieving a single entity."""
|
||||
# First create an entity
|
||||
permalink = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="""
|
||||
@@ -22,9 +22,34 @@ async def test_get_single_entity(client):
|
||||
""",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
assert result
|
||||
|
||||
# Get the entity
|
||||
entity = await get_entity(permalink)
|
||||
entity = await get_entity("test/test-note")
|
||||
|
||||
# Verify entity details
|
||||
assert entity.title == "Test Note"
|
||||
assert entity.permalink == "test/test-note"
|
||||
assert len(entity.observations) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_single_entity_memory_url(client):
|
||||
"""Test retrieving a single entity."""
|
||||
# First create an entity
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="""
|
||||
# Test\nThis is a test note
|
||||
- [note] First observation
|
||||
""",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
assert result
|
||||
|
||||
# Get the entity
|
||||
entity = await get_entity("memory://test/test-note")
|
||||
|
||||
# Verify entity details
|
||||
assert entity.title == "Test Note"
|
||||
@@ -36,40 +61,68 @@ async def test_get_single_entity(client):
|
||||
async def test_get_multiple_entities(client):
|
||||
"""Test retrieving multiple entities."""
|
||||
# Create two test entities
|
||||
permalink1 = await notes.write_note(
|
||||
await notes.write_note(
|
||||
title="Test Note 1",
|
||||
folder="test",
|
||||
content="# Test 1",
|
||||
)
|
||||
permalink2 = await notes.write_note(
|
||||
await notes.write_note(
|
||||
title="Test Note 2",
|
||||
folder="test",
|
||||
content="# Test 2",
|
||||
)
|
||||
|
||||
# Get both entities
|
||||
request = GetEntitiesRequest(permalinks=[permalink1, permalink2])
|
||||
request = GetEntitiesRequest(permalinks=["test/test-note-1", "test/test-note-2"])
|
||||
response = await get_entities(request)
|
||||
|
||||
# Verify we got both entities
|
||||
assert len(response.entities) == 2
|
||||
permalinks = {e.permalink for e in response.entities}
|
||||
assert permalink1 in permalinks
|
||||
assert permalink2 in permalinks
|
||||
assert "test/test-note-1" in permalinks
|
||||
assert "test/test-note-2" in permalinks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multiple_entities_memory_ur(client):
|
||||
"""Test retrieving multiple entities."""
|
||||
# Create two test entities
|
||||
await notes.write_note(
|
||||
title="Test Note 1",
|
||||
folder="test",
|
||||
content="# Test 1",
|
||||
)
|
||||
await notes.write_note(
|
||||
title="Test Note 2",
|
||||
folder="test",
|
||||
content="# Test 2",
|
||||
)
|
||||
|
||||
# Get both entities
|
||||
request = GetEntitiesRequest(
|
||||
permalinks=["memory://test/test-note-1", "memory://test/test-note-2"]
|
||||
)
|
||||
response = await get_entities(request)
|
||||
|
||||
# Verify we got both entities
|
||||
assert len(response.entities) == 2
|
||||
permalinks = {e.permalink for e in response.entities}
|
||||
assert "test/test-note-1" in permalinks
|
||||
assert "test/test-note-2" in permalinks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_entities(client):
|
||||
"""Test deleting entities."""
|
||||
# Create a test entity
|
||||
permalink = await notes.write_note(
|
||||
await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test Note to Delete",
|
||||
)
|
||||
|
||||
# Delete the entity
|
||||
request = DeleteEntitiesRequest(permalinks=[permalink])
|
||||
request = DeleteEntitiesRequest(permalinks=["test/test-note"])
|
||||
response = await delete_entities(request)
|
||||
|
||||
# Verify deletion
|
||||
@@ -77,7 +130,29 @@ async def test_delete_entities(client):
|
||||
|
||||
# Verify entity no longer exists
|
||||
with pytest.raises(ToolError):
|
||||
await get_entity(permalink)
|
||||
await get_entity("test/test-note")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_entities_memory_url(client):
|
||||
"""Test deleting entities."""
|
||||
# Create a test entity
|
||||
await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test Note to Delete",
|
||||
)
|
||||
|
||||
# Delete the entity
|
||||
request = DeleteEntitiesRequest(permalinks=["memory://test/test-note"])
|
||||
response = await delete_entities(request)
|
||||
|
||||
# Verify deletion
|
||||
assert response.deleted is True
|
||||
|
||||
# Verify entity no longer exists
|
||||
with pytest.raises(ToolError):
|
||||
await get_entity("test/test-note")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -89,7 +89,9 @@ async def test_recent_activity_timeframe_formats(client, test_graph):
|
||||
# Test each valid timeframe
|
||||
for timeframe in valid_timeframes:
|
||||
try:
|
||||
result = await recent_activity(type=["entity"], timeframe=timeframe, max_results=1)
|
||||
result = await recent_activity(
|
||||
type=["entity"], timeframe=timeframe, page=1, page_size=10, max_related=10
|
||||
)
|
||||
assert result is not None
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
|
||||
@@ -136,7 +138,9 @@ async def test_build_context_timeframe_formats(client, test_graph):
|
||||
# Test each valid timeframe
|
||||
for timeframe in valid_timeframes:
|
||||
try:
|
||||
result = await build_context(url=test_url, timeframe=timeframe, max_results=1)
|
||||
result = await build_context(
|
||||
url=test_url, timeframe=timeframe, page=1, page_size=10, max_related=10
|
||||
)
|
||||
assert result is not None
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
|
||||
|
||||
+176
-62
@@ -1,10 +1,11 @@
|
||||
"""Tests for note tools that exercise the full stack with SQLite."""
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.mcp.tools import notes
|
||||
from basic_memory.schemas import EntityResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -17,31 +18,41 @@ async def test_write_note(app):
|
||||
- Handle tags correctly
|
||||
- Return valid permalink
|
||||
"""
|
||||
permalink = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a test note",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
|
||||
assert permalink # Got a valid permalink
|
||||
assert result
|
||||
assert (
|
||||
dedent("""
|
||||
# Created test/Test Note.md (159f2168)
|
||||
permalink: test/test-note
|
||||
|
||||
## Tags
|
||||
- test, documentation
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
|
||||
# Try reading it back via permalink
|
||||
content = await notes.read_note(permalink)
|
||||
content = await notes.read_note("test/test-note")
|
||||
assert (
|
||||
"""
|
||||
---
|
||||
title: Test Note
|
||||
type: note
|
||||
permalink: test/test-note
|
||||
tags:
|
||||
- '#test'
|
||||
- '#documentation'
|
||||
---
|
||||
|
||||
# Test
|
||||
This is a test note
|
||||
""".strip()
|
||||
dedent("""
|
||||
---
|
||||
title: Test Note
|
||||
type: note
|
||||
permalink: test/test-note
|
||||
tags:
|
||||
- '#test'
|
||||
- '#documentation'
|
||||
---
|
||||
|
||||
# Test
|
||||
This is a test note
|
||||
""").strip()
|
||||
in content
|
||||
)
|
||||
|
||||
@@ -49,20 +60,28 @@ This is a test note
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_no_tags(app):
|
||||
"""Test creating a note without tags."""
|
||||
permalink = await notes.write_note(title="Simple Note", folder="test", content="Just some text")
|
||||
result = await notes.write_note(title="Simple Note", folder="test", content="Just some text")
|
||||
|
||||
# Should be able to read it back
|
||||
content = await notes.read_note(permalink)
|
||||
assert result
|
||||
assert (
|
||||
"""
|
||||
--
|
||||
title: Simple Note
|
||||
type: note
|
||||
permalink: test/simple-note
|
||||
---
|
||||
|
||||
Just some text
|
||||
""".strip()
|
||||
dedent("""
|
||||
# Created test/Simple Note.md (9a1ff079)
|
||||
permalink: test/simple-note
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
# Should be able to read it back
|
||||
content = await notes.read_note("test/simple-note")
|
||||
assert (
|
||||
dedent("""
|
||||
--
|
||||
title: Simple Note
|
||||
type: note
|
||||
permalink: test/simple-note
|
||||
---
|
||||
|
||||
Just some text
|
||||
""").strip()
|
||||
in content
|
||||
)
|
||||
|
||||
@@ -84,24 +103,44 @@ async def test_write_note_update_existing(app):
|
||||
- Handle tags correctly
|
||||
- Return valid permalink
|
||||
"""
|
||||
permalink = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a test note",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
|
||||
assert permalink # Got a valid permalink
|
||||
assert result # Got a valid permalink
|
||||
assert (
|
||||
dedent("""
|
||||
# Created test/Test Note.md (159f2168)
|
||||
permalink: test/test-note
|
||||
|
||||
## Tags
|
||||
- test, documentation
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
|
||||
permalink = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is an updated note",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
assert (
|
||||
dedent("""
|
||||
# Updated test/Test Note.md (131b5662)
|
||||
permalink: test/test-note
|
||||
|
||||
## Tags
|
||||
- test, documentation
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
|
||||
# Try reading it back
|
||||
content = await notes.read_note(permalink)
|
||||
content = await notes.read_note("test/test-note")
|
||||
assert (
|
||||
"""
|
||||
---
|
||||
@@ -135,10 +174,18 @@ async def test_read_note_by_title(app):
|
||||
async def test_note_unicode_content(app):
|
||||
"""Test handling of unicode content in notes."""
|
||||
content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦"
|
||||
permalink = await notes.write_note(title="Unicode Test", folder="test", content=content)
|
||||
result = await notes.write_note(title="Unicode Test", folder="test", content=content)
|
||||
|
||||
assert (
|
||||
dedent("""
|
||||
# Created test/Unicode Test.md (272389cd)
|
||||
permalink: test/unicode-test
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
|
||||
# Read back should preserve unicode
|
||||
result = await notes.read_note(permalink)
|
||||
result = await notes.read_note("test/unicode-test")
|
||||
assert content in result
|
||||
|
||||
|
||||
@@ -147,20 +194,61 @@ async def test_multiple_notes(app):
|
||||
"""Test creating and managing multiple notes."""
|
||||
# Create several notes
|
||||
notes_data = [
|
||||
("Note 1", "test", "Content 1", ["tag1"]),
|
||||
("Note 2", "test", "Content 2", ["tag1", "tag2"]),
|
||||
("Note 3", "test", "Content 3", []),
|
||||
("test/note-1", "Note 1", "test", "Content 1", ["tag1"]),
|
||||
("test/note-2", "Note 2", "test", "Content 2", ["tag1", "tag2"]),
|
||||
("test/note-3", "Note 3", "test", "Content 3", []),
|
||||
]
|
||||
|
||||
permalinks = []
|
||||
for title, folder, content, tags in notes_data:
|
||||
permalink = await notes.write_note(title=title, folder=folder, content=content, tags=tags)
|
||||
permalinks.append(permalink)
|
||||
for _, title, folder, content, tags in notes_data:
|
||||
await notes.write_note(title=title, folder=folder, content=content, tags=tags)
|
||||
|
||||
# Should be able to read each one
|
||||
for i, permalink in enumerate(permalinks):
|
||||
content = await notes.read_note(permalink)
|
||||
assert f"Content {i + 1}" in content
|
||||
for permalink, title, folder, content, _ in notes_data:
|
||||
note = await notes.read_note(permalink)
|
||||
assert content in note
|
||||
|
||||
# read multiple notes at once
|
||||
|
||||
result = await notes.read_note("test/*")
|
||||
|
||||
# note we can't compare times
|
||||
assert "--- memory://test/note-1" in result
|
||||
assert "Content 1" in result
|
||||
|
||||
assert "--- memory://test/note-2" in result
|
||||
assert "Content 2" in result
|
||||
|
||||
assert "--- memory://test/note-3" in result
|
||||
assert "Content 3" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_notes_pagination(app):
|
||||
"""Test creating and managing multiple notes."""
|
||||
# Create several notes
|
||||
notes_data = [
|
||||
("test/note-1", "Note 1", "test", "Content 1", ["tag1"]),
|
||||
("test/note-2", "Note 2", "test", "Content 2", ["tag1", "tag2"]),
|
||||
("test/note-3", "Note 3", "test", "Content 3", []),
|
||||
]
|
||||
|
||||
for _, title, folder, content, tags in notes_data:
|
||||
await notes.write_note(title=title, folder=folder, content=content, tags=tags)
|
||||
|
||||
# Should be able to read each one
|
||||
for permalink, title, folder, content, _ in notes_data:
|
||||
note = await notes.read_note(permalink)
|
||||
assert content in note
|
||||
|
||||
# read multiple notes at once with pagination
|
||||
result = await notes.read_note("test/*", page=1, page_size=2)
|
||||
|
||||
# note we can't compare times
|
||||
assert "--- memory://test/note-1" in result
|
||||
assert "Content 1" in result
|
||||
|
||||
assert "--- memory://test/note-2" in result
|
||||
assert "Content 2" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -172,16 +260,16 @@ async def test_delete_note_existing(app):
|
||||
- Return valid permalink
|
||||
- Delete the note
|
||||
"""
|
||||
permalink = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a test note",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
|
||||
assert permalink # Got a valid permalink
|
||||
assert result
|
||||
|
||||
deleted = await notes.delete_note(permalink)
|
||||
deleted = await notes.delete_note("test/test-note")
|
||||
assert deleted is True
|
||||
|
||||
|
||||
@@ -207,7 +295,7 @@ async def test_write_note_verbose(app):
|
||||
- Handle tags correctly
|
||||
- Return valid permalink
|
||||
"""
|
||||
entity = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="""
|
||||
@@ -218,21 +306,47 @@ async def test_write_note_verbose(app):
|
||||
|
||||
""",
|
||||
tags=["test", "documentation"],
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
assert isinstance(entity, EntityResponse)
|
||||
assert (
|
||||
dedent("""
|
||||
# Created test/Test Note.md (06873a7a)
|
||||
permalink: test/test-note
|
||||
|
||||
## Observations
|
||||
- note: 1
|
||||
|
||||
## Relations
|
||||
- Resolved: 0
|
||||
- Unresolved: 1
|
||||
|
||||
Unresolved relations will be retried on next sync.
|
||||
|
||||
## Tags
|
||||
- test, documentation
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
|
||||
assert entity.title == "Test Note"
|
||||
assert entity.file_path == "test/Test Note.md"
|
||||
assert entity.entity_type == "note"
|
||||
assert entity.permalink == "test/test-note"
|
||||
|
||||
assert len(entity.observations) == 1
|
||||
assert entity.observations[0].content == "First observation"
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_note_memory_url(app):
|
||||
"""Test reading a note using a memory:// URL.
|
||||
|
||||
assert len(entity.relations) == 1
|
||||
assert entity.relations[0].relation_type == "relates to"
|
||||
assert entity.relations[0].from_id == "test/test-note"
|
||||
assert entity.relations[0].to_id is None
|
||||
assert entity.relations[0].to_name == "Knowledge"
|
||||
Should:
|
||||
- Handle memory:// URLs correctly
|
||||
- Normalize the URL before resolving
|
||||
- Return the note content
|
||||
"""
|
||||
# First create a note
|
||||
result = await notes.write_note(
|
||||
title="Memory URL Test",
|
||||
folder="test",
|
||||
content="Testing memory:// URL handling",
|
||||
)
|
||||
assert result
|
||||
|
||||
# Should be able to read it with a memory:// URL
|
||||
memory_url = "memory://test/memory-url-test"
|
||||
content = await notes.read_note(memory_url)
|
||||
assert "Testing memory:// URL handling" in content
|
||||
|
||||
@@ -12,12 +12,13 @@ from basic_memory.schemas.search import SearchQuery, SearchItemType
|
||||
async def test_search_basic(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
permalink = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
tags=["test", "search"],
|
||||
)
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
query = SearchQuery(text="searchable")
|
||||
@@ -25,7 +26,28 @@ async def test_search_basic(client):
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) > 0
|
||||
assert any(r.permalink == permalink for r in response.results)
|
||||
assert any(r.permalink == "test/test-search-note" for r in response.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_pagination(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
result = await notes.write_note(
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
tags=["test", "search"],
|
||||
)
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
query = SearchQuery(text="searchable")
|
||||
response = await search(query, page=1, page_size=1)
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) == 1
|
||||
assert any(r.permalink == "test/test-search-note" for r in response.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -51,16 +51,17 @@ def test_relation_in_validation():
|
||||
def test_relation_response():
|
||||
"""Test RelationResponse validation."""
|
||||
data = {
|
||||
"permalink": "test/123/relates_to/test/456",
|
||||
"from_id": "test/123",
|
||||
"to_id": "test/456",
|
||||
"relation_type": "test",
|
||||
"relation_type": "relates_to",
|
||||
"from_entity": {"permalink": "test/123"},
|
||||
"to_entity": {"permalink": "test/456"},
|
||||
}
|
||||
relation = RelationResponse.model_validate(data)
|
||||
assert relation.from_id == "test/123"
|
||||
assert relation.to_id == "test/456"
|
||||
assert relation.relation_type == "test"
|
||||
assert relation.relation_type == "relates_to"
|
||||
assert relation.context is None
|
||||
|
||||
|
||||
@@ -73,16 +74,27 @@ def test_entity_out_from_attributes():
|
||||
"file_path": "test",
|
||||
"entity_type": "knowledge",
|
||||
"content_type": "text/markdown",
|
||||
"observations": [{"id": 1, "category": "note", "content": "test obs", "context": None}],
|
||||
"relations": [
|
||||
"observations": [
|
||||
{
|
||||
"id": 1,
|
||||
"from_id": "test/test",
|
||||
"to_id": "test/test",
|
||||
"relation_type": "test",
|
||||
"permalink": "permalink",
|
||||
"category": "note",
|
||||
"content": "test obs",
|
||||
"context": None,
|
||||
}
|
||||
],
|
||||
"relations": [
|
||||
{
|
||||
"id": 1,
|
||||
"permalink": "test/test/relates_to/test/test",
|
||||
"from_id": "test/test",
|
||||
"to_id": "test/test",
|
||||
"relation_type": "relates_to",
|
||||
"context": None,
|
||||
}
|
||||
],
|
||||
"created_at": "2023-01-01T00:00:00",
|
||||
"updated_at": "2023-01-01T00:00:00",
|
||||
}
|
||||
entity = EntityResponse.model_validate(db_data)
|
||||
assert entity.permalink == "test/test"
|
||||
|
||||
@@ -112,7 +112,7 @@ def test_search_response():
|
||||
metadata={},
|
||||
),
|
||||
]
|
||||
response = SearchResponse(results=results)
|
||||
response = SearchResponse(results=results, current_page=1, page_size=1)
|
||||
assert len(response.results) == 2
|
||||
assert response.results[0].score > response.results[1].score
|
||||
|
||||
|
||||
@@ -128,18 +128,6 @@ async def test_build_context(context_service, test_graph):
|
||||
assert total_results == len(primary_results) + len(related_results)
|
||||
|
||||
|
||||
@pytest.mark.skip("search prefix see:'https://sqlite.org/fts5.html#FTS5 Prefix Queries'")
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_pattern(context_service, test_graph):
|
||||
"""Test permalink wildcard lookup."""
|
||||
# url = memory_url.validate_strings("memory://test/connected*")
|
||||
# results = await context_service.build_context(url)
|
||||
# matched_results = results["metadata"]["matched_results"]
|
||||
# primary_results = results["primary_results"]
|
||||
# related_results = results["related_results"]
|
||||
# total_results = results["metadata"]["total_results"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_not_found(context_service):
|
||||
"""Test handling non-existent permalinks."""
|
||||
|
||||
@@ -10,7 +10,17 @@ from basic_memory.services.link_resolver import LinkResolver
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_entities(entity_service, file_service):
|
||||
"""Create a set of test entities."""
|
||||
"""Create a set of test entities.
|
||||
|
||||
├── components
|
||||
│ ├── Auth Service.md
|
||||
│ └── Core Service.md
|
||||
├── config
|
||||
│ └── Service Config.md
|
||||
└── specs
|
||||
└── Core Features.md
|
||||
|
||||
"""
|
||||
|
||||
e1, _ = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
@@ -40,6 +50,20 @@ async def test_entities(entity_service, file_service):
|
||||
folder="specs",
|
||||
)
|
||||
)
|
||||
e5, _ = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
title="Sub Features 1",
|
||||
entity_type="specs",
|
||||
folder="specs/subspec",
|
||||
)
|
||||
)
|
||||
e6, _ = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
title="Sub Features 2",
|
||||
entity_type="specs",
|
||||
folder="specs/subspec",
|
||||
)
|
||||
)
|
||||
|
||||
return [e1, e2, e3, e4]
|
||||
|
||||
@@ -68,18 +92,11 @@ async def test_exact_title_match(link_resolver, test_entities):
|
||||
assert entity.permalink == "components/core-service"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Fuzzy misspelling not yet implemented")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fuzzy_title_match_misspelling(link_resolver):
|
||||
# Test slight misspelling
|
||||
result = await link_resolver.resolve_link("Core Servise")
|
||||
assert result.permalink == "components/core-service"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fuzzy_title_partial_match(link_resolver):
|
||||
# Test partial match
|
||||
result = await link_resolver.resolve_link("Auth Serv")
|
||||
assert result is not None, "Did not find partial match"
|
||||
assert result.permalink == "components/auth-service"
|
||||
|
||||
|
||||
@@ -114,31 +131,3 @@ async def test_resolve_none(link_resolver):
|
||||
"""Test resolving non-existent entity."""
|
||||
# Basic new entity
|
||||
assert await link_resolver.resolve_link("New Feature") is None
|
||||
|
||||
|
||||
@pytest.mark.skip("Advanced relevance scoring not yet implemented")
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_matches_resolution(link_resolver):
|
||||
"""Test resolution when multiple potential matches exist."""
|
||||
# Add some similar entities
|
||||
test_cases = [
|
||||
{
|
||||
"link": "Service", # Ambiguous
|
||||
"expected_prefix": "components/", # Should prefer component directory match
|
||||
},
|
||||
{
|
||||
"link": "Core", # Ambiguous
|
||||
"expected_prefix": "specs/", # Should prefer specs directory match
|
||||
},
|
||||
{
|
||||
"link": "Service",
|
||||
"expected": "components/core-service", # Should pick shortest/highest scored
|
||||
},
|
||||
]
|
||||
|
||||
for case in test_cases:
|
||||
result = await link_resolver.resolve_link(case["link"])
|
||||
if "expected_prefix" in case:
|
||||
assert result.startswith(case["expected_prefix"])
|
||||
else:
|
||||
assert result == case["expected"]
|
||||
|
||||
@@ -19,6 +19,25 @@ async def test_search_permalink(search_service, test_graph):
|
||||
assert "test/root" in r.permalink
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_limit_offset(search_service, test_graph):
|
||||
"""Exact permalink"""
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/*"))
|
||||
assert len(results) > 1
|
||||
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/*"), limit=1)
|
||||
assert len(results) == 1
|
||||
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/*"), limit=100)
|
||||
num_results = len(results)
|
||||
|
||||
# assert offset
|
||||
offset_results = await search_service.search(
|
||||
SearchQuery(permalink_match="test/*"), limit=100, offset=1
|
||||
)
|
||||
assert len(offset_results) == num_results - 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_permalink_observations_wildcard(search_service, test_graph):
|
||||
"""Pattern matching"""
|
||||
@@ -32,21 +51,24 @@ async def test_search_permalink_observations_wildcard(search_service, test_graph
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_permalink_relation_wildcard(search_service, test_graph):
|
||||
"""Pattern matching"""
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/root/connects_to/*"))
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/root/connects-to/*"))
|
||||
assert len(results) == 1
|
||||
permalinks = {r.permalink for r in results}
|
||||
assert "test/root/connects-to/test/connected-entity-1" in permalinks
|
||||
|
||||
|
||||
@pytest.mark.skip("search prefix see:'https://sqlite.org/fts5.html#FTS5 Prefix Queries'")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_permalink_wildcard2(search_service, test_graph):
|
||||
"""Pattern matching"""
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/connected*"))
|
||||
assert len(results) == 2
|
||||
results = await search_service.search(
|
||||
SearchQuery(
|
||||
permalink_match="test/connected*",
|
||||
)
|
||||
)
|
||||
assert len(results) >= 2
|
||||
permalinks = {r.permalink for r in results}
|
||||
assert "test/connected1" in permalinks
|
||||
assert "test/connected2" in permalinks
|
||||
assert "test/connected-entity-1" in permalinks
|
||||
assert "test/connected-entity-2" in permalinks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -68,17 +90,27 @@ async def test_search_title(search_service, test_graph):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_search_features(search_service, test_graph):
|
||||
async def test_text_search_case_insensitive(search_service, test_graph):
|
||||
"""Test text search functionality."""
|
||||
# Case insensitive
|
||||
results = await search_service.search(SearchQuery(text="ENTITY"))
|
||||
assert any("test/root" in r.permalink for r in results)
|
||||
|
||||
# Partial word match
|
||||
results = await search_service.search(SearchQuery(text="Connect"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_search_content_word_match(search_service, test_graph):
|
||||
"""Test text search functionality."""
|
||||
|
||||
# content word match
|
||||
results = await search_service.search(SearchQuery(text="Connected"))
|
||||
assert len(results) > 0
|
||||
assert any(r.file_path == "test/Connected Entity 2.md" for r in results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_search_multiple_terms(search_service, test_graph):
|
||||
"""Test text search functionality."""
|
||||
|
||||
# Multiple terms
|
||||
results = await search_service.search(SearchQuery(text="root note"))
|
||||
assert any("test/root" in r.permalink for r in results)
|
||||
@@ -88,15 +120,20 @@ async def test_text_search_features(search_service, test_graph):
|
||||
async def test_pattern_matching(search_service, test_graph):
|
||||
"""Test pattern matching with various wildcards."""
|
||||
# Test wildcards
|
||||
results = await search_service.search(SearchQuery(permalink="test/*"))
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/*"))
|
||||
for r in results:
|
||||
assert "test/" in r.permalink
|
||||
|
||||
# Test start wildcards
|
||||
results = await search_service.search(SearchQuery(permalink="*/observations"))
|
||||
results = await search_service.search(SearchQuery(permalink_match="*/observations"))
|
||||
for r in results:
|
||||
assert "/observations" in r.permalink
|
||||
|
||||
# Test permalink partial match
|
||||
results = await search_service.search(SearchQuery(permalink_match="test"))
|
||||
for r in results:
|
||||
assert "test/" in r.permalink
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filters(search_service, test_graph):
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
"""Tests for basic-memory package"""
|
||||
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
from frontmatter.default_handlers import toml
|
||||
|
||||
from basic_memory import __version__
|
||||
from basic_memory.config import config
|
||||
|
||||
|
||||
def read_toml_version(file_path):
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
if sys.version_info >= (3, 11):
|
||||
data = tomllib.load(f)
|
||||
else:
|
||||
data = toml.load(f)
|
||||
if "project" in data and "version" in data["project"]:
|
||||
return data["project"]["version"]
|
||||
else:
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (toml.TomlDecodeError, tomllib.TOMLDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
file_path = "pyproject.toml"
|
||||
version = read_toml_version(file_path)
|
||||
|
||||
|
||||
def test_version():
|
||||
"""Test version is set"""
|
||||
assert __version__ is not None
|
||||
"""Test version is set in project src code and pyproject.toml"""
|
||||
assert __version__ == version
|
||||
|
||||
|
||||
def test_config_env():
|
||||
"""Test the config env is set to test for pytest"""
|
||||
assert config.env == "test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_env_async():
|
||||
"""Test the config env is set to test for async pytest"""
|
||||
assert config.env == "test"
|
||||
|
||||
@@ -59,6 +59,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asttokens"
|
||||
version = "3.0.0"
|
||||
@@ -70,7 +79,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "basic-memory"
|
||||
version = "0.1.2"
|
||||
version = "0.6.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -79,6 +88,7 @@ dependencies = [
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "greenlet" },
|
||||
{ name = "icecream" },
|
||||
{ name = "logfire", extra = ["fastapi", "httpx", "sqlalchemy", "sqlite3"] },
|
||||
{ name = "loguru" },
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "mcp" },
|
||||
@@ -116,6 +126,7 @@ requires-dist = [
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.8" },
|
||||
{ name = "greenlet", specifier = ">=3.1.1" },
|
||||
{ name = "icecream", specifier = ">=2.1.3" },
|
||||
{ name = "logfire", extras = ["fastapi", "httpx", "sqlalchemy", "sqlite3"], specifier = ">=3.6.0" },
|
||||
{ name = "loguru", specifier = ">=0.7.3" },
|
||||
{ name = "markdown-it-py", specifier = ">=3.0.0" },
|
||||
{ name = "mcp", specifier = ">=1.2.0" },
|
||||
@@ -169,6 +180,41 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.8"
|
||||
@@ -281,6 +327,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/0a/981c438c4cd84147c781e4e96c1d72df03775deb1bc76c5a6ee8afa89c62/dateparser-1.2.1-py3-none-any.whl", hash = "sha256:bdcac262a467e6260030040748ad7c10d6bacd4f3b9cdb4cfd2251939174508c", size = 295658 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deprecated"
|
||||
version = "1.2.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dmgbuild"
|
||||
version = "1.6.4"
|
||||
@@ -419,6 +477,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/51824bd1f2c1ce70aa01495aa6ffe04ab789fa819fa7e6f0ad2388fb03c6/gevent-24.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:ec68e270543ecd532c4c1d70fca020f90aa5486ad49c4f3b8b2e64a66f5c9274", size = 1540088 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "googleapis-common-protos"
|
||||
version = "1.67.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/31/e1/fbffb85a624f1404133b5bb624834e77e0f549e2b8548146fe18c56e1411/googleapis_common_protos-1.67.0.tar.gz", hash = "sha256:21398025365f138be356d5923e9168737d94d46a72aefee4a6110a1f23463c86", size = 57344 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/30/2bd0eb03a7dee7727cd2ec643d1e992979e62d5e7443507381cce0455132/googleapis_common_protos-1.67.0-py2.py3-none-any.whl", hash = "sha256:579de760800d13616f51cf8be00c876f00a9f146d3e6510e19d1f4111758b741", size = 164985 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.1.1"
|
||||
@@ -544,6 +614,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "zipp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.0.0"
|
||||
@@ -576,6 +658,38 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/be/34b8864d0976258f29a5e3d7bedb2785fd56409cf866813458bfd32d2a6b/lief-0.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:52dc05445d8019b61a9ab8c6eb9d6238c4346ac692dcecca76d5f329a999216e", size = 3167188 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logfire"
|
||||
version = "3.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "executing" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
||||
{ name = "opentelemetry-instrumentation" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/03/3ed1bab39509c0d9bd1048387bceee75e6229c520eec9f7200ff85714b91/logfire-3.6.0.tar.gz", hash = "sha256:065a12dc727c92450bb05c0bf17b6ca36d1ee1ba99b6ca53d0f88d877ac863d9", size = 268596 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/7f/f1baa1f8ba5e5a8a1a892b42589a0e51878db0385893b043cf75734c3b7e/logfire-3.6.0-py3-none-any.whl", hash = "sha256:9d215ede261cb579d4bad77ff3419ee17145a2a87a9864e1fda21f3143fa2307", size = 180916 },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
fastapi = [
|
||||
{ name = "opentelemetry-instrumentation-fastapi" },
|
||||
]
|
||||
httpx = [
|
||||
{ name = "opentelemetry-instrumentation-httpx" },
|
||||
]
|
||||
sqlalchemy = [
|
||||
{ name = "opentelemetry-instrumentation-sqlalchemy" },
|
||||
]
|
||||
sqlite3 = [
|
||||
{ name = "opentelemetry-instrumentation-sqlite3" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "loguru"
|
||||
version = "0.7.3"
|
||||
@@ -697,6 +811,205 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecated" },
|
||||
{ name = "importlib-metadata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/6d/bbbf879826b7f3c89a45252010b5796fb1f1a0d45d9dc4709db0ef9a06c8/opentelemetry_api-1.30.0.tar.gz", hash = "sha256:375893400c1435bf623f7dfb3bcd44825fe6b56c34d0667c542ea8257b1a1240", size = 63703 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/0a/eea862fae6413d8181b23acf8e13489c90a45f17986ee9cf4eab8a0b9ad9/opentelemetry_api-1.30.0-py3-none-any.whl", hash = "sha256:d5f5284890d73fdf47f843dda3210edf37a38d66f44f2b5aedc1e89ed455dc09", size = 64955 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-otlp-proto-common"
|
||||
version = "1.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-proto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/d7/44098bf1ef89fc5810cdbda05faa2ae9322a0dbda4921cdc965dc68a9856/opentelemetry_exporter_otlp_proto_common-1.30.0.tar.gz", hash = "sha256:ddbfbf797e518411857d0ca062c957080279320d6235a279f7b64ced73c13897", size = 19640 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/54/f4b3de49f8d7d3a78fd6e6e1a6fd27dd342eb4d82c088b9078c6a32c3808/opentelemetry_exporter_otlp_proto_common-1.30.0-py3-none-any.whl", hash = "sha256:5468007c81aa9c44dc961ab2cf368a29d3475977df83b4e30aeed42aa7bc3b38", size = 18747 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-otlp-proto-http"
|
||||
version = "1.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecated" },
|
||||
{ name = "googleapis-common-protos" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-common" },
|
||||
{ name = "opentelemetry-proto" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/f9/abb9191d536e6a2e2b7903f8053bf859a76bf784e3ca19a5749550ef19e4/opentelemetry_exporter_otlp_proto_http-1.30.0.tar.gz", hash = "sha256:c3ae75d4181b1e34a60662a6814d0b94dd33b628bee5588a878bed92cee6abdc", size = 15073 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/3c/cdf34bc459613f2275aff9b258f35acdc4c4938dad161d17437de5d4c034/opentelemetry_exporter_otlp_proto_http-1.30.0-py3-none-any.whl", hash = "sha256:9578e790e579931c5ffd50f1e6975cbdefb6a0a0a5dea127a6ae87df10e0a589", size = 17245 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation"
|
||||
version = "0.51b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "packaging" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/5a/4c7f02235ac1269b48f3855f6be1afc641f31d4888d28b90b732fbce7141/opentelemetry_instrumentation-0.51b0.tar.gz", hash = "sha256:4ca266875e02f3988536982467f7ef8c32a38b8895490ddce9ad9604649424fa", size = 27760 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/2c/48fa93f1acca9f79a06da0df7bfe916632ecc7fce1971067b3e46bcae55b/opentelemetry_instrumentation-0.51b0-py3-none-any.whl", hash = "sha256:c6de8bd26b75ec8b0e54dff59e198946e29de6a10ec65488c357d4b34aa5bdcf", size = 30923 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation-asgi"
|
||||
version = "0.51b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-instrumentation" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "opentelemetry-util-http" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/67/8aa6e1129f641f0f3f8786e6c5d18c1f2bbe490bd4b0e91a6879e85154d2/opentelemetry_instrumentation_asgi-0.51b0.tar.gz", hash = "sha256:b3fe97c00f0bfa934371a69674981d76591c68d937b6422a5716ca21081b4148", size = 24201 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7e/0a95ab37302729543631a789ba8e71dea75c520495739dbbbdfdc580b401/opentelemetry_instrumentation_asgi-0.51b0-py3-none-any.whl", hash = "sha256:e8072993db47303b633c6ec1bc74726ba4d32bd0c46c28dfadf99f79521a324c", size = 16340 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation-dbapi"
|
||||
version = "0.51b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-instrumentation" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/b7/fdc107617b9f626632f5fbe444a6a91efa4a9d1e38447500802b8a12010c/opentelemetry_instrumentation_dbapi-0.51b0.tar.gz", hash = "sha256:740b5e17eef02a91a8d3966f06e5605817a7d875ae4d9dec8318ef652ccfc1fe", size = 13860 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/13/d3cd0292680ebd54ed6d55d7a81434bc2c6f7327d971c6690c98114d6abc/opentelemetry_instrumentation_dbapi-0.51b0-py3-none-any.whl", hash = "sha256:1b4dfb4f25b4ef509b70fb24c637436a40fe5fc8204933b956f1d0ccaa61735f", size = 12373 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation-fastapi"
|
||||
version = "0.51b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-instrumentation" },
|
||||
{ name = "opentelemetry-instrumentation-asgi" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "opentelemetry-util-http" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/dc/8db4422b5084177d1ef6c7855c69bf2e9e689f595a4a9b59e60588e0d427/opentelemetry_instrumentation_fastapi-0.51b0.tar.gz", hash = "sha256:1624e70f2f4d12ceb792d8a0c331244cd6723190ccee01336273b4559bc13abc", size = 19249 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/1c/ec2d816b78edf2404d7b3df6d09eefb690b70bfd191b7da06f76634f1bdc/opentelemetry_instrumentation_fastapi-0.51b0-py3-none-any.whl", hash = "sha256:10513bbc11a1188adb9c1d2c520695f7a8f2b5f4de14e8162098035901cd6493", size = 12117 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation-httpx"
|
||||
version = "0.51b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-instrumentation" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "opentelemetry-util-http" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/d5/4a3990c461ae7e55212115e0f8f3aa412b5ce6493579e85c292245ac69ea/opentelemetry_instrumentation_httpx-0.51b0.tar.gz", hash = "sha256:061d426a04bf5215a859fea46662e5074f920e5cbde7e6ad6825a0a1b595802c", size = 17700 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/ba/23d4ab6402408c01f1c3f32e0c04ea6dae575bf19bcb9a0049c9e768c983/opentelemetry_instrumentation_httpx-0.51b0-py3-none-any.whl", hash = "sha256:2e3fdf755ba6ead6ab43031497c3d55d4c796d0368eccc0ce48d304b7ec6486a", size = 14109 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation-sqlalchemy"
|
||||
version = "0.51b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-instrumentation" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "packaging" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/b2/970b1b46576b663bba64503486afe266c064c2bfd1862876420714ce29d9/opentelemetry_instrumentation_sqlalchemy-0.51b0.tar.gz", hash = "sha256:dbfe95b69006017f903dda194606be458d54789e6b3419d37161fb8861bb98a5", size = 14582 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/d4/b68c3b3388dd5107f3ed532747e112249c152ba44af71a1f96673d66e3ee/opentelemetry_instrumentation_sqlalchemy-0.51b0-py3-none-any.whl", hash = "sha256:5ff4816228b496aef1511149e2b17a25e0faacec4d5eb65bf18a9964af40f1af", size = 14132 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation-sqlite3"
|
||||
version = "0.51b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-instrumentation" },
|
||||
{ name = "opentelemetry-instrumentation-dbapi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/2a/1755f34fd1d58858272970ce9f8386a488ce2aa16c2673373ed31cc60d33/opentelemetry_instrumentation_sqlite3-0.51b0.tar.gz", hash = "sha256:3bd5dbe2292a68b27b79c44a13a03b1443341404e02351d3886ee6526792ead1", size = 7930 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/d0/6288eb2b6065b7766eee545729e6e68ac241ce82ec60a8452742414536c7/opentelemetry_instrumentation_sqlite3-0.51b0-py3-none-any.whl", hash = "sha256:77418bfec1b45f4d44a9a316c355aab33d36eb7cc1cd5d871f40acae36ae5c96", size = 9339 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "1.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/31/6e/c1ff2e3b0cd3a189a6be03fd4d63441d73d7addd9117ab5454e667b9b6c7/opentelemetry_proto-1.30.0.tar.gz", hash = "sha256:afe5c9c15e8b68d7c469596e5b32e8fc085eb9febdd6fb4e20924a93a0389179", size = 34362 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/d7/85de6501f7216995295f7ec11e470142e6a6e080baacec1753bbf272e007/opentelemetry_proto-1.30.0-py3-none-any.whl", hash = "sha256:c6290958ff3ddacc826ca5abbeb377a31c2334387352a259ba0df37c243adc11", size = 55854 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-sdk"
|
||||
version = "1.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/93/ee/d710062e8a862433d1be0b85920d0c653abe318878fef2d14dfe2c62ff7b/opentelemetry_sdk-1.30.0.tar.gz", hash = "sha256:c9287a9e4a7614b9946e933a67168450b9ab35f08797eb9bc77d998fa480fa18", size = 158633 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/28/64d781d6adc6bda2260067ce2902bd030cf45aec657e02e28c5b4480b976/opentelemetry_sdk-1.30.0-py3-none-any.whl", hash = "sha256:14fe7afc090caad881addb6926cec967129bd9260c4d33ae6a217359f6b61091", size = 118717 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-semantic-conventions"
|
||||
version = "0.51b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecated" },
|
||||
{ name = "opentelemetry-api" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/c0/0f9ef4605fea7f2b83d55dd0b0d7aebe8feead247cd6facd232b30907b4f/opentelemetry_semantic_conventions-0.51b0.tar.gz", hash = "sha256:3fabf47f35d1fd9aebcdca7e6802d86bd5ebc3bc3408b7e3248dde6e87a18c47", size = 107191 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/75/d7bdbb6fd8630b4cafb883482b75c4fc276b6426619539d266e32ac53266/opentelemetry_semantic_conventions-0.51b0-py3-none-any.whl", hash = "sha256:fdc777359418e8d06c86012c3dc92c88a6453ba662e941593adb062e48c2eeae", size = 177416 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-util-http"
|
||||
version = "0.51b0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/64/32510c0a803465eb6ef1f5bd514d0f5627f8abc9444ed94f7240faf6fcaa/opentelemetry_util_http-0.51b0.tar.gz", hash = "sha256:05edd19ca1cc3be3968b1e502fd94816901a365adbeaab6b6ddb974384d3a0b9", size = 8043 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/dd/c371eeb9cc78abbdad231a27ce1a196a37ef96328d876ccbb381dea4c8ee/opentelemetry_util_http-0.51b0-py3-none-any.whl", hash = "sha256:0561d7a6e9c422b9ef9ae6e77eafcfcd32a2ab689f5e801475cbb67f189efa20", size = 7304 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "24.2"
|
||||
@@ -729,6 +1042,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "5.29.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/d1/e0a911544ca9993e0f17ce6d3cc0932752356c1b0a834397f28e63479344/protobuf-5.29.3.tar.gz", hash = "sha256:5da0f41edaf117bde316404bad1a486cb4ededf8e4a54891296f648e8e076620", size = 424945 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/7a/1e38f3cafa022f477ca0f57a1f49962f21ad25850c3ca0acd3b9d0091518/protobuf-5.29.3-cp310-abi3-win32.whl", hash = "sha256:3ea51771449e1035f26069c4c7fd51fba990d07bc55ba80701c78f886bf9c888", size = 422708 },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/fa/aae8e10512b83de633f2646506a6d835b151edf4b30d18d73afd01447253/protobuf-5.29.3-cp310-abi3-win_amd64.whl", hash = "sha256:a4fa6f80816a9a0678429e84973f2f98cbc218cca434abe8db2ad0bffc98503a", size = 434508 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/04/3eaedc2ba17a088961d0e3bd396eac764450f431621b58a04ce898acd126/protobuf-5.29.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a8434404bbf139aa9e1300dbf989667a83d42ddda9153d8ab76e0d5dcaca484e", size = 417825 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/06/7c467744d23c3979ce250397e26d8ad8eeb2bea7b18ca12ad58313c1b8d5/protobuf-5.29.3-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:daaf63f70f25e8689c072cfad4334ca0ac1d1e05a92fc15c54eb9cf23c3efd84", size = 319573 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/45/2ebbde52ad2be18d3675b6bee50e68cd73c9e0654de77d595540b5129df8/protobuf-5.29.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:c027e08a08be10b67c06bf2370b99c811c466398c357e615ca88c91c07f0910f", size = 319672 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/b2/ab07b09e0f6d143dfb839693aa05765257bceaa13d03bf1a696b78323e7a/protobuf-5.29.3-py3-none-any.whl", hash = "sha256:0a18ed4a24198528f2333802eb075e59dea9d679ab7a6c5efb017a59004d849f", size = 172550 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.22"
|
||||
@@ -1058,6 +1385,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "13.9.4"
|
||||
@@ -1254,6 +1596,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/b7/6ec57841fb67c98f52fc8e4a2d96df60059637cba077edc569a302a8ffc7/Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39", size = 235494 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.34.0"
|
||||
@@ -1374,6 +1725,57 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "1.17.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799 },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821 },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919 },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721 },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222 },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567 },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824 },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690 },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155 },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471 },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339 },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232 },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986 },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
version = "3.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zope-event"
|
||||
version = "5.0"
|
||||
|
||||
Reference in New Issue
Block a user