diff --git a/README.md b/README.md index d68b1ed..3370553 100644 --- a/README.md +++ b/README.md @@ -22,31 +22,62 @@ This will automatically install the dependencies (including `pytest`). Happy hac ## Roadmap -### v0.0.1 - MVP +### v0.0.1 - Simple CAS + insert/remove files manually -- [ ] `bsv init [] [--repo ]` command to initialize bsv. -- [ ] `bsv repo [-v]` list all known repositories. -- [ ] `bsv repo create ` create a new repository. -- [ ] `bsv repo add [] ` add an already existing repository. -- [ ] `bsv repo remove |` remove a repository. -- [ ] `bsv map` list mappings between bsv paths and filesystem paths. -- [ ] `bsv map add ` add a mapping. -- [ ] `bsv map remove ` remove a mapping. +Basic features. Naïve CAS implementation that store everything in a single big file with no option for removing objects. Supports a single "local" repository. + +- [x] Simple CAS implementation (it's OK if it's naïve). +- [x] Content-based chunking to store files. +- [x] `bsv init` command to initialize bsv. +- [ ] `bsv info` print useful information bsv configuration. - [ ] `bsv log` show the history of snapshots. - [ ] `bsv show ` show the object `digest`. - [ ] `bsv ls ` list files in a bsv directory. - [ ] `bsv mkdir ` create a directory in bsv directly. -- [ ] `bsv add ` copy files from the filesystem to bsv. -- [ ] `bsv get ` copy files from bsv to the filesystem. -- [ ] `bsv snapshot` capture a snapshot, i.e. to ensure that mapped files in the repositories match what is on the filesystem. +- [ ] `bsv add [-r] ` copy files from the filesystem to bsv. +- [ ] `bsv get [-r] ` copy files from bsv to the filesystem. +- [ ] `bsv rm [-r] ` create a directory in bsv directly. + +### v0.0.2 - File map + snapshots + +Add support for mapping files from BSV virtual file system to the actual file system. Add snapshot and restore commands. + +- [ ] `bsv map` list mappings between bsv paths and filesystem paths. +- [ ] `bsv map add ` add a mapping. +- [ ] `bsv map remove ` remove a mapping. +- [ ] `bsv snapshot` capture a snapshot, i.e. ensure that mapped files in the repositories match what is on the filesystem. - [ ] `bsv restore ` update files on the filesystem to the version captured by `snapshot`. -- [x] Simple CAS implementation (it's OK if it's naïve). -- [x] Content-based chunking to store files. -### Later +### v0.0.3 - Multiple repository + +Support multiple repository. Repository can be configured to store only metadata (typically for the local repository) or everything. +- [ ] Support repositories that store only metadata. +- [ ] `bsv repo [-v]` list all known repositories. +- [ ] `bsv repo create ` create a new repository. +- [ ] `bsv repo add [] ` add an already existing repository. +- [ ] `bsv repo remove |` remove a repository. - [ ] `bsv fetch []` fetch latest metadata from known repositories. - [ ] `bsv sync` similar to `snapshot` + `fetch` + `restore`: Fetch latest changes from the repositories and update the filesystem to match. In case of conflict (file changed both in the repositories and locally), performs a snapshot first to ensure all conflicting versions are backed'up, then use some conflict-resolution strategy and warn the user. + +### v0.0.4 - Proper CAS + +- [ ] Safe concurrent access (e.g. when several devices use a shared repository). +- [ ] Support removing objects. +- [ ] Garbage collection (remove unreferenced objects). +- [ ] Use garbage collection to keep metadata-only repository clean. + +### v0.0.5 - Some extra features + - [ ] `bsv tag [] [-m ]` set/update a tag (an alias to a specific snapshot). +- [ ] Support for symlinks. + +### Later + - [ ] `bsv watch` starts a daemon that watch changes in mapped directories and automatically create snapshots. -- [ ] `bsv serve` starts an http server that expose an API + an interface to manipulate BSV. Allow to list files, explore history, download and upload files... +- [ ] `bsv http` starts an http server that expose an API + an interface to manipulate BSV. Allow to list files, explore history, download and upload files... +- [ ] Bsv protocol + client/server +- [ ] Custom rules for repository to select what must be stored or not. + - [ ] Create sensible rules for backup (keep a lot of recent versions, less for older versions). +- [ ] Add object set support (a kind of object that simply store a collection of objects). Can be used as tag. +- [ ] Add mail object ? diff --git a/pyproject.toml b/pyproject.toml index 2fb48d3..3de5f04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ "fastcdc", + "rich", "tomlkit", ] diff --git a/src/bsv/cli.py b/src/bsv/cli.py new file mode 100644 index 0000000..cf09b4b --- /dev/null +++ b/src/bsv/cli.py @@ -0,0 +1,126 @@ +# bsv - Backup, Synchronization, Versioning +# Copyright (C) 2023 Simon Boyé +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from __future__ import annotations + +from typing import Any, Callable, TypeVar + +from rich.console import Console +from rich.text import Text + + +_console: Console | None = None +def get_console() -> Console: + assert _console is not None + return _console + +_error_console: Console | None = None +def get_error_console() -> Console: + assert _error_console is not None + return _error_console + + +def init_consoles(color: str="auto"): + global _console + global _error_console + + assert _console is None + assert _error_console is None + + kwargs: dict[str, Any] = { + "tab_size": 4, + } + match color: + case "always": + kwargs["force_terminal"] = True + case "auto": + pass + case "never": + kwargs["no_color"] = True + + _console = Console( + **kwargs, + ) + _error_console = Console( + stderr = True, + **kwargs, + ) + + +PromptType = TypeVar("PromptType") + +class NoDefaultType: + def __repr__(self): + return "NoDefault" +NoDefault = NoDefaultType() + +def prompt( + prompt: str, + factory: Callable[[str], PromptType], + *, + console: Console | None = None, + default: PromptType | NoDefaultType = NoDefault, + show_default: bool = True, +) -> PromptType: + if console is None: + console = get_console() + + prompt_text = Text(prompt, style="prompt") + prompt_text.end = "" + if show_default and default is not NoDefault: + prompt_text.append(" ") + prompt_text.append(f"({default})", style="prompt.default") + prompt_text.append(": ") + + while True: + try: + value = console.input(prompt_text) + except KeyboardInterrupt: + console.print("") + raise + + if not value and not isinstance(default, NoDefaultType): + return default + try: + return factory(value) + except ValueError as err: + console.print(err) + +def prompt_confirmation(prompt: str, *, console: Console | None=None, default: bool=True) -> bool: + if console is None: + console = get_console() + + prompt_text = Text(prompt, style="prompt") + prompt_text.end = "" + prompt_text.append(" ") + if default: + prompt_text.append("(Y/n)", style="prompt.default") + else: + prompt_text.append("(y/N)", style="prompt.default") + prompt_text.append(": ") + + while True: + try: + value = console.input(prompt_text).strip().lower() + except KeyboardInterrupt: + console.print("") + raise + + if not value and not isinstance(default, NoDefaultType): + return default + if value not in "yn": + console.print("Please answer 'y' or 'n'.") + else: + return value == "y" diff --git a/src/bsv/command/init.py b/src/bsv/command/init.py index eb43e08..c90abc4 100644 --- a/src/bsv/command/init.py +++ b/src/bsv/command/init.py @@ -16,35 +16,40 @@ from __future__ import annotations from argparse import ArgumentParser -from os import getlogin from pathlib import Path import platform from bsv.command import command +from bsv.repository import check_config_path, check_device_name, check_local_repository_path, create_repository +from bsv.util import default_local_repository_path def init_parser(parser: ArgumentParser): - parser.add_argument( - "--name", "-n", - help = "Name of the repository. Default to system hostname.", - ) parser.add_argument( "--interactive", "-i", + default = False, action = "store_true", help = "Prompt the user for configuration choices.", ) parser.add_argument( - "destination", + "--local-repository", "-l", type = Path, + default = default_local_repository_path(), nargs = "?", + dest = "local_repository_path", help = "Path to a non-existing or empty folder where bsv data will be stored.", ) + parser.add_argument( + "--device-name", "-n", + default = platform.node(), + help = "Name of the device. Default to system hostname.", + ) @command(init_parser) def init( - repository_path: Path | None, - destination: Path | None = None, - name: str | None = None, + config_path: Path, + device_name: str, + local_repository_path: Path, interactive: bool = False, ) -> int: """Initialize a new bsv repository. @@ -52,60 +57,62 @@ def init( from datetime import datetime as DateTime import tomlkit - if name is None: - name = platform.node() + from bsv.cli import get_console, get_error_console, prompt, prompt_confirmation + + print = get_console().print + + def make_config_path(value: str) -> Path: + path = Path(value.strip()) + if not path.is_absolute(): + path = path.resolve() + check_config_path(path) + return path - if destination is None: - # TODO: Choose a sensible system-dependent path. - destination = Path.cwd() + def make_device_name(value: str) -> str: + device_name = value.strip() + check_device_name(device_name) + return device_name + + def make_local_repository_path(value: str) -> Path: + path = Path(value) + if not path.is_absolute(): + path = path.resolve() + check_local_repository_path(path) + return path if interactive: - name = input(f"Repository name: (default to {name})\n").strip() or name - destination = Path(input(f"Destination: (default to {destination})\n").strip()) or destination - if not destination.is_absolute(): - destination = Path.cwd() / destination - - if not name: - raise RuntimeError("repository name cannot be empty") - if not destination.parent.exists(): - raise RuntimeError(f"destination directory {destination.parent} does not exists") - if destination.exists() and not destination.is_dir(): - raise RuntimeError(f"destination {destination} exists but is not a directory") - if destination.exists() and len(list(destination.iterdir())): - raise RuntimeError(f"destination directory {destination} is not empty") + config_path = prompt("Bsv configuration file", make_config_path, default=config_path) + device_name = prompt("Device name", make_device_name, default=device_name) + local_repository_path = prompt("Destination", make_local_repository_path, default=local_repository_path) + + if not config_path.is_absolute(): + config_path = config_path.resolve() + if not local_repository_path.is_absolute(): + local_repository_path = local_repository_path.resolve() try: - destination.mkdir(exist_ok=True) - except: - raise RuntimeError(f"failed to create destination directory {destination}") - - bsv_table = tomlkit.table() - bsv_table.add(tomlkit.comment("Name of the repository.")) - bsv_table.add(tomlkit.comment("Ideally, this should be unique among all connected repositories.")) - bsv_table.add("name", name) - bsv_table.add(tomlkit.nl()) - bsv_table.add(tomlkit.comment("Mapping between bsv tree and the actual filesystem.")) - bsv_table.add("path_map", tomlkit.array()) - - cas_table = tomlkit.table() - cas_table.add("type", "simple") - cas_table.add("hash", "sha256") - - doc = tomlkit.document() - doc.add(tomlkit.comment("bsv repository configuration")) - doc.add(tomlkit.comment(f"Created by {getlogin()} on {DateTime.now().isoformat()}.")) - doc.add(tomlkit.nl()) - doc.add("bsv", bsv_table) - doc.add(tomlkit.nl()) - doc.add("cas", cas_table) - - config_path = destination / "bsv_config.toml" - try: - stream = config_path.open("w", encoding="utf-8") - except: - raise RuntimeError("failed to open configuration file {config_path}") + check_config_path(config_path) + check_device_name(device_name) + check_local_repository_path(local_repository_path) + except ValueError as err: + get_error_console().print(err, style="bold red") + return 1 + + print("Bsv repository will be created with the following settings:", style="green") + print("") + print(f"\t[blue]:page_facing_up: Config path[/blue]: [bold yellow]{config_path}") + print(f"\t[blue]:computer: Device name[/blue]: [bold yellow]{device_name}") + print(f"\t[blue]:floppy_disk: Local repository[/blue]: [bold yellow]{local_repository_path}") + print("") - with stream: - tomlkit.dump(doc, stream) + if interactive: + if not prompt_confirmation("Create repository ?"): + return 1 + + create_repository( + config_path = config_path, + device_name = device_name, + local_repository_path = local_repository_path, + ) return 0 diff --git a/src/bsv/main.py b/src/bsv/main.py index 4a7db3e..eafb3ff 100644 --- a/src/bsv/main.py +++ b/src/bsv/main.py @@ -22,7 +22,9 @@ import sys from textwrap import dedent from bsv import __version__ +from bsv.cli import get_error_console, init_consoles from bsv.command import init_commands +from bsv.util import default_bsv_config_path def make_parser( @@ -31,10 +33,20 @@ def make_parser( ) -> ArgumentParser: parent_parser = ArgumentParser(add_help=False) parent_parser.add_argument( - "--repository", + "--color", + default = "auto", + choices = ("always", "auto", "never"), + help = dedent(""" + Force or disable colors, or auto-detect terminal support. + """).strip(), + ) + parent_parser.add_argument( + "--config", + default = default_bsv_config_path(), type = Path, + dest = "config_path", help = dedent(""" - Bsv repository path. Overides default paths and BSV_REPOSITORY environment variable. + Bsv config path. Overrides default paths and BSV_CONFIG environment variable. """).strip(), ) @@ -68,16 +80,16 @@ def main( ) arg_dict = vars(parser.parse_args(args or sys.argv[1:])) - repository_path: Path | None = arg_dict.pop("repository") - if repository_path is None and "BSV_REPOSITORY" in os.environ: - repository_path = Path(os.environ["BSV_REPOSITORY"]) - # else: - # for path in get_config_dirs(): - # maybe_config_path = path / "config.toml" - # if maybe_config_path.is_file(): - # config_path = maybe_config_path - # break + color = arg_dict.pop("color") + init_consoles(color=color) command = arg_dict.pop("command") - return command(repository_path=repository_path, **arg_dict) + try: + return command(**arg_dict) + except Exception as err: + get_error_console().print_exception() + except KeyboardInterrupt: + return 130 + + return 0 diff --git a/src/bsv/repository.py b/src/bsv/repository.py index 0a868d4..59a6146 100644 --- a/src/bsv/repository.py +++ b/src/bsv/repository.py @@ -20,12 +20,10 @@ from datetime import datetime as DateTime import hashlib from io import BytesIO from pathlib import Path, PurePosixPath -import platform import tomllib -from typing import TYPE_CHECKING, BinaryIO, Callable, Self +from typing import TYPE_CHECKING, Any, BinaryIO, Self from fastcdc import fastcdc -import tomlkit from bsv import __version__ from bsv.exception import ConfigError @@ -33,7 +31,7 @@ from bsv.object import ObjectInfo from bsv.path_map import PathMap from bsv.simple_cas import SimpleCas from bsv.simple_cas.cas import Digest, SimpleCas -from bsv.util import Hash, read_exact, read_exact_or_eof, time_from_timestamp_us, timestamp_us_from_time +from bsv.util import default_bsv_config_path, default_local_repository_path, read_exact, read_exact_or_eof, time_from_timestamp_us, timestamp_us_from_time if TYPE_CHECKING: from bsv.tree_walker import TreeWalker @@ -45,8 +43,9 @@ DEFAULT_MAX_CHUNK_SIZE = 1 << 20 class Repository: - _path: Path - _name: str + _config_path: Path + _device_name: str + _local_repository_path: Path _cas: SimpleCas _min_chunk_size: int = DEFAULT_MIN_CHUNK_SIZE @@ -58,38 +57,36 @@ class Repository: _context_depth: int = 0 - def __init__(self, path: Path): - self._path = path + def __init__(self, config_path: Path): + self._config_path = config_path - with self.config_file.open("rb") as stream: + with self._config_path.open("rb") as stream: config = tomllib.load(stream) bsv = config.get("bsv", {}) + def get(key: str) -> Any: + value = bsv.get(key) + if value is None: + raise ConfigError(f"invalid bsv configuration: missing bsv.{key} item") + return value - self._name = bsv.get("name") or platform.node() + self._device_name = get("device_name") + self._local_repository_path = Path(get("local_repository")) + self._min_chunk_size = get("min_chunk_size") + self._avg_chunk_size = get("avg_chunk_size") + self._max_chunk_size = get("max_chunk_size") + self._path_map = PathMap.from_obj(get("path_map")) - self._cas = make_cas( - bsv.get("cas"), - self._path, - lambda: hashlib.new(bsv.get("hash")), # type: ignore - ) - self._min_chunk_size = bsv.get("min_chunk_size") - self._avg_chunk_size = bsv.get("avg_chunk_size") - self._max_chunk_size = bsv.get("max_chunk_size") - - self._path_map = PathMap.from_obj(bsv.get("path_map", [])) + self._cas = make_cas(self._local_repository_path) - @property - def path(self) -> Path: - return self._path @property - def config_file(self) -> Path: - return self.path / "bsv_config.toml" + def config_path(self) -> Path: + return self._config_path @property - def name(self) -> str: - return self._name + def device_name(self) -> str: + return self._device_name @property def path_map(self) -> PathMap: @@ -257,9 +254,30 @@ class Repository: return self._cas.__exit__(exc_type, exc_value, traceback) +def check_config_path(path: Path): + if path.exists(): + raise ValueError(f"{path} already exists.") + if path != default_bsv_config_path() and not path.parent.is_dir(): + raise ValueError(f"{path.parent} does not exist or is not a directory.") + +def check_device_name(device_name: str): + if not device_name: + raise ValueError("Device name cannot be empty.") + if not device_name.isidentifier(): + raise ValueError(f"{device_name} is not a valid device name.") + +def check_local_repository_path(path: Path): + if path != default_local_repository_path() and not path.parent.exists(): + raise ValueError(f"Directory {path.parent} does not exists.") + if path.exists() and not path.is_dir(): + raise ValueError(f"{path} exists but is not a directory.") + if path.exists() and len(list(path.iterdir())): + raise ValueError(f"Local repository directory {path} is not empty.") + def create_repository( - destination: Path, - name: str, + config_path: Path, + device_name: str, + local_repository_path: Path, cas: str = "simple", hash: str = "sha256", min_chunk_size: int = DEFAULT_MIN_CHUNK_SIZE, @@ -269,55 +287,96 @@ def create_repository( from datetime import datetime as DateTime from os import getlogin - if not name: - raise RuntimeError("repository name cannot be empty") - if not destination.parent.exists(): - raise RuntimeError(f"destination directory {destination.parent} does not exists") - if destination.exists() and not destination.is_dir(): - raise RuntimeError(f"destination {destination} exists but is not a directory") - if destination.exists() and len(list(destination.iterdir())): - raise RuntimeError(f"destination directory {destination} is not empty") + import tomlkit + + check_config_path(config_path) + check_device_name(device_name) + check_local_repository_path(local_repository_path) + + if config_path == default_bsv_config_path(): + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + except: + raise RuntimeError(f"failed to create bsv config destination directory {config_path.parent}") try: - destination.mkdir(exist_ok=True) + local_repository_path.mkdir(exist_ok=True) except: - raise RuntimeError(f"failed to create destination directory {destination}") + raise RuntimeError(f"failed to create local repository directory {local_repository_path}") bsv_table = tomlkit.table() - bsv_table.add(tomlkit.comment("Name of the repository.")) - bsv_table.add(tomlkit.comment("Ideally, this should be unique among all connected repositories.")) - bsv_table.add("name", name) + + bsv_table.add(tomlkit.comment("Name of the instance.")) + bsv_table.add(tomlkit.comment("Ideally, this should be unique among all connected devices.")) + bsv_table.add("device_name", device_name) + bsv_table.add(tomlkit.nl()) - bsv_table.add(tomlkit.comment("Mapping between bsv tree and the actual filesystem.")) - bsv_table.add("path_map", tomlkit.array()) - bsv_table.add("cas", cas) - bsv_table.add("hash", hash) + bsv_table.add(tomlkit.comment("Path to the local repository.")) + bsv_table.add("local_repository", str(local_repository_path)) + + bsv_table.add(tomlkit.nl()) + bsv_table.add(tomlkit.comment("Properties of the content-based chunking algorithm.")) bsv_table.add("min_chunk_size", min_chunk_size) bsv_table.add("avg_chunk_size", avg_chunk_size) bsv_table.add("max_chunk_size", max_chunk_size) - doc = tomlkit.document() - doc.add(tomlkit.comment("bsv repository configuration")) - doc.add(tomlkit.comment(f"Created by {getlogin()} on {DateTime.now().isoformat()}.")) - doc.add(tomlkit.nl()) - doc.add("bsv", bsv_table) + bsv_table.add(tomlkit.nl()) + bsv_table.add(tomlkit.comment("Mapping between bsv tree and the local filesystem.")) + bsv_table.add("path_map", tomlkit.array()) + + bsv_config = tomlkit.document() + bsv_config.add(tomlkit.comment("bsv device configuration")) + bsv_config.add(tomlkit.comment(f"Created by {getlogin()} on {DateTime.now().isoformat()}.")) + bsv_config.add(tomlkit.nl()) + bsv_config.add("bsv", bsv_table) + + + cas_table = tomlkit.table() + cas_table.add("type", cas) + cas_table.add("hash", hash) + + cas_config = tomlkit.document() + cas_config.add(tomlkit.comment(f"bsv local repository configuration for instance {config_path}.")) + cas_config.add(tomlkit.comment(f"Created by {getlogin()} on {DateTime.now().isoformat()}.")) + cas_config.add(tomlkit.nl()) + cas_config.add("cas", cas_table) - config_path = destination / "bsv_config.toml" try: - stream = config_path.open("w", encoding="utf-8") + bsv_stream = config_path.open("w", encoding="utf-8") except: - raise RuntimeError("failed to open configuration file {config_path}") + raise RuntimeError(f"failed to open bsv configuration file {config_path}") + try: + cas_stream = (local_repository_path / "bsv_repository.config").open("w", encoding="utf-8") + except: + raise RuntimeError(f"failed to open local repository configuration file {config_path}") + + with bsv_stream: + tomlkit.dump(bsv_config, bsv_stream) + + with cas_stream: + tomlkit.dump(cas_config, cas_stream) + + repo = Repository(config_path) + + return repo - with stream: - tomlkit.dump(doc, stream) - return Repository(destination) +def make_cas(cas_config_path: Path) -> SimpleCas: + with (cas_config_path / "bsv_repository.config").open("rb") as stream: + config = tomllib.load(stream) + cas = config.get("cas", {}) + def get(key: str) -> Any: + value = cas.get(key) + if value is None: + raise ConfigError(f"invalid repository configuration: missing {key} item") + return value -def make_cas(cas_name: str, path: Path, hash_factory: Callable[[], Hash]) -> SimpleCas: - if cas_name == "simple": - return SimpleCas(path, hash_factory) - raise ConfigError(f"unknown cas name {cas_name}") + type = get("type") + hash_factory = lambda: hashlib.new(get("hash")) + if type == "simple": + return SimpleCas(cas_config_path, hash_factory) # type: ignore + raise ConfigError(f"unknown cas type {type}") diff --git a/src/bsv/util.py b/src/bsv/util.py index 60372d8..597ef47 100644 --- a/src/bsv/util.py +++ b/src/bsv/util.py @@ -17,7 +17,9 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import UTC, datetime as DateTime, timedelta as TimeDelta +import os from pathlib import Path +import platform import stat from typing import BinaryIO @@ -48,7 +50,7 @@ def read_exact_or_eof(stream: BinaryIO, num_bytes: int) -> bytes | None: def is_bsv_repository(path: Path) -> bool: - return (path / "bsv_config.toml").is_file() + return (path / "bsv_repository.config").is_file() def object_type_from_path(path: Path) -> bytes: @@ -64,6 +66,45 @@ def object_type_from_mode(mode: int) -> bytes: return b"" +def default_bsv_config_path() -> Path: + path = Path(os.environ.get("BSV_CONFIG", "")) + if path and path.is_absolute() and path.is_file(): + return path + for path in user_config_dirs(): + if path.is_file(): + return path + return user_config_home() / "bsv/config" + +def default_local_repository_path() -> Path: + return user_data_home() / "bsv" + + +def user_data_home() -> Path: + if platform.system() in ("Windows", "Darwin", "Java"): + raise NotImplemented(f"{platform.system()} support not implemented yet") + else: # Assume Unix + path = Path(os.environ.get("XDG_DATA_HOME", "")) + if path and path.is_absolute(): + return path + return Path.home() / ".local/share" + +def user_config_home() -> Path: + if platform.system() in ("Windows", "Darwin", "Java"): + raise NotImplemented(f"{platform.system()} support not implemented yet") + else: # Assume Unix + path = Path(os.environ.get("XDG_CONFIG_HOME", "")) + if path and path.is_absolute(): + return path + return Path.home() / ".config" + +def user_config_dirs() -> list[Path]: + if platform.system() in ("Windows", "Darwin", "Java"): + raise NotImplemented(f"{platform.system()} support not implemented yet") + else: # Assume Unix + paths = list(filter(Path.is_absolute, map(Path, (os.environ.get("XDG_CONFIG_DIRS") or "/etc/xdg").split(":")))) + return [user_config_home()] + paths + + class Hash(ABC): name: str digest_size: int diff --git a/tests/test_repository.py b/tests/test_repository.py index 9928050..07d3225 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -38,8 +38,9 @@ def tmp_dir(): @pytest.fixture def repo(tmp_dir): return create_repository( - tmp_dir / "bsv", + tmp_dir / "bsv.config", "test_repo", + tmp_dir / "bsv_repo", ) @@ -154,7 +155,7 @@ def test_add_tree(tmp_dir: Path, repo: Repository): }, "Another test with long name and spaces and a bang !": b"Should works.\n", "bsv_repo": { - "bsv_config.toml": b"[bsv]\n", + "bsv_repository.config": b"[bsv]\n", }, } structure1 = { @@ -168,7 +169,7 @@ def test_add_tree(tmp_dir: Path, repo: Repository): "new_file": b"whatever", "Another test with long name and spaces and a bang !": b"Should works.\n", "bsv_repo": { - "bsv_config.toml": b"[bsv]\n", + "bsv_repository.config": b"[bsv]\n", }, }