From 28318d84359450b42f42ce6b5baa9967da151cde Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 7 Feb 2025 20:32:06 -0600 Subject: [PATCH 1/6] add install.sh script --- scripts/install.sh | 36 ++++++++++++++++++++++++++++++++++++ uv.lock | 8 ++++---- 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100755 scripts/install.sh diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..c2931d0a --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -e + +echo "Welcome to Basic Memory installer" + +# 1. Install uv if not present +if ! command -v uv &> /dev/null; then + echo "Installing uv package manager..." + curl -LsSf https://github.com/astral-sh/uv/releases/download/0.1.23/uv-installer.sh | sh +fi + +# 2. Configure Claude Desktop +echo "Configuring Claude Desktop..." +CONFIG_FILE="$HOME/Library/Application Support/Claude/claude_desktop_config.json" + +# Create config directory if it doesn't exist +mkdir -p "$(dirname "$CONFIG_FILE")" + +# If config file doesn't exist, create it with initial structure +if [ ! -f "$CONFIG_FILE" ]; then + echo '{"mcpServers": {}}' > "$CONFIG_FILE" +fi + +# Add/update the basic-memory config using jq +jq '.mcpServers."basic-memory" = { + "command": "uvx", + "args": ["basic-memory"] +}' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" + +echo "Installation complete! Basic Memory is now available in Claude Desktop." +echo "Please restart Claude Desktop for changes to take effect." + +echo -e "\nQuick Start:" +echo "1. You can run sync directly using: uvx basic-memory sync" +echo "2. Optionally, install globally with: uv pip install basic-memory" +echo -e "\nBuilt with ♥️ by Basic Machines." \ No newline at end of file diff --git a/uv.lock b/uv.lock index 685b9952..bf736ea9 100644 --- a/uv.lock +++ b/uv.lock @@ -61,7 +61,7 @@ wheels = [ [[package]] name = "basic-memory" -version = "0.0.1" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -144,7 +144,7 @@ name = "click" version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ @@ -582,7 +582,7 @@ email = [ { name = "email-validator" }, ] timezone = [ - { name = "tzdata", marker = "platform_system == 'Windows'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] [[package]] @@ -997,7 +997,7 @@ name = "tzlocal" version = "5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tzdata", marker = "platform_system == 'Windows'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/04/d3/c19d65ae67636fe63953b20c2e4a8ced4497ea232c43ff8d01db16de8dc0/tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e", size = 30201 } wheels = [ From c58a2e0893bf898341e935197bc6268a3714b03a Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 7 Feb 2025 22:02:59 -0600 Subject: [PATCH 2/6] cx freeze installer --- .gitignore | 2 + Makefile | 3 +- installer/README.md | 18 +++++ installer/installer.py | 51 ++++++++++++++ installer/setup.py | 16 +++++ pyproject.toml | 16 ++--- uv.lock | 146 +++++++++++++++++++++++++++++++++++++---- 7 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 installer/README.md create mode 100644 installer/installer.py create mode 100644 installer/setup.py diff --git a/.gitignore b/.gitignore index 5669fa6f..2a38550a 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ projects/*.db-journal *.log /.coverage.* + +installer/build/ \ No newline at end of file diff --git a/Makefile b/Makefile index 0996fa32..fd94aa56 100644 --- a/Makefile +++ b/Makefile @@ -36,4 +36,5 @@ format: format-python # run inspector tool run-dev: - uv run mcp dev src/basic_memory/mcp/main.py \ No newline at end of file + uv run mcp dev src/basic_memory/mcp/main.py + diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 00000000..f5fcb65e --- /dev/null +++ b/installer/README.md @@ -0,0 +1,18 @@ +# Basic Memory Installer + +This directory contains the macOS installer for Basic Memory. + +## Building the Installer + +Build the installer: +```bash +cd installer +python setup.py build +``` + +The installer app will be created in `installer/build/Basic Memory Installer` + +## Development + +- `installer.py` - Main installation script +- `setup.py` - py2app configuration for building the macOS app diff --git a/installer/installer.py b/installer/installer.py new file mode 100644 index 00000000..d798ea0c --- /dev/null +++ b/installer/installer.py @@ -0,0 +1,51 @@ +import json +import os +import subprocess +from pathlib import Path + +def ensure_uv_installed(): + """Check if uv is installed, install if not.""" + try: + subprocess.run(['uv', '--version'], capture_output=True, check=True) + except (subprocess.CalledProcessError, FileNotFoundError): + print("Installing uv package manager...") + subprocess.run(['curl', '-LsSf', 'https://github.com/astral-sh/uv/releases/download/0.1.23/uv-installer.sh', '|', 'sh'], shell=True) + +def update_claude_config(): + """Update Claude Desktop config to include basic-memory.""" + config_path = Path.home() / 'Library/Application Support/Claude/claude_desktop_config.json' + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Load existing config or create new + if config_path.exists(): + config = json.loads(config_path.read_text()) + else: + config = {"mcpServers": {}} + + # Add/update basic-memory config + config['mcpServers']['basic-memory'] = { + "command": "uvx", + "args": ["basic-memory"] + } + + # Write back config + config_path.write_text(json.dumps(config, indent=2)) + +def print_completion_message(): + """Show completion message with helpful tips.""" + print("\nInstallation complete! Basic Memory is now available in Claude Desktop.") + print("Please restart Claude Desktop for changes to take effect.") + print("\nQuick Start:") + print("1. You can run sync directly using: uvx basic-memory sync") + print("2. Optionally, install globally with: uv pip install basic-memory") + print("\nBuilt with ♥️ by Basic Machines.") + +def main(): + print("Welcome to Basic Memory installer") + ensure_uv_installed() + print("Configuring Claude Desktop...") + update_claude_config() + print_completion_message() + +if __name__ == '__main__': + main() diff --git a/installer/setup.py b/installer/setup.py new file mode 100644 index 00000000..e615440a --- /dev/null +++ b/installer/setup.py @@ -0,0 +1,16 @@ +from cx_Freeze import setup, Executable + +executables = [ + Executable( + "installer.py", + target_name="Basic Memory Installer", + base="gui" + ) +] + +setup( + name="basic-memory-installer", + version="1.0.0", + description="Installer for Basic Memory", + executables=executables, +) diff --git a/pyproject.toml b/pyproject.toml index 8feff213..8103880c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,15 +30,6 @@ dependencies = [ "alembic>=1.14.1", ] -[project.optional-dependencies] -dev = [ - "pytest>=8.3.4", - "pytest-cov>=4.1.0", - "pytest-mock>=3.12.0", - "pytest-asyncio>=0.24.0", - "ruff>=0.1.6", -] - [project.urls] Homepage = "https://github.com/basicmachines-co/basic-memory" Repository = "https://github.com/basicmachines-co/basic-memory" @@ -65,9 +56,14 @@ target-version = "py312" [tool.uv] dev-dependencies = [ "icecream>=2.1.3", + "pytest>=8.3.4", + "pytest-cov>=4.1.0", + "pytest-mock>=3.12.0", + "pytest-asyncio>=0.24.0", + "ruff>=0.1.6", + "cx-freeze>=7.2.10", ] - [tool.pyright] include = ["src/"] exclude = ["**/__pycache__"] diff --git a/uv.lock b/uv.lock index bf736ea9..0a56e664 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,14 @@ version = 1 requires-python = ">=3.12.1" +resolution-markers = [ + "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or sys_platform != 'linux'", + "platform_machine == 'x86_64' and sys_platform == 'linux'", + "platform_machine == 'i686' and sys_platform == 'linux'", + "platform_machine == 'aarch64' and sys_platform == 'linux'", + "platform_machine == 'armv7l' and sys_platform == 'linux'", + "platform_machine == 'ppc64le' and sys_platform == 'linux'", + "platform_machine == 's390x' and sys_platform == 'linux'", +] [[package]] name = "aiosqlite" @@ -85,8 +94,10 @@ dependencies = [ { name = "watchfiles" }, ] -[package.optional-dependencies] +[package.dev-dependencies] dev = [ + { name = "cx-freeze" }, + { name = "icecream" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -94,11 +105,6 @@ dev = [ { name = "ruff" }, ] -[package.dev-dependencies] -dev = [ - { name = "icecream" }, -] - [package.metadata] requires-dist = [ { name = "aiosqlite", specifier = ">=0.20.0" }, @@ -113,14 +119,9 @@ requires-dist = [ { name = "pydantic", extras = ["email", "timezone"], specifier = ">=2.10.3" }, { name = "pydantic-settings", specifier = ">=2.6.1" }, { name = "pyright", specifier = ">=1.1.390" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.4" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, - { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" }, { name = "python-frontmatter", specifier = ">=1.1.0" }, { name = "pyyaml", specifier = ">=6.0.1" }, { name = "rich", specifier = ">=13.9.4" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.6" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "typer", specifier = ">=0.9.0" }, { name = "unidecode", specifier = ">=1.3.8" }, @@ -128,7 +129,15 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "icecream", specifier = ">=2.1.3" }] +dev = [ + { name = "cx-freeze", specifier = ">=7.2.10" }, + { name = "icecream", specifier = ">=2.1.3" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-mock", specifier = ">=3.12.0" }, + { name = "ruff", specifier = ">=0.1.6" }, +] [[package]] name = "certifi" @@ -198,6 +207,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/55/52f5e66142a9d7bc93a15192eba7a78513d2abf6b3558d77b4ca32f5f424/coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d", size = 212781 }, ] +[[package]] +name = "cx-freeze" +version = "7.2.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cx-logging", marker = "sys_platform == 'win32'" }, + { name = "dmgbuild", marker = "sys_platform == 'darwin'" }, + { name = "filelock", marker = "sys_platform == 'linux'" }, + { name = "lief", marker = "sys_platform == 'win32'" }, + { name = "packaging" }, + { name = "patchelf", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'armv7l' and sys_platform == 'linux') or (platform_machine == 'i686' and sys_platform == 'linux') or (platform_machine == 'ppc64le' and sys_platform == 'linux') or (platform_machine == 's390x' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/c3/9f05155f7e0f55041add55cedc8724e9d36418c11c2e29131056fbce8e5e/cx_freeze-7.2.10.tar.gz", hash = "sha256:16a6eb37db7d158d0eb4bba8b93a135b6c85ea4bf2c4176c437bc34f01067e61", size = 3146042 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/9a/0f4084aa728a67974574eac2f1db3a032671536f68a935f340caa31b8667/cx_Freeze-7.2.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4a908dcad86888b741aa2051a65693d8662999a8abf9b7cf3285ef4ccfddad43", size = 21397503 }, + { url = "https://files.pythonhosted.org/packages/f6/ba/1a66ed666e128152ee9d43b6aa952db823de9918fc7c864373c1000816a4/cx_Freeze-7.2.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:416460e7f3312b1e2010ce5cb35e0449ff5a6e75b3536adddf83fd9b10d13a88", size = 12649925 }, + { url = "https://files.pythonhosted.org/packages/3c/bd/c237c1b0c48cf5fe2103dedad42a3d36c0ef6c44ab86265f8480d13b3c6f/cx_Freeze-7.2.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e251c74b2551668ae890d4c7a2fbb73a6c9b692de4096ddd9867322873e11b2b", size = 12787091 }, + { url = "https://files.pythonhosted.org/packages/8f/66/fae7d0992e72290a08494fa7c5826f00b5130e63747a434993a140746e07/cx_Freeze-7.2.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7f5d65830cb3b0d24590e4526995baf01beeb2517c41838d597c5a2e4ca76d7", size = 13766813 }, + { url = "https://files.pythonhosted.org/packages/f1/78/c123ec526b57531851d9d9c3338aa2df41b396fe3d23b7a2737bf1ef1a63/cx_Freeze-7.2.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d00914208a2953125398d192d1c332eea2bfb4f210a1507efe5188fec18fb8e3", size = 13315368 }, + { url = "https://files.pythonhosted.org/packages/ac/6c/4a3ef0c1991805505b2ea69fd09e1fd694138b011d2c62808ab5d4548b16/cx_Freeze-7.2.10-cp312-cp312-win32.whl", hash = "sha256:c0af82ba512323a1036a42c2e863ea56e12764c8c9f0764303df1fa3e29b9d4a", size = 2136880 }, + { url = "https://files.pythonhosted.org/packages/06/4c/d1c81cb9cf6b0a68837d7ee44a98a3269bafbc259cb8b3b96c22ac1e235e/cx_Freeze-7.2.10-cp312-cp312-win_amd64.whl", hash = "sha256:15b7dafc5eb2b5f6c118eaeed25b0fb0f3a9022e779958e5f9cd1ebf9f77c737", size = 2139867 }, +] + +[[package]] +name = "cx-logging" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/69/50b0c38e26658072b0221f1ea243c47dd56a9f3f50e5754aa5a39189145c/cx_logging-3.2.1.tar.gz", hash = "sha256:812665ae5012680a6fe47095c3772bce638e47cf05b2c3483db3bdbe6b06da44", size = 26966 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/9b/d8babcfafa7233b862b310a6fe630fc5e6ced02453ca4e60b0c819afbaff/cx_Logging-3.2.1-cp312-cp312-win32.whl", hash = "sha256:3f3de06cf09d5986b39e930c213567c340b3237dfce03d8d3bf6099475eaa02e", size = 22869 }, + { url = "https://files.pythonhosted.org/packages/5c/52/b6bd4f4d51eb4f3523da182cdf5969a560e35f4ef178f34841ba6795addc/cx_Logging-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3452add0544db6ff29116b72a4c48761aaffa9b638728330433853c0c4ad2ea1", size = 26911 }, + { url = "https://files.pythonhosted.org/packages/e1/78/0ce28b89aedf369b02bb5cb763324e799844144386fba75c03128ea9e2ff/cx_Logging-3.2.1-cp313-cp313-win32.whl", hash = "sha256:330a29030bdca8795c99b678b4f6d87a75fb606eed1da206fdd9fa579a33dc21", size = 22874 }, + { url = "https://files.pythonhosted.org/packages/cb/23/dab5f561888951ec02843f087f34a59c791e8ac6423c25a412eb49300633/cx_Logging-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:e14748b031522a95aa2db4adfc5f2be5f96f4d0fe687da591114f73a09e66926", size = 26916 }, +] + [[package]] name = "dateparser" version = "1.2.0" @@ -213,6 +258,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/29/db12aa4dda81580be1999824a689bd52aa40061fc12c9ccdc3feab5ea718/dateparser-1.2.0-py2.py3-none-any.whl", hash = "sha256:0b21ad96534e562920a0083e97fd45fa959882d4162acc358705144520a35830", size = 294995 }, ] +[[package]] +name = "dmgbuild" +version = "1.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ds-store", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or sys_platform != 'linux'" }, + { name = "mac-alias", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or sys_platform != 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/17/89c089f4263b6c7f40a69355dd74f2cf5b5a58c4f7db79f76ed3d9e1ca5b/dmgbuild-1.6.4.tar.gz", hash = "sha256:adacada75ee517398d014e3fb250b004a2225f01e54832df52d38b9cd1530b74", size = 36824 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/c5/71ecb2f6a95082aa6a7227a92054024e29caba133a7a86094ce32fd03c1f/dmgbuild-1.6.4-py3-none-any.whl", hash = "sha256:53a2f0a9e65111314fce7ecfb6637d08bc3186fa99e719abc9a5af2b484a7d65", size = 34900 }, +] + [[package]] name = "dnspython" version = "2.7.0" @@ -222,6 +280,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 }, ] +[[package]] +name = "ds-store" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mac-alias", marker = "(platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'i686' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_machine != 'x86_64') or sys_platform != 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/36/902259bf7ddb142dd91cf7a9794aa15e1a8ab985974f90375e5d3463b441/ds_store-1.3.1.tar.gz", hash = "sha256:c27d413caf13c19acb85d75da4752673f1f38267f9eb6ba81b3b5aa99c2d207c", size = 27052 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/bf/b1c10362a0d670ee8ae086d92c3ab795fca2a927e4ff25e7cd15224d3863/ds_store-1.3.1-py3-none-any.whl", hash = "sha256:fbacbb0bd5193ab3e66e5a47fff63619f15e374ffbec8ae29744251a6c8f05b5", size = 16268 }, +] + [[package]] name = "email-validator" version = "2.2.0" @@ -287,6 +357,15 @@ standard = [ { name = "uvicorn", extra = ["standard"] }, ] +[[package]] +name = "filelock" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/9c/0b15fb47b464e1b663b1acd1253a062aa5feecb07d4e597daea542ebd2b5/filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e", size = 18027 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/ec/00d68c4ddfedfe64159999e5f8a98fb8442729a63e2077eb9dcd89623d27/filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338", size = 16164 }, +] + [[package]] name = "greenlet" version = "3.1.1" @@ -433,6 +512,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596 }, ] +[[package]] +name = "lief" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/c6/2b8eab0b1c061d2a0a29642cbef91b9b36148e86f3fce99200ede5e8e788/lief-0.16.3-cp312-cp312-win32.whl", hash = "sha256:e7ba797829584c5cc1c8a736b2f1587f09b1f3030239c968c7664649fb79ae15", size = 3042391 }, + { url = "https://files.pythonhosted.org/packages/76/4e/7bb9efe6bbe804449c150295fa597c7e10fd7264c5a5205c38f9b3bd9942/lief-0.16.3-cp312-cp312-win_amd64.whl", hash = "sha256:70c1f5f66bd4eeead2853a7a80d941b4dd03e3791c68c5351b0d39c78cfa9afe", size = 3167314 }, + { url = "https://files.pythonhosted.org/packages/57/9a/be854d274352a2ce406691fb6c80c82b4a6adf0fdb6978734e4c082326b7/lief-0.16.3-cp313-cp313-win32.whl", hash = "sha256:aefbe78b06d9e89387ab8fc069d1cb34252f5916cf35eaa21088b21b74b99d08", size = 3042393 }, + { 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 = "loguru" version = "0.7.3" @@ -446,6 +536,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 }, ] +[[package]] +name = "mac-alias" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/a3/83b50f620d318a98363dc7e701fb94856eaaecc472e23a89ac625697b3ea/mac_alias-2.2.2.tar.gz", hash = "sha256:c99c728eb512e955c11f1a6203a0ffa8883b26549e8afe68804031aa5da856b7", size = 34073 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a1/4136777ed6a56df83e7c748ad28892f0672cbbcdc3b3d15a57df6ba72443/mac_alias-2.2.2-py3-none-any.whl", hash = "sha256:504ab8ac546f35bbd75ad014d6ad977c426660aa721f2cd3acf3dc2f664141bd", size = 21220 }, +] + [[package]] name = "mako" version = "1.3.9" @@ -554,6 +653,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] +[[package]] +name = "patchelf" +version = "0.17.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/ec/ac383eb82792e092d8037649b382cf78a7b79c2ce4e5b861f61519b9b14e/patchelf-0.17.2.1.tar.gz", hash = "sha256:a6eb0dd452ce4127d0d5e1eb26515e39186fa609364274bc1b0b77539cfa7031", size = 167484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/09/b7ec52d01ad2936c967e929fd076b0a2e4f94ce950c9440e38f98c67675e/patchelf-0.17.2.1-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:fc329da0e8f628bd836dfb8eaf523547e342351fa8f739bf2b3fe4a6db5a297c", size = 421930 }, + { url = "https://files.pythonhosted.org/packages/69/47/e02357d1075cdf4b56be39a6c218a5a2b0bd3896011120ae3765190ab527/patchelf-0.17.2.1-py2.py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:ccb266a94edf016efe80151172c26cff8c2ec120a57a1665d257b0442784195d", size = 381147 }, + { url = "https://files.pythonhosted.org/packages/92/78/3f4e19d6ba7acf1e01db6d82d23e0435a76466187644ce0dbcf6bd621317/patchelf-0.17.2.1-py2.py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:f47b5bdd6885cfb20abdd14c707d26eb6f499a7f52e911865548d4aa43385502", size = 488526 }, + { url = "https://files.pythonhosted.org/packages/08/d3/adedfbcff489605a0e907f390006ef7d59a3ac56a9e2dbe721ddbbb1e58e/patchelf-0.17.2.1-py2.py3-none-manylinux_2_17_s390x.manylinux2014_s390x.musllinux_1_1_s390x.whl", hash = "sha256:a9e6ebb0874a11f7ed56d2380bfaa95f00612b23b15f896583da30c2059fcfa8", size = 470060 }, + { url = "https://files.pythonhosted.org/packages/c6/9b/33e54d5916863904609e40b53206e5a49cf649b71f7f787eddcbc27a9f5c/patchelf-0.17.2.1-py2.py3-none-manylinux_2_5_i686.manylinux1_i686.musllinux_1_1_i686.whl", hash = "sha256:3c8d58f0e4c1929b1c7c45ba8da5a84a8f1aa6a82a46e1cfb2e44a4d40f350e5", size = 499565 }, + { url = "https://files.pythonhosted.org/packages/c6/73/c3105c973dd2afcdc5d946ee211d5c4ecdf9d27bb54ae835b144e706e86d/patchelf-0.17.2.1-py2.py3-none-manylinux_2_5_x86_64.manylinux1_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:d1a9bc0d4fd80c038523ebdc451a1cce75237cfcc52dbd1aca224578001d5927", size = 425709 }, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -878,6 +991,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/4e/33df635528292bd2d18404e4daabcd74ca8a9853b2e1df85ed3d32d24362/ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6", size = 10001738 }, ] +[[package]] +name = "setuptools" +version = "75.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ec/089608b791d210aec4e7f97488e67ab0d33add3efccb83a056cbafe3a2a6/setuptools-75.8.0.tar.gz", hash = "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6", size = 1343222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/8a/b9dc7678803429e4a3bc9ba462fa3dd9066824d3c607490235c6a796be5a/setuptools-75.8.0-py3-none-any.whl", hash = "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3", size = 1228782 }, +] + [[package]] name = "shellingham" version = "1.5.4" From 12cd1e43b0f2021a06b51578b908d967b1601fbc Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 7 Feb 2025 23:09:48 -0600 Subject: [PATCH 3/6] create .dmg --- Makefile | 29 ++++++++++++++++++++++++++- installer/setup.py | 26 +++++++++++++++++++++++- pyproject.toml | 1 + uv.lock | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index fd94aa56..2224d82f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install test lint db-new db-up db-down db-reset +.PHONY: install test lint db-new db-up db-down db-reset installer installer-deps installer-build installer-dmg install: brew install dbmate @@ -27,6 +27,9 @@ db-reset: clean: find . -type f -name '*.pyc' -delete find . -type d -name '__pycache__' -exec rm -r {} + + rm -rf installer/build/ + rm -rf installer/dist/ + rm -f rw.*.dmg format: uv run ruff format . @@ -38,3 +41,27 @@ format: format-python run-dev: uv run mcp dev src/basic_memory/mcp/main.py +# Installer targets +installer-deps: + uv pip install -e ".[dev]" + brew install create-dmg || true # Don't fail if already installed + +installer-build: + cd installer && python setup.py bdist_mac + +installer-dmg: + mkdir -p installer/dist + create-dmg \ + --volname "Basic Memory Installer" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "Basic Memory Installer.app" 200 190 \ + --hide-extension "Basic Memory Installer.app" \ + --app-drop-link 600 185 \ + "installer/dist/Basic Memory-Installer.dmg" \ + "installer/build/Basic Memory Installer.app" + + +# Main installer target that runs everything +installer: installer-deps installer-build installer-dmg \ No newline at end of file diff --git a/installer/setup.py b/installer/setup.py index e615440a..01c72bdb 100644 --- a/installer/setup.py +++ b/installer/setup.py @@ -1,10 +1,28 @@ from cx_Freeze import setup, Executable +import sys + +# Base configuration for macOS +base = "gui" if sys.platform == "darwin" else None + +# Mac specific options +mac_options = { + 'bundle_name': 'Basic Memory Installer', + 'plist_items': [ + # Convert dict to list of tuples for plist items + ('CFBundleName', 'Basic Memory Installer'), + ('CFBundleDisplayName', 'Basic Memory Installer'), + ('CFBundleIdentifier', 'com.basicmemory.installer'), + ('CFBundleVersion', '1.0.0'), + ('CFBundlePackageType', 'APPL'), + ('LSMinimumSystemVersion', '10.13.0'), + ] +} executables = [ Executable( "installer.py", target_name="Basic Memory Installer", - base="gui" + base=base, ) ] @@ -13,4 +31,10 @@ setup( version="1.0.0", description="Installer for Basic Memory", executables=executables, + options={ + "build_exe": { + "include_files": [], + }, + "bdist_mac": mac_options, + } ) diff --git a/pyproject.toml b/pyproject.toml index 8103880c..23aef801 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ dev-dependencies = [ "pytest-asyncio>=0.24.0", "ruff>=0.1.6", "cx-freeze>=7.2.10", + "pyqt6>=6.8.1", ] [tool.pyright] diff --git a/uv.lock b/uv.lock index 0a56e664..b70047c0 100644 --- a/uv.lock +++ b/uv.lock @@ -98,6 +98,7 @@ dependencies = [ dev = [ { name = "cx-freeze" }, { name = "icecream" }, + { name = "pyqt6" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -132,6 +133,7 @@ requires-dist = [ dev = [ { name = "cx-freeze", specifier = ">=7.2.10" }, { name = "icecream", specifier = ">=2.1.3" }, + { name = "pyqt6", specifier = ">=6.8.1" }, { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=4.1.0" }, @@ -759,6 +761,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, ] +[[package]] +name = "pyqt6" +version = "6.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyqt6-qt6" }, + { name = "pyqt6-sip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/bf/ff284a136b39cb1873c18e4fca4a40a8847c84a1910c5fb38c6a77868968/pyqt6-6.8.1.tar.gz", hash = "sha256:91d937d6166274fafd70f4dee11a8da6dbfdb0da53de05f5d62361ddf775e256", size = 1064723 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/da/70971b3d7f53a68644ea32544d3786dfbbb162d18572ac1defcf5a6481d5/PyQt6-6.8.1-cp39-abi3-macosx_10_14_universal2.whl", hash = "sha256:0425f9eebdd5d4e57ab36424c9382f2ea06670c3c550fa0028c2b19bd0a1d7bd", size = 12213924 }, + { url = "https://files.pythonhosted.org/packages/be/25/a4392c323a0fb97eb5f449b7594f37e93d9794b900756b43cd65772def77/PyQt6-6.8.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:36bf48e3df3a6ff536e703315d155480ef4e260396eb5469eb7a875bc5bb7ab4", size = 8238120 }, + { url = "https://files.pythonhosted.org/packages/de/a3/e528b4cc3394f2ae15b531c17f27b53de756a8c0404dfa9c184502367c48/PyQt6-6.8.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:2eac2267a34828b8db7660dd3cc3b3b5fd76a92e61ad45471565b01221cb558b", size = 8173996 }, + { url = "https://files.pythonhosted.org/packages/f2/69/11404cfcb916bd7207805c21432ecab0401779361d48b67f28ae9337f70d/PyQt6-6.8.1-cp39-abi3-win_amd64.whl", hash = "sha256:70bad7b890a8f9e9e5fb9598c544b832d9d9d99a9519e0009cb29c1e15e96632", size = 6723466 }, + { url = "https://files.pythonhosted.org/packages/00/2a/21a555aea9bc8abc4f09017b922dbdf509c421f70506d4c83d2e8f4315b2/PyQt6-6.8.1-cp39-abi3-win_arm64.whl", hash = "sha256:a40f878e8e5eeeb0bba995152d07eeef9375ea0116df0f4aad0a6b97c8ad1175", size = 5463379 }, +] + +[[package]] +name = "pyqt6-qt6" +version = "6.8.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/a4/3d764e05955382b3dc7227cbfde090700edd63431147f1c66d428ccac45c/PyQt6_Qt6-6.8.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:470dd4211fe5a67b0565e0202e7aa67816e5dcf7d713528b88327adaebd0934e", size = 66121240 }, + { url = "https://files.pythonhosted.org/packages/d6/b3/6d4f8257b46554fb2c89b33a6773a3f05ed961b3cd83828caee5dc79899f/PyQt6_Qt6-6.8.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:40cda901a3e1617e79225c354fe9d89b80249f0a6c6aaa18b40938e05bbf7d1f", size = 60286219 }, + { url = "https://files.pythonhosted.org/packages/92/95/0036435b9e2cbd22e08f14eec2362c32fc641660c6e4aea6f59d165cb5fc/PyQt6_Qt6-6.8.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:fb6d0acdd7d43c33fb8b9d2dd7922d381cdedd00da316049fbe01fc1973e6f05", size = 81263397 }, + { url = "https://files.pythonhosted.org/packages/6e/fb/c01dde044eca1542d88cac72fc99369af76a981cc2f52790236efa566e01/PyQt6_Qt6-6.8.2-py3-none-manylinux_2_39_aarch64.whl", hash = "sha256:5970c85d22cbe5c476418994549161b23ed938e25b04fc4ca8fabf6dcac7b03f", size = 79832921 }, + { url = "https://files.pythonhosted.org/packages/1a/f7/31f03a9f5e6c7cc23ceb2bd0d9c2df0518837f7af0e693e15b6e0881b8b0/PyQt6_Qt6-6.8.2-py3-none-win_amd64.whl", hash = "sha256:28e2bb641f05b01e498503c3ef01c8a919d6e0e96b50230301c0baac2b7d1433", size = 71934164 }, + { url = "https://files.pythonhosted.org/packages/00/c9/102c9537795ca11c12120ec9d5f554d9437787f52d8e23fbc8269e6a2699/PyQt6_Qt6-6.8.2-py3-none-win_arm64.whl", hash = "sha256:912afdddd0dfc666ce1c16bc4695e2acd680db72343e4f7a2b7c053a0146b4bc", size = 48120018 }, +] + +[[package]] +name = "pyqt6-sip" +version = "13.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/18/0405c54acba0c8e276dd6f0601890e6e735198218d031a6646104870fe22/pyqt6_sip-13.10.0.tar.gz", hash = "sha256:d6daa95a0bd315d9ec523b549e0ce97455f61ded65d5eafecd83ed2aa4ae5350", size = 92464 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/81/66d9bdacb790592a0641378749a047f12e3b254cdc2cb51f7ed636cf01d2/PyQt6_sip-13.10.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:48791db2914fc39c3218519a02d2a5fd3fcd354a1be3141a57bf2880701486f2", size = 112334 }, + { url = "https://files.pythonhosted.org/packages/26/2c/4796c209009a018e0d4a5c406d5a519234c5a378f370dc679d0ad5f455b2/PyQt6_sip-13.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:466d6b4791973c9fcbdc2e0087ed194b9ea802a8c3948867a849498f0841c70c", size = 322334 }, + { url = "https://files.pythonhosted.org/packages/99/34/2ec54bd475f0a811df1d32be485f2344cf9e8b388ce7adb26b46ce5552d4/PyQt6_sip-13.10.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ae15358941f127cd3d1ab09c1ebd45c4dabb0b2e91587b9eebde0279d0039c54", size = 303798 }, + { url = "https://files.pythonhosted.org/packages/0c/e4/82099bb4ab8bc152b5718541e93c0b3adf7566c0f307c9e58e2368b8c517/PyQt6_sip-13.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad573184fa8b00041944e5a17d150ab0d08db2d2189e39c9373574ebab3f2e58", size = 53569 }, + { url = "https://files.pythonhosted.org/packages/e3/09/90e0378887a3cb9664da77061229cf8e97e6ec25a5611b7dbc9cc3e02c78/PyQt6_sip-13.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:2d579d810d0047d40bde9c6aef281d6ed218db93c9496ebc9e55b9e6f27a229d", size = 45430 }, + { url = "https://files.pythonhosted.org/packages/6b/0c/8d1de48b45b565a46bf4757341f13f9b1853a7d2e6b023700f0af2c213ab/PyQt6_sip-13.10.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b6e250c2e7c14702a623f2cc1479d7fb8db2b6eee9697cac10d06fe79c281bb", size = 112343 }, + { url = "https://files.pythonhosted.org/packages/af/13/e2cc2b667a9f5d44c2d0e18fa6e1066fca3f4521dcb301f4b5374caeb33e/PyQt6_sip-13.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fcb30756568f8cd59290f9ef2ae5ee3e72ff9cdd61a6f80c9e3d3b95ae676be", size = 322527 }, + { url = "https://files.pythonhosted.org/packages/20/1a/5c6fcae85edb65cf236c9dc6d23b279b5316e94cdca1abdee6d0a217ddbb/PyQt6_sip-13.10.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:757ac52c92b2ef0b56ecc7cd763b55a62d3c14271d7ea8d03315af85a70090ff", size = 303407 }, + { url = "https://files.pythonhosted.org/packages/b9/db/6924ec985be7d746772806b96ab81d24263ef72f0249f0573a82adaed75e/PyQt6_sip-13.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:571900c44a3e38738d696234d94fe2043972b9de0633505451c99e2922cb6a34", size = 53580 }, + { url = "https://files.pythonhosted.org/packages/77/c3/9e44729b582ee7f1d45160e8c292723156889f3e38ce6574f88d5ab8fa02/PyQt6_sip-13.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:39cba2cc71cf80a99b4dc8147b43508d4716e128f9fb99f5eb5860a37f082282", size = 45446 }, +] + [[package]] name = "pyright" version = "1.1.392.post0" From fa77a41e6f0fdfa46d610e83d71e667ce2d5409e Mon Sep 17 00:00:00 2001 From: phernandez Date: Sat, 8 Feb 2025 10:22:47 -0600 Subject: [PATCH 4/6] menubar app wip --- .gitignore | 32 ++++++++--------- pyproject.toml | 1 + src/basic_memory/ui/menubar/__init__.py | 4 +++ src/basic_memory/ui/menubar/app.py | 47 +++++++++++++++++++++++++ uv.lock | 36 +++++++++++++++++++ 5 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 src/basic_memory/ui/menubar/__init__.py create mode 100644 src/basic_memory/ui/menubar/app.py diff --git a/.gitignore b/.gitignore index 2a38550a..a3619ea8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ -# Python -__pycache__/ *.py[cod] -*$py.class -*.so +__pycache__/ +.pytest_cache/ +.coverage +htmlcov/ + +# Distribution / packaging .Python build/ develop-eggs/ @@ -20,7 +22,12 @@ wheels/ .installed.cfg *.egg -# Virtual Environment +# Installer artifacts +installer/build/ +installer/dist/ +rw.*.dmg # Temporary disk images + +# Virtual environments .env .venv env/ @@ -30,15 +37,8 @@ ENV/ # IDE .idea/ .vscode/ +*.swp +*.swo -# Project specific -projects/*.db -projects/*.db-journal -/.coverage - -**/.DS_Store - -*.log -/.coverage.* - -installer/build/ \ No newline at end of file +# macOS +.DS_Store diff --git a/pyproject.toml b/pyproject.toml index 23aef801..ecd76c96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "watchfiles>=1.0.4", "fastapi[standard]>=0.115.8", "alembic>=1.14.1", + "rumps>=0.4.0", ] [project.urls] diff --git a/src/basic_memory/ui/menubar/__init__.py b/src/basic_memory/ui/menubar/__init__.py new file mode 100644 index 00000000..d16b6be1 --- /dev/null +++ b/src/basic_memory/ui/menubar/__init__.py @@ -0,0 +1,4 @@ +"""Basic Memory menubar application package.""" +from .app import BasicMemoryApp, main + +__all__ = ["BasicMemoryApp", "main"] diff --git a/src/basic_memory/ui/menubar/app.py b/src/basic_memory/ui/menubar/app.py new file mode 100644 index 00000000..0466740b --- /dev/null +++ b/src/basic_memory/ui/menubar/app.py @@ -0,0 +1,47 @@ +"""Basic Memory menubar app using rumps.""" +import rumps +from loguru import logger + +class BasicMemoryApp(rumps.App): + def __init__(self): + super().__init__("Basic Memory", "✓") + + # Initialize menu structure + self.menu = [ + rumps.MenuItem("Status: Idle"), + None, # separator + rumps.MenuItem("Recent Events"), + None, + ["Settings", + "Choose Home Directory...", + "Start at Login" + ], + None, + "Start Sync", + ] + + @rumps.clicked("Start Sync") + def toggle_sync(self, _): + """Toggle sync on/off.""" + sender = self.menu["Start Sync"] + if sender.title == "Start Sync": + sender.title = "Stop Sync" + self.icon = "↻" + # TODO: Start sync process + else: + sender.title = "Start Sync" + self.icon = "✓" + # TODO: Stop sync process + + @rumps.clicked("Settings", "Choose Home Directory...") + def choose_home_dir(self, _): + """Open dialog to choose home directory.""" + # TODO: Implement directory chooser + logger.info("Choose home directory clicked") + +def main(): + """Run the menubar app.""" + BasicMemoryApp().run() + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index b70047c0..4c407134 100644 --- a/uv.lock +++ b/uv.lock @@ -88,6 +88,7 @@ dependencies = [ { name = "python-frontmatter" }, { name = "pyyaml" }, { name = "rich" }, + { name = "rumps" }, { name = "sqlalchemy" }, { name = "typer" }, { name = "unidecode" }, @@ -123,6 +124,7 @@ requires-dist = [ { name = "python-frontmatter", specifier = ">=1.1.0" }, { name = "pyyaml", specifier = ">=6.0.1" }, { name = "rich", specifier = ">=13.9.4" }, + { name = "rumps", specifier = ">=0.4.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "typer", specifier = ">=0.9.0" }, { name = "unidecode", specifier = ">=1.3.8" }, @@ -761,6 +763,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, ] +[[package]] +name = "pyobjc-core" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/94/a111239b98260869780a5767e5d74bfd3a8c13a40457f479c28dcd91f89d/pyobjc_core-11.0.tar.gz", hash = "sha256:63bced211cb8a8fb5c8ff46473603da30e51112861bd02c438fbbbc8578d9a70", size = 994931 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/ce/bf3ff9a9347721a398c3dfb83e29b43fb166b7ef590f3f7b7ddcd283df39/pyobjc_core-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a03061d4955c62ddd7754224a80cdadfdf17b6b5f60df1d9169a3b1b02923f0b", size = 739750 }, + { url = "https://files.pythonhosted.org/packages/72/16/0c468e73dbecb821e3da8819236fe832dfc53eb5f66a11775b055a7589ea/pyobjc_core-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c338c1deb7ab2e9436d4175d1127da2eeed4a1b564b3d83b9f3ae4844ba97e86", size = 743900 }, + { url = "https://files.pythonhosted.org/packages/f3/88/cecec88fd51f62a6cd7775cc4fb6bfde16652f97df88d28c84fb77ca0c18/pyobjc_core-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b4e9dc4296110f251a4033ff3f40320b35873ea7f876bd29a1c9705bb5e08c59", size = 791905 }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/32/53809096ad5fc3e7a2c5ddea642590a5f2cb5b81d0ad6ea67fdb2263d9f9/pyobjc_framework_cocoa-11.0.tar.gz", hash = "sha256:00346a8cb81ad7b017b32ff7bf596000f9faa905807b1bd234644ebd47f692c5", size = 6173848 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/8d/0e2558447c26b3ba64f7c9776a5a6c9d2ae8abf9d34308b174ae0934402e/pyobjc_framework_Cocoa-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:280a577b83c68175a28b2b7138d1d2d3111f2b2b66c30e86f81a19c2b02eae71", size = 385811 }, + { url = "https://files.pythonhosted.org/packages/1d/a5/609281a7e89efefbef9db1d8fe66bc0458c3b4e74e2227c644f9c18926fa/pyobjc_framework_Cocoa-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:15b2bd977ed340074f930f1330f03d42912d5882b697d78bd06f8ebe263ef92e", size = 385889 }, + { url = "https://files.pythonhosted.org/packages/93/f6/2d5a863673ef7b85a3cba875c43e6c495fb1307427a6801001ae94bb5e54/pyobjc_framework_Cocoa-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5750001db544e67f2b66f02067d8f0da96bb2ef71732bde104f01b8628f9d7ea", size = 389831 }, +] + [[package]] name = "pyqt6" version = "6.8.1" @@ -1041,6 +1068,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/4e/33df635528292bd2d18404e4daabcd74ca8a9853b2e1df85ed3d32d24362/ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6", size = 10001738 }, ] +[[package]] +name = "rumps" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/e2/2e6a47951290bd1a2831dcc50aec4b25d104c0cf00e8b7868cbd29cf3bfe/rumps-0.4.0.tar.gz", hash = "sha256:17fb33c21b54b1e25db0d71d1d793dc19dc3c0b7d8c79dc6d833d0cffc8b1596", size = 39257 } + [[package]] name = "setuptools" version = "75.8.0" From 8094528c40e40955d8c7f91a5f5778faef752f70 Mon Sep 17 00:00:00 2001 From: phernandez Date: Sat, 8 Feb 2025 11:50:54 -0600 Subject: [PATCH 5/6] menubar wip --- pyproject.toml | 1 + src/basic_memory/cli/commands/sync.py | 44 -------- src/basic_memory/sync/__init__.py | 4 +- src/basic_memory/sync/watch_service.py | 28 ++++- src/basic_memory/ui/menubar/app.py | 136 ++++++++++++++++++------- uv.lock | 11 ++ 6 files changed, 142 insertions(+), 82 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ecd76c96..9815bbb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "fastapi[standard]>=0.115.8", "alembic>=1.14.1", "rumps>=0.4.0", + "qasync>=0.27.1", ] [project.urls] diff --git a/src/basic_memory/cli/commands/sync.py b/src/basic_memory/cli/commands/sync.py index 3b2a35cf..c100e75b 100644 --- a/src/basic_memory/cli/commands/sync.py +++ b/src/basic_memory/cli/commands/sync.py @@ -97,51 +97,7 @@ def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[V return dict(grouped) -def display_validation_errors(issues: List[ValidationIssue]): - """Display validation errors in a rich, organized format.""" - # Create header - console.print() - console.print( - Panel("[red bold]Error:[/red bold] Invalid frontmatter in knowledge files", expand=False) - ) - console.print() - # Group issues by directory - grouped_issues = group_issues_by_directory(issues) - - # Create tree structure - tree = Tree("Knowledge Files") - for dir_name, dir_issues in sorted(grouped_issues.items()): - # Create branch for directory - branch = tree.add( - f"[bold blue]{dir_name}/[/bold blue] ([yellow]{len(dir_issues)} files[/yellow])" - ) - - # Add each file issue - for issue in sorted(dir_issues, key=lambda x: x.file_path): - file_name = Path(issue.file_path).name - branch.add( - Text.assemble(("└─ ", "dim"), (file_name, "yellow"), ": ", (issue.error, "red")) - ) - - # Display tree - console.print(Padding(tree, (1, 2))) - - # Add help text - console.print() - console.print( - Panel( - Text.assemble( - ("To fix:", "bold"), - "\n1. Add required frontmatter fields to each file", - "\n2. Run ", - ("basic-memory sync", "bold cyan"), - " again", - ), - expand=False, - ) - ) - console.print() def display_sync_summary(knowledge: SyncReport): diff --git a/src/basic_memory/sync/__init__.py b/src/basic_memory/sync/__init__.py index 024c0cc2..c108df5d 100644 --- a/src/basic_memory/sync/__init__.py +++ b/src/basic_memory/sync/__init__.py @@ -1,5 +1,7 @@ from .file_change_scanner import FileChangeScanner from .sync_service import SyncService +from .watch_service import WatchService + +__all__ = ["SyncService", "FileChangeScanner", "WatchService"] -__all__ = ["SyncService", "FileChangeScanner"] diff --git a/src/basic_memory/sync/watch_service.py b/src/basic_memory/sync/watch_service.py index 643e4b0f..728bbec1 100644 --- a/src/basic_memory/sync/watch_service.py +++ b/src/basic_memory/sync/watch_service.py @@ -131,13 +131,36 @@ class WatchService: return table - async def run(self): + async def run(self, console_status: bool = False): """Watch for file changes and sync them""" + logger.info("Watching for sync changes") self.state.running = True self.state.start_time = datetime.now() await self.write_status() - with Live(self.generate_table(), refresh_per_second=4, console=self.console) as live: + if console_status: + with Live(self.generate_table(), refresh_per_second=4, console=self.console) as live: + try: + async for changes in awatch( + self.config.home, + watch_filter=self.filter_changes, + debounce=self.config.sync_delay, + recursive=True, + ): + # Process changes + await self.handle_changes(self.config.home) + # Update display + live.update(self.generate_table()) + + except Exception as e: + self.state.record_error(str(e)) + await self.write_status() + raise + finally: + self.state.running = False + await self.write_status() + + else: try: async for changes in awatch( self.config.home, @@ -148,7 +171,6 @@ class WatchService: # Process changes await self.handle_changes(self.config.home) # Update display - live.update(self.generate_table()) except Exception as e: self.state.record_error(str(e)) diff --git a/src/basic_memory/ui/menubar/app.py b/src/basic_memory/ui/menubar/app.py index 0466740b..8135b2b3 100644 --- a/src/basic_memory/ui/menubar/app.py +++ b/src/basic_memory/ui/menubar/app.py @@ -1,47 +1,115 @@ -"""Basic Memory menubar app using rumps.""" -import rumps +"""Basic Memory menubar app using PyQt.""" +import sys +import asyncio +from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QStyle +from PyQt6.QtCore import QTimer +from PyQt6.QtGui import QIcon from loguru import logger +import qasync -class BasicMemoryApp(rumps.App): +from basic_memory.config import config +from basic_memory.cli.commands.sync import get_sync_service +from basic_memory.sync.watch_service import WatchService + +class BasicMemoryTray(QSystemTrayIcon): def __init__(self): - super().__init__("Basic Memory", "✓") + super().__init__() - # Initialize menu structure - self.menu = [ - rumps.MenuItem("Status: Idle"), - None, # separator - rumps.MenuItem("Recent Events"), - None, - ["Settings", - "Choose Home Directory...", - "Start at Login" - ], - None, - "Start Sync", - ] + # Set default icon + self.setIcon(QApplication.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon)) + + # Create menu + menu = QMenu() + self.status_item = menu.addAction("Status: Idle") + self.status_item.setEnabled(False) + + menu.addSeparator() + self.sync_action = menu.addAction("Start Sync") + self.sync_action.triggered.connect(self.toggle_sync) + + menu.addSeparator() + quit_action = menu.addAction("Quit") + quit_action.triggered.connect(QApplication.instance().quit) + + self.setContextMenu(menu) + self.show() - @rumps.clicked("Start Sync") - def toggle_sync(self, _): - """Toggle sync on/off.""" - sender = self.menu["Start Sync"] - if sender.title == "Start Sync": - sender.title = "Stop Sync" - self.icon = "↻" - # TODO: Start sync process + # Initialize sync state + self.watch_service = None + self.sync_task = None + self.is_syncing = False + + # Update timer for status + self.status_timer = QTimer() + self.status_timer.timeout.connect(self.update_status) + self.status_timer.start(1000) # Update every second + + logger.info("Basic Memory tray initialized") + + def toggle_sync(self): + """Start or stop sync process.""" + if not self.is_syncing: + logger.info("Starting sync") + self.start_sync() + self.sync_action.setText("Stop Sync") + self.is_syncing = True else: - sender.title = "Start Sync" - self.icon = "✓" - # TODO: Stop sync process + logger.info("Stopping sync") + self.stop_sync() + self.sync_action.setText("Start Sync") + self.is_syncing = False - @rumps.clicked("Settings", "Choose Home Directory...") - def choose_home_dir(self, _): - """Open dialog to choose home directory.""" - # TODO: Implement directory chooser - logger.info("Choose home directory clicked") + def start_sync(self): + """Start the sync service.""" + async def run_sync(): + try: + sync_service = await get_sync_service() + self.watch_service = WatchService( + sync_service=sync_service, + file_service=sync_service.entity_service.file_service, + config=config + ) + await self.watch_service.run(console_status=False) + except Exception as e: + logger.exception("Error in sync service") + self.status_item.setText(f"Status: Error - {str(e)}") + + # Start sync using the event loop + loop = asyncio.get_event_loop() + self.sync_task = loop.create_task(run_sync()) + + def stop_sync(self): + """Stop the sync service.""" + if self.watch_service: + self.watch_service.state.running = False + self.watch_service = None + if self.sync_task and not self.sync_task.done(): + self.sync_task.cancel() + + def update_status(self): + """Update status display.""" + if self.watch_service: + state = self.watch_service.state + status = "Running" if state.running else "Stopped" + last_scan = state.last_scan.strftime("%H:%M:%S") if state.last_scan else "-" + self.status_item.setText(f"Status: {status} (Last scan: {last_scan})") + else: + self.status_item.setText("Status: Idle") def main(): """Run the menubar app.""" - BasicMemoryApp().run() + app = QApplication(sys.argv) + + # Create event loop + loop = qasync.QEventLoop(app) + asyncio.set_event_loop(loop) + + # Create tray + tray = BasicMemoryTray() + + # Run event loop + with loop: + loop.run_forever() if __name__ == "__main__": main() diff --git a/uv.lock b/uv.lock index 4c407134..777073f7 100644 --- a/uv.lock +++ b/uv.lock @@ -87,6 +87,7 @@ dependencies = [ { name = "pyright" }, { name = "python-frontmatter" }, { name = "pyyaml" }, + { name = "qasync" }, { name = "rich" }, { name = "rumps" }, { name = "sqlalchemy" }, @@ -123,6 +124,7 @@ requires-dist = [ { name = "pyright", specifier = ">=1.1.390" }, { name = "python-frontmatter", specifier = ">=1.1.0" }, { name = "pyyaml", specifier = ">=6.0.1" }, + { name = "qasync", specifier = ">=0.27.1" }, { name = "rich", specifier = ">=13.9.4" }, { name = "rumps", specifier = ">=0.4.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, @@ -978,6 +980,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] +[[package]] +name = "qasync" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/e0/7c7c973f52e1765d6ddfc41e9272294f65d5d52b8f5f5eae92adf411ad46/qasync-0.27.1.tar.gz", hash = "sha256:8dc768fd1ee5de1044c7c305eccf2d39d24d87803ea71189d4024fb475f4985f", size = 14287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/06/bc628aa2981bcfd452a08ee435b812fd3eee4ada8acb8a76c4a09d1a5a77/qasync-0.27.1-py3-none-any.whl", hash = "sha256:5d57335723bc7d9b328dadd8cb2ed7978640e4bf2da184889ce50ee3ad2602c7", size = 14866 }, +] + [[package]] name = "regex" version = "2024.11.6" From 452308fd965ca32a9d07be6dde3bff80f06c7ad8 Mon Sep 17 00:00:00 2001 From: phernandez Date: Sat, 8 Feb 2025 12:35:28 -0600 Subject: [PATCH 6/6] subprocess app tray wip --- src/basic_memory/ui/menubar/app.py | 202 ++++++++++++++++++++--------- 1 file changed, 138 insertions(+), 64 deletions(-) diff --git a/src/basic_memory/ui/menubar/app.py b/src/basic_memory/ui/menubar/app.py index 8135b2b3..6771a895 100644 --- a/src/basic_memory/ui/menubar/app.py +++ b/src/basic_memory/ui/menubar/app.py @@ -1,21 +1,60 @@ -"""Basic Memory menubar app using PyQt.""" +"""Basic Memory menubar app - process manager for sync service.""" +import os import sys -import asyncio -from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QStyle +import subprocess +from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QStyle, QMainWindow, QTextEdit from PyQt6.QtCore import QTimer -from PyQt6.QtGui import QIcon from loguru import logger -import qasync from basic_memory.config import config -from basic_memory.cli.commands.sync import get_sync_service -from basic_memory.sync.watch_service import WatchService + + +class StatusWindow(QMainWindow): + """Window to show sync process output.""" + def __init__(self): + super().__init__() + self.setWindowTitle("Basic Memory Status") + self.resize(800, 400) + + # Create terminal-like text display + self.text_display = QTextEdit() + self.text_display.setReadOnly(True) + self.text_display.setStyleSheet(""" + QTextEdit { + background-color: #1e1e1e; + color: #ffffff; + font-family: Menlo, Monaco, 'Courier New', monospace; + font-size: 12px; + padding: 8px; + } + """) + self.setCentralWidget(self.text_display) + + # Update timer for reading process output + self.update_timer = QTimer() + self.update_timer.timeout.connect(self.update_output) + self.update_timer.start(100) # Check output every 100ms + + def set_process(self, process): + """Set the process to monitor.""" + self.process = process + self.text_display.clear() + + def update_output(self): + """Read and display new output from process.""" + if hasattr(self, 'process') and self.process: + while True: + line = self.process.stdout.readline() + if not line: + break + self.text_display.append(line.strip()) class BasicMemoryTray(QSystemTrayIcon): + """System tray icon for managing Basic Memory sync.""" def __init__(self): super().__init__() - # Set default icon + # Set icon self.setIcon(QApplication.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon)) # Create menu @@ -27,89 +66,124 @@ class BasicMemoryTray(QSystemTrayIcon): self.sync_action = menu.addAction("Start Sync") self.sync_action.triggered.connect(self.toggle_sync) + menu.addAction("Show Status").triggered.connect(self.show_status) + menu.addSeparator() - quit_action = menu.addAction("Quit") - quit_action.triggered.connect(QApplication.instance().quit) + menu.addAction("Quit").triggered.connect(self.cleanup_and_quit) self.setContextMenu(menu) self.show() - # Initialize sync state - self.watch_service = None - self.sync_task = None - self.is_syncing = False + # Initialize state + self.process = None + self.status_window = None # Update timer for status self.status_timer = QTimer() - self.status_timer.timeout.connect(self.update_status) - self.status_timer.start(1000) # Update every second + self.status_timer.timeout.connect(self.check_process) + self.status_timer.start(1000) # Check every second logger.info("Basic Memory tray initialized") + def show_status(self): + """Show status window.""" + if not self.status_window: + self.status_window = StatusWindow() + if self.process: + self.status_window.set_process(self.process) + self.status_window.show() + self.status_window.raise_() + def toggle_sync(self): """Start or stop sync process.""" - if not self.is_syncing: - logger.info("Starting sync") + if not self.process: self.start_sync() - self.sync_action.setText("Stop Sync") - self.is_syncing = True else: - logger.info("Stopping sync") self.stop_sync() - self.sync_action.setText("Start Sync") - self.is_syncing = False def start_sync(self): - """Start the sync service.""" - async def run_sync(): - try: - sync_service = await get_sync_service() - self.watch_service = WatchService( - sync_service=sync_service, - file_service=sync_service.entity_service.file_service, - config=config - ) - await self.watch_service.run(console_status=False) - except Exception as e: - logger.exception("Error in sync service") - self.status_item.setText(f"Status: Error - {str(e)}") - - # Start sync using the event loop - loop = asyncio.get_event_loop() - self.sync_task = loop.create_task(run_sync()) + """Start the sync subprocess.""" + logger.info("Starting sync process") + try: + self.process = subprocess.Popen( + ['uvx', ' --directory', '/Users/phernandez/dev/basicmachines/basic-memory', 'basic-memory', 'sync', '--watch'], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 # Line buffered + ) + self.sync_action.setText("Stop Sync") + if self.status_window: + self.status_window.set_process(self.process) + except Exception as e: + logger.exception("Failed to start sync process") + self.status_item.setText(f"Status: Error - {str(e)}") def stop_sync(self): - """Stop the sync service.""" - if self.watch_service: - self.watch_service.state.running = False - self.watch_service = None - if self.sync_task and not self.sync_task.done(): - self.sync_task.cancel() + """Stop the sync subprocess.""" + logger.info("Stopping sync process") + if self.process: + self.process.terminate() + try: + self.process.wait(timeout=2) # Wait up to 2 seconds + except subprocess.TimeoutExpired: + self.process.kill() # Force kill if it doesn't terminate + self.process = None + self.sync_action.setText("Start Sync") - def update_status(self): - """Update status display.""" - if self.watch_service: - state = self.watch_service.state - status = "Running" if state.running else "Stopped" - last_scan = state.last_scan.strftime("%H:%M:%S") if state.last_scan else "-" - self.status_item.setText(f"Status: {status} (Last scan: {last_scan})") + def check_process(self): + """Check sync process status.""" + if self.process: + if self.process.poll() is not None: # Process has ended + self.process = None + self.sync_action.setText("Start Sync") + self.status_item.setText("Status: Idle") + else: + self.status_item.setText("Status: Running") else: self.status_item.setText("Status: Idle") + def cleanup_and_quit(self): + """Clean up before quitting.""" + logger.info("Cleaning up...") + if self.status_window: + self.status_window.close() + self.stop_sync() + QApplication.instance().quit() + +def ensure_single_instance(): + """Ensure only one instance of the menubar app is running.""" + pid_file = config.home / ".basic-memory" / "menubar.pid" + pid_file.parent.mkdir(parents=True, exist_ok=True) + + if pid_file.exists(): + try: + pid = int(pid_file.read_text()) + os.kill(pid, 0) # Check if process exists + logger.error(f"Basic Memory menubar is already running (PID: {pid})") + sys.exit(1) + except (OSError, ValueError): + # Process not running or invalid PID + pass + + # Write current PID + pid = str(os.getpid()) + pid_file.write_text(pid) + logger.debug(f"PID {pid} written to file {pid_file}") + return pid_file + def main(): """Run the menubar app.""" - app = QApplication(sys.argv) - - # Create event loop - loop = qasync.QEventLoop(app) - asyncio.set_event_loop(loop) + # Ensure single instance + pid_file = ensure_single_instance() - # Create tray - tray = BasicMemoryTray() - - # Run event loop - with loop: - loop.run_forever() + try: + app = QApplication(sys.argv) + tray = BasicMemoryTray() + app.exec() + finally: + # Clean up PID file + pid_file.unlink(missing_ok=True) if __name__ == "__main__": main()