Many changes to keys and move persist() to fs.py

This fixes a lot of awkwardness that came from having the record()
functionality inside of store.py. It is still broken, but is much closer
to actually working now. I also sketched some data and io functionality,
which has no tests and is not yet working at all.
This commit is contained in:
Jacob Hinkle 2022-10-11 12:27:40 -04:00
parent 07ccef601c
commit 8e69ca3390
15 changed files with 523 additions and 486 deletions

View File

@ -37,7 +37,7 @@ deps =
pytest-cov pytest-cov
coverage coverage
commands = commands =
pytest --cov src/nancy pytest --cov {envsitepackagesdir}/nancy
[testenv:mypy] [testenv:mypy]
deps = deps =

View File

@ -3,7 +3,6 @@ from loguru import logger
from ..version import __version__ from ..version import __version__
# from .freeze import freeze, thaw
from . import diff from . import diff
from . import record from . import record
@ -50,8 +49,6 @@ def main(log_level: str) -> None:
logger.add(sys.stderr, level=log_level) logger.add(sys.stderr, level=log_level)
# main.add_command(freeze)
# main.add_command(thaw)
main.add_command(diff.status, name="status") main.add_command(diff.status, name="status")
main.add_command(record.record_cli, name="record") main.add_command(record.record_cli, name="record")
main.add_command(version) main.add_command(version)

View File

@ -38,6 +38,11 @@ def print_diff(
hashcolor = Fore.MAGENTA if use_color else "" hashcolor = Fore.MAGENTA if use_color else ""
def _print_row(tag: str, entry: fs.FSEntry, level: int) -> None: def _print_row(tag: str, entry: fs.FSEntry, level: int) -> None:
if len(entry.versions) == 0:
print(Fore.RED + "NOVERSIONS" + Style.RESET_ALL + entry.sha256)
else:
ver = entry.versions[-1]
relpath = entry.relpath relpath = entry.relpath
# Format relpath using filetype-based colors # Format relpath using filetype-based colors
@ -48,25 +53,25 @@ def print_diff(
dirstr = ( dirstr = (
(filetypecolors["DIR"] + dname + "/" + reset) if dname != "" else "" (filetypecolors["DIR"] + dname + "/" + reset) if dname != "" else ""
) )
assert entry.filetype is not None assert ver.filetype is not None
fname = filetypecolors.get(str(entry.filetype), "") + fname + reset fname = filetypecolors.get(str(ver.filetype), "") + fname + reset
if entry.filetype == fs.FileType.LNK: # append symlink target if ver.filetype == fs.FileType.LNK: # append symlink target
assert entry.symlink_target is not None assert ver.symlink_target is not None
fname += " -> " + entry.symlink_target fname += " -> " + ver.symlink_target
relpath = dirstr + fname relpath = dirstr + fname
assert entry.sha256 is not None assert entry.sha256 is not None
hashchange = ( hashchange = (
(hashcolor + entry.sha256.hex() + reset + " " + changetags[tag]) (hashcolor + ver.sha256.hex() + reset + " " + changetags[tag])
if show_hashes if show_hashes
else changetags[tag] else changetags[tag]
) )
print( print(
hashchange, hashchange,
entry.unfrozen_perms, ver.perms,
relpath, relpath,
) )

View File

@ -1,45 +0,0 @@
import click
from .. import store
import os
from typing import List
@click.command()
@click.argument("directory")
def freeze(directory: str) -> None:
"""
Initialize tracking in a directory or freeze a tracked directory.
If DIRECTORY is not already part of an existing nancy store, then a new
'nancy.db' file is created in that directory. On the other hand, if the
directory is part of an existing store, it will be re-frozen and versions
of any files changes since thawing will be incremented.
"""
if not os.path.isdir(directory):
raise ValueError(f"Cannot freeze non-existent directory {directory}")
existing_store = store.find_store(directory)
if existing_store is None: # this is a new store
s = store.Store.init(directory)
else: # this is an existing store
s = store.Store(directory)
# s.freeze()
@click.command()
@click.argument("files", nargs=-1) # , help="Files or directories to thaw.")
def thaw(files: List[str]) -> None:
"""
Enable manual alteration of files within a tracked directory.
This command is meant to be used in conjunction with the 'freeze'
subcommand. After thawing, changes may be made in the current directory,
after which `nancy freeze` should be run changes may be made in the current
directory, after which `nancy freeze` should be run. At that point, changes
will be recorded: new files will be detected and modified files will have
their version numbers incremented.
"""
pass

19
src/nancy/data.py Normal file
View File

@ -0,0 +1,19 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Generic, Optional, TypeVar
import uuid
T = TypeVar("T")
@dataclass(slots=True)
class Box(Generic[T]):
value: Optional[T] = None
uuid: str = ""
version: int = 0 # incremented whenever passed as a non-const argument
def __post_init__(self) -> None:
self.uuid = str(uuid.uuid4())
StrBox = Box[str]

View File

@ -85,13 +85,13 @@ class Environment:
fdor = "" fdor = ""
try: try:
fdor = json.dumps(platform.freedesktop_os_release()) fdor = json.dumps(platform.freedesktop_os_release(), sort_keys=True)
except AttributeError: except AttributeError:
# freedesktop_os_release only available for python >= 3.10 # freedesktop_os_release only available for python >= 3.10
fdor = "" fdor = ""
return cls( return cls(
json.dumps(dict(os.environ)), json.dumps(dict(os.environ), sort_keys=True),
platform.python_implementation(), platform.python_implementation(),
sys.version, sys.version,
sys.hexversion, sys.hexversion,
@ -99,6 +99,6 @@ class Environment:
timezone=time.tzname[time.daylight], timezone=time.tzname[time.daylight],
release=platform.release(), release=platform.release(),
freedesktop_os_release=fdor, freedesktop_os_release=fdor,
win32_ver=json.dumps(platform.win32_ver()), win32_ver=json.dumps(platform.win32_ver(), sort_keys=True),
mac_ver=json.dumps(platform.mac_ver()), mac_ver=json.dumps(platform.mac_ver(), sort_keys=True),
) )

View File

@ -2,7 +2,9 @@
from loguru import logger from loguru import logger
from dataclasses import dataclass from . import program
from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
import hashlib import hashlib
@ -11,68 +13,26 @@ import os
from pathlib import Path from pathlib import Path
import sqlite3 import sqlite3
import stat import stat
from typing import Any, AnyStr, List, Optional, Tuple, TypeVar, Type, Union from typing import (
Any,
AnyStr,
Callable,
List,
Optional,
Tuple,
TypeVar,
Type,
Union,
TYPE_CHECKING,
)
import uuid
import warnings import warnings
if TYPE_CHECKING:
PathStr = Union[str, Path, "os.PathLike[str]"] from .store import Store
def remove_write_perms(path: PathStr) -> Optional[str]: PathStr = Union[str, os.PathLike[str]]
"""Remove write permissions for all users while preserving other perms"""
if not os.path.islink(path):
s = os.stat(path)
orig_perm_string = stat.filemode(s.st_mode)
os.chmod(
path,
s.st_mode & -(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH),
)
follow_symlinks = False
else:
if os.stat not in os.supports_follow_symlinks:
# can't stat this thing directly on this platform
# means we can only stat the content.
# In this case, we return None and do not lock this link
warnings.warn(
"This platform cannot stat symlinks. Will not set them read-only."
)
return None
s = os.stat(path)
orig_perm_string = stat.filemode(s.st_mode)
if os.chmod in os.supports_follow_symlinks:
follow_symlinks = True
else:
warnings.warn(
": Platform does not support chmod of symlinks. "
"Links will not be set read-only.",
)
return orig_perm_string
os.chmod(
path,
s.st_mode & -(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH),
follow_symlinks=follow_symlinks,
)
return orig_perm_string
def make_readonly_recursive(
path: PathStr,
excluded: List[PathStr] = [],
) -> None:
"""Recursively "freeze" a directory by setting all files and directories read-only"""
# traversing bottom-up makes it easier to freeze perms on directories
for root, dirs, files in os.walk(str(path), topdown=False):
for f in files:
p = os.path.join(root, f)
if p in excluded:
continue
remove_write_perms(os.path.join(Path(path), p))
for d in dirs:
p = os.path.join(root, d)
if p in excluded:
continue
remove_write_perms(os.path.join(path, p))
class FileType(Enum): class FileType(Enum):
@ -104,34 +64,59 @@ _FSEntryVersionT = TypeVar("_FSEntryVersionT", bound="FSEntryVersion")
class FSEntryVersion: class FSEntryVersion:
"""A version of a file or directory.""" """A version of a file or directory."""
id: Optional[int]
filedir: "FSEntry" filedir: "FSEntry"
recorded_time: datetime # When was this version recorded? recorded_time: datetime # When was this version recorded?
filetype: FileType filetype: FileType
deleted: bool # set True when recording a deleted file deleted: bool # set True when recording a deleted file
unfrozen_perms: str # stat.filemode(os.stat(path).st_mode): '-rw-rw-r--' perms: str # stat.filemode(os.stat(path).st_mode): '-rw-rw-r--'
symlink_target: str # if this is a symlink, this is the (read but not fully symlink_target: str # if this is a symlink, this is the (read but not fully
# resolved) target. I.e. this is the "content" of the symlink. # resolved) target. I.e. this is the "content" of the symlink.
sha256: bytes sha256: bytes
source_task_id: Optional[int] = None source_task_id: Optional[int] = None
uuid: str = ""
def __post_init__(self) -> None:
if self.uuid == "":
self.uuid = str(uuid.uuid4())
def persist(
self,
cur: sqlite3.Cursor,
source_task: program.Task,
) -> None:
cur.execute(
"INSERT INTO filedir_version VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
self.uuid,
self.filedir.sha256,
datetime.now().timestamp(),
self.filetype,
False,
self.perms,
self.symlink_target,
self.sha256.hex(),
source_task.uuid,
),
)
assert isinstance(cur.lastrowid, int)
@classmethod @classmethod
def from_row( def from_row(
cls: Type[_FSEntryVersionT], cls: Type[_FSEntryVersionT],
row: Tuple[int, int, float, str, bool, str, str, str, Optional[int]], row: Tuple[str, str, float, str, bool, str, str, str, Optional[int]],
filedir: "FSEntry", filedir: "FSEntry",
) -> _FSEntryVersionT: ) -> _FSEntryVersionT:
return cls( return cls(
row[0], # id
filedir, # filedir filedir, # filedir
datetime.fromtimestamp(row[2]), # recorded_time datetime.fromtimestamp(row[2]), # recorded_time
FileType(row[3]), # filetype FileType(row[3]), # filetype
row[4], # deleted row[4], # deleted
row[5], # unfrozen_perms row[5], # perms
row[6], # symlink_target row[6], # symlink_target
bytes.fromhex(row[7]), # sha256 bytes.fromhex(row[7]), # sha256
row[8], # source_task_id row[8], # source_task_id
uuid=row[0], # uuid
) )
@ -143,40 +128,31 @@ _FSEntryT = TypeVar("_FSEntryT", bound="FSEntry")
class FSEntry: class FSEntry:
"""A hashed file or directory.""" """A hashed file or directory."""
id: Optional[int] # defaults to None
filename: str # with parent directory stripped. None if this is the root filename: str # with parent directory stripped. None if this is the root
relpath: str # relative to some root directory relpath: str # relative to store root directory or / (if store is None)
parent: Optional["FSEntry"] # upward link parent: Optional["FSEntry"] # upward link
# children for dirs only: non-recursive; files/dirs at this level only # children for dirs only: non-recursive; files/dirs at this level only
children: List["FSEntry"] children: List["FSEntry"]
filetype: Optional[ versions: List[FSEntryVersion] = field(default_factory=list)
FileType sha256: bytes = b""
] # regular, symlink, special (block, char, pipe, or socket) store: Optional["Store"] = None
deleted: Optional[bool]
versions: Optional[List[FSEntryVersion]] = None
# these will be filled from the version list automatically
unfrozen_perms: Optional[
str
] = None # stat.filemode(os.stat(path).st_mode): '-rw-rw-r--'
symlink_target: Optional[
str
] = None # if this is a symlink, this is the (read but not fully
# resolved) target. I.e. this is the "content" of the symlink.
sha256: Optional[bytes] = None
latest_version: Optional[FSEntryVersion] = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.versions is not None and len(self.versions) > 0: # derive hash from store, parent, and filename only (not children)
self.latest_version = self.versions[-1] m = hashlib.sha256()
self.unfrozen_perms = self.latest_version.unfrozen_perms upstr: Callable[[str], None] = lambda s: m.update(bytes(s, "utf-8"))
self.symlink_target = self.latest_version.symlink_target upstr("FSEntry:")
self.sha256 = self.latest_version.sha256 if self.store is not None:
upstr(self.store.uuid)
if self.parent is not None:
m.update(self.parent.sha256)
upstr(self.filename)
self.sha256 = m.digest()
@classmethod @classmethod
def from_path( def from_path(
cls: Type[_FSEntryT], cls: Type[_FSEntryT],
root: PathStr, store: Optional["Store"] = None,
relpath: Optional[str] = None, relpath: Optional[str] = None,
exclude: List[str] = ["nancy.db"], exclude: List[str] = ["nancy.db"],
parent: Optional[_FSEntryT] = None, parent: Optional[_FSEntryT] = None,
@ -184,15 +160,11 @@ class FSEntry:
) -> _FSEntryT: ) -> _FSEntryT:
""" """
Scan a path to instantiate (recursive). Scan a path to instantiate (recursive).
Arguments:
root (str or PathLike): The root directory of an existing or new store path
relpath (str or PathLike): Path of some directory under the store
path in which to find files and directories. Only these entries
and their children will be included.
""" """
m = hashlib.sha256() m = hashlib.sha256()
root = "/" if store is None else store.path
if relpath is None: # top-level invocation at root if relpath is None: # top-level invocation at root
path = root path = root
else: else:
@ -236,7 +208,7 @@ class FSEntry:
children = [ children = [
cls.from_path( cls.from_path(
root=root, store=store,
relpath=rp, relpath=rp,
direntry=e, direntry=e,
) )
@ -248,10 +220,14 @@ class FSEntry:
# changes without modifying the hashes of individual files, # changes without modifying the hashes of individual files,
# which remain content-based for compatibility with # which remain content-based for compatibility with
# other tools # other tools
if c.unfrozen_perms is not None: assert (
m.update(bytes(c.unfrozen_perms, "utf-8")) len(c.versions) > 0
if c.sha256 is not None: ) # must have a version since we derived from files
m.update(c.sha256) ver = c.versions[-1]
if ver.perms is not None:
m.update(bytes(ver.perms, "utf-8"))
if ver.sha256 is not None:
m.update(ver.sha256)
elif stat.S_ISREG(s): elif stat.S_ISREG(s):
filetype = FileType.REG filetype = FileType.REG
m.update(open(path, "rb").read()) m.update(open(path, "rb").read())
@ -275,118 +251,117 @@ class FSEntry:
sha256 = m.digest() sha256 = m.digest()
ob = cls( ob = cls(
id=None, store=store,
filename="." if relpath is None else os.path.basename(relpath), filename="." if relpath is None else os.path.basename(relpath),
relpath="." if relpath is None else relpath, relpath="." if relpath is None else relpath,
parent=parent, parent=parent,
children=children, children=children,
filetype=None,
deleted=None,
versions=[], versions=[],
) )
# Update versions after the fact to get self-reference # Update versions after the fact to get self-reference
ob.versions = [ ob.versions = [
FSEntryVersion( FSEntryVersion(
id=None,
filedir=ob, filedir=ob,
recorded_time=datetime.now(), recorded_time=datetime.now(),
filetype=filetype, filetype=filetype,
deleted=False, deleted=False,
unfrozen_perms=stat.filemode(s), perms=stat.filemode(s),
symlink_target=str(symlink_target), symlink_target=str(symlink_target),
sha256=sha256, sha256=sha256,
source_task_id=None, source_task_id=None,
) )
] ]
# now change children's parents to point to this object # point versions and children back to ob
for v in ob.versions: for v in ob.versions:
v.filedir = ob v.filedir = ob
if len(ob.versions) > 0:
last_ver = ob.versions[-1]
ob.filetype = last_ver.filetype
ob.deleted = last_ver.deleted
ob.unfrozen_perms = last_ver.unfrozen_perms
ob.symlink_target = last_ver.symlink_target
ob.sha256 = last_ver.sha256
for c in ob.children: for c in ob.children:
c.parent = ob c.parent = ob
return ob return ob
@classmethod def persist(
def empty_root(cls: Type[_FSEntryT]) -> _FSEntryT: self,
"""Just a standardized value indicating an empty root directory""" cur: sqlite3.Cursor,
return cls( source_task: program.Task,
id=None, parent_key: Optional[str] = None,
filename=".", ) -> None:
relpath=".", # Find entries with this name and parent
parent=None, cur.execute(
children=[], "SELECT sha256 FROM filedir WHERE store = 1 AND name = ? AND parent = ? LIMIT 1",
filetype=FileType.DIR, (self.filename, None if self.parent is None else self.parent.sha256),
unfrozen_perms="----------",
sha256=hashlib.sha256().digest(),
deleted=False,
) )
res = cur.fetchall()
if len(res) == 0:
# create filedir entry and get its id
cur.execute(
"INSERT INTO filedir VALUES (?, ?, ?, ?)",
(
self.sha256,
None if self.store is None else self.store.uuid,
self.filename,
parent_key,
),
)
self.versions[-1].persist(cur=cur, source_task=source_task)
# descend into children and record all of them anew as well
for c in self.children:
c.persist(cur=cur, source_task=source_task, parent_key=self.sha256.hex())
def persist_delete(self, cur: sqlite3.Cursor, source_task: program.Task) -> None:
# add a new version to self and every child with deleted=True
raise NotImplementedError
# @logger.catch
@classmethod @classmethod
def from_db_index( def from_db_key(
cls: Type[_FSEntryT], cls: Type[_FSEntryT],
cursor: sqlite3.Cursor, cursor: sqlite3.Cursor,
root_id: Optional[int] = None, store: "Store",
root_key: Optional[str] = None,
root_row: Optional[ root_row: Optional[
Tuple[int, str, bool] Tuple[str, str, str] # sha256, name, store
] = None, # TODO: Type the expected sqlite rows ] = None, # TODO: Type the expected sqlite rows
parent: Optional[_FSEntryT] = None, parent: Optional[_FSEntryT] = None,
) -> _FSEntryT: ) -> _FSEntryT:
"""Given id of an entry in filedir, recursively fill this object""" """Given key of an entry in filedir, recursively fill this object"""
if root_row is None: if root_row is None:
assert root_id is not None assert root_key is not None
cursor.execute( cursor.execute(
"SELECT id, name, frozen FROM filedir WHERE id=?", "SELECT sha256, name, store FROM filedir WHERE sha256=?",
(root_id,), (root_key,),
) )
root_row = cursor.fetchone() root_row = cursor.fetchone()
root_id, filename, frozen = root_row root_key, filename, store_key = root_row
assert store_key == store.uuid
relpath = filename if parent is None else os.path.join(parent.relpath, filename) relpath = filename if parent is None else os.path.join(parent.relpath, filename)
# instantiate class before filling children # instantiate class before filling children and versions
ob = cls( ob = cls(
id=root_id,
filename=filename, filename=filename,
relpath=relpath, relpath=relpath,
parent=parent, parent=parent,
children=[], children=[],
filetype=None,
unfrozen_perms=None,
sha256=None,
deleted=None,
versions=[], versions=[],
store=store,
) )
cursor.execute( cursor.execute(
"SELECT id, name, frozen FROM filedir WHERE parent=?", "SELECT sha256, name, store FROM filedir WHERE parent=?",
(root_id,), (root_key,),
) )
rows = cursor.fetchall() rows = cursor.fetchall()
ob.children = [cls.from_db_index(cursor, root_row=r, parent=ob) for r in rows] ob.children = [
cls.from_db_key(cursor, root_row=r, parent=ob, store=store) for r in rows
]
# get all versions # get all versions
cursor.execute( cursor.execute(
"SELECT * FROM filedir_version WHERE filedir=? ORDER BY recorded_time", "SELECT * FROM filedir_version WHERE filedir=? ORDER BY recorded_time",
(root_id,), (root_key,),
) )
matches = cursor.fetchall() matches = cursor.fetchall()
versions = [FSEntryVersion.from_row(row, filedir=ob) for row in matches] ob.versions = [FSEntryVersion.from_row(row, filedir=ob) for row in matches]
if len(versions) > 0:
last_ver = versions[-1]
ob.filetype = last_ver.filetype
ob.deleted = last_ver.deleted
ob.unfrozen_perms = last_ver.unfrozen_perms
ob.symlink_target = last_ver.symlink_target
ob.sha256 = last_ver.sha256
ob.latest_version = last_ver
return ob return ob
@ -409,19 +384,13 @@ class FSEntry:
childsec = childsep + childsep.join(c for c in childstrs) childsec = childsep + childsep.join(c for c in childstrs)
# TODO: list versions in str() # TODO: list versions in str()
# versions: [FSEntryVersion] = []
return "\n".join( return "\n".join(
(" " * level) + line (" " * level) + line
for line in f"""id: {self.id} for line in f"""sha256: {self.sha256.hex()}
filename: {self.filename} filename: {self.filename}
relpath: {self.relpath} relpath: {self.relpath}
parent (relpath): {'None' if self.parent is None else self.parent.relpath} parent (relpath): {'None' if self.parent is None else self.parent.relpath}
filetype: {self.filetype} num versions: {len(self.versions)}
deleted: {self.deleted}
unfrozen_perms: {self.unfrozen_perms}
symlink_target: {self.symlink_target}
sha256: {'None' if self.sha256 is None else self.sha256.hex()}
children: {childsec} children: {childsec}
""".splitlines() """.splitlines()
) )
@ -448,12 +417,18 @@ class FSDiff:
@staticmethod @staticmethod
def compare(A: FSEntry, B: FSEntry) -> bool: def compare(A: FSEntry, B: FSEntry) -> bool:
return ( # get latest versions
A.sha256 == B.sha256 Alatest = None if len(A.versions) == 0 else A.versions[-1]
and A.unfrozen_perms == B.unfrozen_perms Blatest = None if len(B.versions) == 0 else B.versions[-1]
and A.filetype == B.filetype if Alatest is None or Blatest is None:
and A.deleted == B.deleted return False
) else:
return (
Alatest.sha256 == Blatest.sha256
and Alatest.perms == Blatest.perms
and Alatest.filetype == Blatest.filetype
and Alatest.deleted == Blatest.deleted
)
def filename(self) -> str: def filename(self) -> str:
if self.A is not None: if self.A is not None:
@ -462,14 +437,6 @@ class FSDiff:
assert self.B is not None assert self.B is not None
return self.B.filename return self.B.filename
def filetype(self) -> Optional[FileType]:
if self.A is not None:
return self.A.filetype
elif self.B is not None:
return self.B.filetype
else:
return None
@classmethod @classmethod
def compute( def compute(
cls: Type[_FSDiffT], A: Optional[FSEntry], B: Optional[FSEntry] cls: Type[_FSDiffT], A: Optional[FSEntry], B: Optional[FSEntry]
@ -528,6 +495,24 @@ class FSDiff:
return cls(A, B, modified_children) return cls(A, B, modified_children)
def persist(
self,
cur: sqlite3.Cursor,
source_task: program.Task,
parent: Optional[_FSDiffT] = None,
) -> None:
"""Record this level of a diff."""
if self.A is None: # new file
assert self.B is not None
self.B.persist(cur, source_task)
elif self.B is None: # deleted file
self.A.persist_delete(cur, source_task)
else:
# either this node modified, or children are
for c in self.modified_children:
c.persist(cur=cur, source_task=source_task, parent=self)
def flatten_tree(self, level: int = 0) -> List[Tuple[int, "FSDiff"]]: def flatten_tree(self, level: int = 0) -> List[Tuple[int, "FSDiff"]]:
"""Return list of all entries, with level, in pairs""" """Return list of all entries, with level, in pairs"""
pairs = [(level, self)] pairs = [(level, self)]

21
src/nancy/io.py Normal file
View File

@ -0,0 +1,21 @@
from .data import Box
from dataclasses import dataclass, InitVar
from pathlib import Path
@dataclass
class FileDir(Box[Path]):
mode: InitVar[str] = "rw"
def __post_init__(self, mode: str = "rw") -> None:
self.reads = "r" in mode
self.writes = "w" in mode
class Dir(FileDir):
pass
class File(FileDir):
pass

View File

@ -1,6 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
import hashlib import hashlib
import json
import platform import platform
import sqlite3 import sqlite3
import time import time

206
src/nancy/program.py Normal file
View File

@ -0,0 +1,206 @@
"""Programs and tasks."""
from loguru import logger
from . import environment
from dataclasses import dataclass
import datetime
import hashlib
import os
import sqlite3
from typing import Any, Callable, List, Optional, Type, TYPE_CHECKING
import uuid
if TYPE_CHECKING: # avoid cyclic imports but enable proper type checking
from .store import Store
@dataclass(slots=True, frozen=True)
class Package:
name: str
version: str
language: str = "Python"
sha256: bytes = b""
def __post_init__(self) -> None:
m = hashlib.sha256()
upstr: Callable[[str], None] = lambda s: m.update(bytes(s, "utf-8"))
upstr("Package:")
upstr(self.name)
upstr(self.version)
upstr(self.language)
object.__setattr__(self, "sha256", m.digest())
@dataclass(slots=True, frozen=True)
class Module:
name: str
code: Optional[str]
package: Optional[Package]
sha256: bytes = b""
def __post_init__(self) -> None:
m = hashlib.sha256()
upstr: Callable[[str], None] = lambda s: m.update(bytes(s, "utf-8"))
upstr("Module:")
upstr(self.name)
if self.code is not None:
upstr(self.code)
if self.package is not None:
m.update(self.package.sha256)
object.__setattr__(self, "sha256", m.digest())
@dataclass(slots=True, frozen=True)
class Function:
name: str
module: Module
func: Callable[[Any], Any]
inputs: List["FunctionInput"] # not included in hash
outputs: List["FunctionOutput"]
sha256: bytes = b""
def __post_init__(self) -> None:
m = hashlib.sha256()
upstr: Callable[[str], None] = lambda s: m.update(bytes(s, "utf-8"))
upstr("Function:")
upstr(self.name)
m.update(self.module.sha256)
object.__setattr__(self, "sha256", m.digest())
@dataclass(slots=True, frozen=True)
class FunctionInput:
name: str
description: str
required: bool
function: Function
position: int
argtype: Type[Any]
const: bool = False
sha256: bytes = b""
def __post_init__(self) -> None:
m = hashlib.sha256()
upstr: Callable[[str], None] = lambda s: m.update(bytes(s, "utf-8"))
upstr("FunctionInput")
upstr(self.name)
upstr(self.description)
m.update(self.function.sha256)
object.__setattr__(self, "sha256", m.digest())
@dataclass(slots=True, frozen=True)
class FunctionOutput:
name: str
function: Function
position: int
sha256: bytes = b""
def __post_init__(self) -> None:
m = hashlib.sha256()
upstr: Callable[[str], None] = lambda s: m.update(bytes(s, "utf-8"))
upstr("FunctionOutput")
upstr(self.name)
object.__setattr__(self, "sha256", m.digest())
@dataclass(slots=True)
class Task:
program: "Program"
uuid: str = ""
py_function: Optional[Function] = None
def __post_init__(self) -> None:
self.uuid = str(uuid.uuid4())
def insert(self, cur: sqlite3.Cursor) -> None:
func_id = None if self.py_function is None else self.py_function.sha256.hex()
cur.execute(
"INSERT INTO task VALUES (?, ?, ?)",
(self.uuid, self.program.uuid, func_id),
)
@dataclass(slots=True)
class Program:
store: "Store"
name: str
message: str
start_time: Optional[datetime.datetime] = None
evaluated: bool = False
uuid: str = ""
def __post_init__(self) -> None:
self.uuid = str(uuid.uuid4())
def __enter__(self) -> "Program":
if self.evaluated:
raise RuntimeError("Cannot re-enter a Program context")
env = environment.Environment.detect()
with self.store.committing() as cur:
env.maybe_insert(cur)
cur.execute(
"INSERT INTO program VALUES (?, ?, ?, ?, ?, ?, ?)",
(
self.uuid, # uuid
self.name, # name TEXT,
# name of the program, usually written lowercase by calling
# code e.g. cnn_crossval
# -- we use POSIX timestamps for time recording.
# -- e.g. datetime.datetime.now().timestamp()
None, # start_time REAL,
None, # end_time REAL,
os.getpid(), # process_id INTEGER, -- host PID of python process on host OS
env.sha256.hex(), # environment INTEGER NOT NULL,
self.message, # user-defined message to help distinguish similar runs
),
)
self.start_time = datetime.datetime.now()
return self
def new_task(
self,
name: str,
cur: sqlite3.Cursor,
py_function: Optional[Function] = None,
) -> Task:
"""Create a new task and return its uuid"""
t = Task(program=self, py_function=py_function)
t.insert(cur=cur)
return t
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc: Optional[BaseException],
traceback: Optional[Any],
) -> None:
end_time = datetime.datetime.now()
# record start and end times in store
assert self.store.conn is not None
with self.store.committing() as cur:
cur.execute(
"""
UPDATE
program
SET
start_time = ?,
end_time = ?
WHERE
uuid = ?
""",
(self.start_time, end_time, self.uuid),
)
self.evaluated = True # prevent re-running
assert self.start_time is not None
elapsed = end_time - self.start_time
logger.success(
f"Program [{self.uuid}] {self.name} "
f"(message:{self.message}) ran in {elapsed} seconds."
)

View File

@ -87,7 +87,7 @@ CREATE TABLE store (
-- imported and live outside the current store. -- imported and live outside the current store.
-- We do not support renaming files. -- We do not support renaming files.
CREATE TABLE filedir ( CREATE TABLE filedir (
uuid TEXT PRIMARY KEY NOT NULL, sha256 TEXT PRIMARY KEY NOT NULL,
store TEXT NOT NULL, store TEXT NOT NULL,
name TEXT, -- only a filename, not a path name TEXT, -- only a filename, not a path
parent TEXT REFERENCES filedir ON UPDATE CASCADE, parent TEXT REFERENCES filedir ON UPDATE CASCADE,
@ -96,7 +96,7 @@ CREATE TABLE filedir (
-- Detect cross-store references -- Detect cross-store references
CREATE TRIGGER insert_filedir BEFORE INSERT ON filedir CREATE TRIGGER insert_filedir BEFORE INSERT ON filedir
BEGIN SELECT CASE BEGIN SELECT CASE
WHEN NEW.parent IS NOT NULL AND NEW.store != (SELECT store FROM filedir WHERE uuid = NEW.parent) WHEN NEW.parent IS NOT NULL AND NEW.store != (SELECT store FROM filedir WHERE sha256 = NEW.parent)
THEN RAISE (ABORT, 'Parent resides in different store') THEN RAISE (ABORT, 'Parent resides in different store')
END; END; END; END;
CREATE TRIGGER update_filedir BEFORE UPDATE ON filedir CREATE TRIGGER update_filedir BEFORE UPDATE ON filedir
@ -123,8 +123,7 @@ CREATE TABLE filedir_version (
filetype TEXT, -- One of 'LNK', 'DIR', 'REG', etc. See store.FSEntry.from_path for details filetype TEXT, -- One of 'LNK', 'DIR', 'REG', etc. See store.FSEntry.from_path for details
deleted BOOL NOT NULL, -- set True when recording a deleted file deleted BOOL NOT NULL, -- set True when recording a deleted file
-- We record the permissions on each file, in a way that enables reloading -- We record the permissions on each file to enable fixing if needed
-- permissions properly when thawing after a freeze operation.
perms TEXT, -- stat.filemode(os.stat(path).st_mode): '-rw-rw-r--' perms TEXT, -- stat.filemode(os.stat(path).st_mode): '-rw-rw-r--'
symlink_target TEXT, -- if this is a symlink, this is the (read but not fully resolved) target. i.e. this is the "content" of the symlink. symlink_target TEXT, -- if this is a symlink, this is the (read but not fully resolved) target. i.e. this is the "content" of the symlink.
@ -159,7 +158,7 @@ END; END;
CREATE TABLE environment ( CREATE TABLE environment (
sha256 TEXT PRIMARY KEY NOT NULL, sha256 TEXT PRIMARY KEY NOT NULL,
envvars_json TEXT, -- json.dumps(dict(os.environ)) envvars_json TEXT, -- json.dumps(dict(os.environ), sort_keys=True)
python_implementation TEXT, -- platform.python_implementation(): 'cpython' python_implementation TEXT, -- platform.python_implementation(): 'cpython'
python_strversion TEXT, -- sys.version: '3.9.7 (default, Sep 16 2021, 13:09:58) \n[GCC 7.5.0]' python_strversion TEXT, -- sys.version: '3.9.7 (default, Sep 16 2021, 13:09:58) \n[GCC 7.5.0]'
python_hexversion INTEGER, -- sys.hexversion: 50923504 python_hexversion INTEGER, -- sys.hexversion: 50923504

View File

@ -2,7 +2,7 @@
from loguru import logger from loguru import logger
from . import db, environment, fs from . import db, environment, fs, program
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
@ -14,96 +14,6 @@ from typing import Any, Iterator, Optional, TypeVar, Type, Union
import uuid import uuid
import warnings import warnings
@dataclass(slots=True)
class Program:
store: "Store"
name: str
message: str
id: Optional[int] = None
start_time: Optional[datetime.datetime] = None
evaluated: bool = False
uuid: str = ""
def __post_init__(self) -> None:
self.uuid = str(uuid.uuid4())
def __enter__(self) -> "Program":
if self.evaluated:
raise RuntimeError("Cannot re-enter a Program context")
env = environment.Environment.detect()
with self.store.committing() as cur:
env.maybe_insert(cur)
cur.execute(
"INSERT INTO program VALUES (?, ?, ?, ?, ?, ?, ?)",
(
self.uuid, # uuid
self.name, # name TEXT,
# name of the program, usually written lowercase by calling
# code e.g. cnn_crossval
# -- we use POSIX timestamps for time recording.
# -- e.g. datetime.datetime.now().timestamp()
None, # start_time REAL,
None, # end_time REAL,
os.getpid(), # process_id INTEGER, -- host PID of python process on host OS
env.sha256.hex(), # environment INTEGER NOT NULL,
self.message, # user-defined message to help distinguish similar runs
),
)
self.id = cur.lastrowid
self.start_time = datetime.datetime.now()
return self
def new_task(
self,
name: str,
cur: sqlite3.Cursor,
py_function_id: Optional[int] = None,
) -> int:
"""Create a new task and return its id"""
cur.execute(
"INSERT INTO task VALUES (?, ?, ?)",
(None, self.id, py_function_id),
)
taskid = cur.lastrowid
assert isinstance(taskid, int)
return taskid
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc: Optional[BaseException],
traceback: Optional[Any],
) -> None:
end_time = datetime.datetime.now()
# record start and end times in store
assert self.store.conn is not None
with self.store.committing() as cur:
cur.execute(
"""
UPDATE
program
SET
start_time = ?,
end_time = ?
WHERE
uuid = ?
""",
(self.start_time, end_time, self.uuid),
)
self.evaluated = True # prevent re-running
assert self.start_time is not None
elapsed = end_time - self.start_time
logger.success(
f"Program [{self.id}] {self.name} "
f"(message:{self.message}) ran in {elapsed} seconds."
)
# see https://stackoverflow.com/questions/44640479/type-annotation-for-classmethod-returning-instance # see https://stackoverflow.com/questions/44640479/type-annotation-for-classmethod-returning-instance
_StoreT = TypeVar("_StoreT", bound="Store") _StoreT = TypeVar("_StoreT", bound="Store")
@ -111,34 +21,25 @@ _StoreT = TypeVar("_StoreT", bound="Store")
class Store: class Store:
"""Describes a data directory, holds active connection to nancy.db""" """Describes a data directory, holds active connection to nancy.db"""
path: Optional[fs.PathStr] path: fs.PathStr
db_path: fs.PathStr db_path: fs.PathStr
conn: Optional[sqlite3.Connection] conn: Optional[sqlite3.Connection]
uuid: str
def __init__( def __init__(
self, self,
directory: Optional[fs.PathStr] = None, directory: fs.PathStr,
conn: Optional[sqlite3.Connection] = None, conn: Optional[sqlite3.Connection] = None,
): ):
""" self.path = Path(directory)
Arguments: self.db_path = self.path / "nancy.db"
directory (str): Location of existing store directory. If omitted
or None, initialize a store in memory, with no associated
directory.
"""
if directory is None:
self.path = None
self.db_path = ":memory:"
else:
self.path = Path(directory)
self.db_path = self.path / "nancy.db"
if conn is None: if conn is None:
self.connect() self.connect()
else: else:
self.conn = conn self.conn = conn
self.store_uuid = self.find_store_uuid() self.uuid = self.find_store_uuid()
def copy(self: _StoreT, store_path: fs.PathStr) -> _StoreT: def copy(self: _StoreT, store_path: fs.PathStr) -> _StoreT:
"""Copy this store to a new store path""" """Copy this store to a new store path"""
@ -162,7 +63,7 @@ class Store:
yield cur yield cur
self.conn.commit() self.conn.commit()
def find_store_uuid(self, cur: Optional[sqlite3.Cursor] = None) -> Optional[str]: def find_store_uuid(self, cur: Optional[sqlite3.Cursor] = None) -> str:
assert self.conn is not None assert self.conn is not None
if cur is None: if cur is None:
cur = self.conn.cursor() cur = self.conn.cursor()
@ -170,25 +71,22 @@ class Store:
'SELECT value FROM local_metadata WHERE key == "store_uuid" LIMIT 1' 'SELECT value FROM local_metadata WHERE key == "store_uuid" LIMIT 1'
) )
res = cur.fetchone() res = cur.fetchone()
return None if res is None else res[0] assert res is not None
(self.uuid,) = res
return self.uuid
@classmethod @classmethod
def init( def init(cls: Type[_StoreT], message: str, directory: fs.PathStr) -> _StoreT:
cls: Type[_StoreT], message: str, directory: Optional[fs.PathStr] = None
) -> _StoreT:
start_time = datetime.datetime.now() start_time = datetime.datetime.now()
if directory is None: # initialize an in-memory store if not os.path.isdir(directory):
db_path = ":memory:" raise FileNotFoundError(
else: f"Directory {directory} must exist before initializing a store there.",
if not os.path.isdir(directory): )
raise FileNotFoundError( db_path = os.path.join(directory, "nancy.db")
f"Directory {directory} must exist before initializing a store there.", if os.path.isfile(db_path):
) raise FileExistsError(
db_path = os.path.join(directory, "nancy.db") f"File {db_path} exists. Refusing to re-initialize",
if os.path.isfile(db_path): )
raise FileExistsError(
f"File {db_path} exists. Refusing to re-initialize",
)
# initialize a database in the target directory # initialize a database in the target directory
conn = sqlite3.connect(db_path, isolation_level="DEFERRED") conn = sqlite3.connect(db_path, isolation_level="DEFERRED")
cur = conn.cursor() cur = conn.cursor()
@ -196,36 +94,35 @@ class Store:
conn.commit() conn.commit()
cur.close() cur.close()
new_store = cls(directory, conn) new_store = cls(directory=directory, conn=conn)
with new_store.program("INIT", message) as p: with new_store.new_program("INIT", message) as p:
# set the timing to the actual times it took to initialize the db # set the timing to the actual times it took to initialize the db
p.start_time = start_time p.start_time = start_time
# generate a new UUID for this store # generate a new UUID for this store
assert new_store.store_uuid is None assert new_store.uuid is None
new_store.store_uuid = str(uuid.uuid4()) new_store.uuid = str(uuid.uuid4())
with new_store.committing() as cur: with new_store.committing() as cur:
cur.execute( cur.execute(
'INSERT INTO local_metadata VALUES ("store_uuid", ?)', 'INSERT INTO local_metadata VALUES ("store_uuid", ?)',
(new_store.store_uuid,), (new_store.uuid,),
) )
return new_store return new_store
def make_readonly(self) -> None: def filedir_root_key(self, cur: Optional[sqlite3.Cursor] = None) -> Optional[str]:
"""Make store directory read-only (except for nancy.db) and return file list""" """Get the database key for the table entry in this store having name '.'."""
fs.make_readonly_recursive(str(self.path), excluded=["./nancy.db"])
def filedir_root_index(self, cur: Optional[sqlite3.Cursor] = None) -> Optional[int]:
"""Get the database id for the table entry in this store having name '.'"""
if cur is None: if cur is None:
assert self.conn is not None assert self.conn is not None
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute("SELECT id FROM filedir WHERE store=1 AND parent is NULL") cur.execute("SELECT uuid FROM filedir WHERE store=1 AND parent is NULL")
(root_id,) = cur.fetchone() row = cur.fetchone()
assert isinstance(root_id, int) if row is None:
return root_id return None
(root_key,) = row
assert isinstance(root_key, str)
return root_key
def path_to_fsentry(self, path: fs.PathStr) -> Optional[fs.FSEntry]: def path_to_fsentry(self, path: fs.PathStr) -> Optional[fs.FSEntry]:
"""Find a path in the filedir database and return it as an fsentry. """Find a path in the filedir database and return it as an fsentry.
@ -242,156 +139,72 @@ class Store:
) )
# rel tells us how to descend recurively to find the filedir for path # rel tells us how to descend recurively to find the filedir for path
fd_id = self.filedir_root_index(cur) fd_key = self.filedir_root_key(cur)
if fd_id is None: if fd_key is None:
# Root isn't even inserted into the db yet # Root isn't even inserted into the db yet
return None return None
for p in Path(rel).parts: # Path.parts splits a path reliably for p in Path(rel).parts: # Path.parts splits a path reliably
# get child with that name # get child with that name
cur.execute( cur.execute(
"SELECT id, filetype FROM filedir WHERE filename=? AND parent=? LIMIT 1", "SELECT uuid, filetype FROM filedir WHERE filename=? AND parent=? LIMIT 1",
(p, fd_id), (p, fd_key),
) )
row = cur.fetchone() row = cur.fetchone()
if row is None: if row is None:
return None return None
fd_id, filetype = row fd_key, filetype = row
return fs.FSEntry.from_db_index(cur, root_id=fd_id) return fs.FSEntry.from_db_key(cur, root_key=fd_key, store=self)
def fs_entries(self, shallow: bool = False) -> Optional[fs.FSEntry]: def fs_entries(self, shallow: bool = False) -> Optional[fs.FSEntry]:
"""Return recursive structure containing FSEntry objects from db""" """Return recursive structure containing FSEntry objects from db"""
root_id = self.filedir_root_index() root_key = self.filedir_root_key()
if root_id is None: if root_key is None:
return None return None
else: else:
assert self.conn is not None assert self.conn is not None
return fs.FSEntry.from_db_index(self.conn.cursor(), root_id=root_id) return fs.FSEntry.from_db_key(
self.conn.cursor(), root_key=root_key, store=self
)
def program(self, name: str, message: str) -> Program: def new_program(self, name: str, message: str) -> program.Program:
p = Program(self, name, message) p = program.Program(self, name, message)
return p return p
def diff(self) -> fs.FSDiff: def diff(self) -> fs.FSDiff:
""" """
Find changes to files and dirs compared to their recorded versions Find changes to files and dirs compared to their recorded versions.
""" """
# get info about files currently at the given locations # get info about files currently at the given locations
current = fs.FSEntry.from_path(str(self.path)) current = fs.FSEntry.from_path(store=self)
# then find a listing covering all the expected paths # then find a listing covering all the expected paths
recorded = self.fs_entries(shallow=True) recorded = self.fs_entries(shallow=True)
return fs.FSDiff.compute(recorded, current) return fs.FSDiff.compute(recorded, current)
def _record_file_version(
self,
cur: sqlite3.Cursor,
ob: fs.FSEntry,
filedir_id: int,
source_task: Optional[int] = None,
) -> int:
cur.execute(
"INSERT INTO filedir_version VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
None,
filedir_id,
datetime.datetime.now().timestamp(),
ob.filetype,
False,
ob.unfrozen_perms,
ob.symlink_target,
None if ob.sha256 is None else ob.sha256.hex(),
source_task,
),
)
assert isinstance(cur.lastrowid, int)
return cur.lastrowid
def _record_new_file_recursive(
self,
ob: fs.FSEntry,
cur: sqlite3.Cursor,
parent_id: Optional[int],
source_task: Optional[int],
) -> None:
# Find entries with this name and parent
cur.execute(
"SELECT id FROM filedir WHERE store = 1 AND name = ? AND parent = ? LIMIT 1",
(ob.filename, None if ob.parent is None else ob.parent.id),
)
res = cur.fetchall()
if len(res) == 0:
# create filedir entry and get its id
cur.execute(
"INSERT INTO filedir VALUES (?, ?, ?, ?, ?)",
(
None,
1,
ob.filename,
parent_id,
False,
),
)
thisid = cur.lastrowid
else:
(thisid,) = res[0]
assert isinstance(thisid, int)
self._record_file_version(cur, ob, thisid, source_task=source_task)
# descend into children and record all of them anew as well
for c in ob.children:
self._record_new_file_recursive(c, cur, thisid, source_task)
def _record_recursive(
self,
diff: fs.FSDiff,
cur: sqlite3.Cursor,
parent_id: Optional[int] = None,
source_task: Optional[int] = None,
) -> None:
"""Record this level of a diff."""
if diff.A is None:
assert diff.B is not None
self._record_new_file_recursive(
diff.B, cur, parent_id, source_task=source_task
)
elif diff.B is None:
# self._record_deleted_file_recursive(diff.B, cur, parent_id)
pass
else:
# possibly modified, record new version then recurse into children
self._record_new_file_recursive(
diff.B, cur, parent_id, source_task=source_task
)
assert diff.A.id is not None
self._record_file_version(cur, diff.B, diff.A.id, source_task=source_task)
# descend into children
def record( def record(
self, self,
diff: fs.FSDiff, diff: fs.FSDiff,
message: str, message: str,
parent_id: Optional[int] = None, parent_id: Optional[str] = None,
cur: Optional[sqlite3.Cursor] = None, cur: Optional[sqlite3.Cursor] = None,
) -> None: ) -> None:
if cur is None: if cur is None:
assert self.conn is not None assert self.conn is not None
cur = self.conn.cursor() cur = self.conn.cursor()
with self.program("RECORD", message) as p: with self.new_program("RECORD", message) as p:
with self.committing() as cur: # entire record operation is one transaction with self.committing() as cur: # entire record operation is one transaction
# create a task for this operation # create a task for this operation
task_id = p.new_task("Store._record_recursive", cur=cur) task = p.new_task(name="Store._record_recursive", cur=cur)
# descend the diff, tracking parent filedir IDs, creating them and # descend the diff, tracking parent filedir IDs, creating them and
# recording new versions of each, when necessary # recording new versions of each, when necessary
self._record_recursive(diff, cur, source_task=task_id) diff.persist(cur=cur, source_task=task)
def find_store(path: Union[str, "os.PathLike[str]"]) -> Optional[str]: def find_store(path: Union[str, "os.PathLike[str]"]) -> Optional[str]:

0
tests/__init__.py Normal file
View File

38
tests/cli/test_record.py Normal file
View File

@ -0,0 +1,38 @@
from click.testing import CliRunner
from nancy.cli import main
import pytest
import os
from pathlib import Path
import tempfile
from typing import Iterator
@pytest.fixture
def junk_dir() -> Iterator[Path]:
"""Create a temp directory with a few files"""
with tempfile.TemporaryDirectory(prefix="nancy_junkdir") as d:
root = Path(d)
open(root / "empty.txt", "w").close() # touch a file
open(root / "full.txt", "w").write("something") # touch a file
os.mkdir(root / "d")
open(root / "foo.txt", "w").write("bar") # touch a file
yield root
def test_record(junk_dir: Path) -> None:
runner = CliRunner()
result = runner.invoke(
main,
[
"record",
"-s",
str(junk_dir),
"-m",
"This is just a test recording",
],
input="y\n",
)
print(result.output)
assert result.exit_code == 0
assert "ERROR" not in result.output

View File

@ -11,7 +11,7 @@ from typing import Iterator
@pytest.fixture @pytest.fixture
def bare_dir() -> Iterator[Path]: def bare_dir() -> Iterator[Path]:
"""Create an emptry temp directory""" """Create an empty temp directory"""
with tempfile.TemporaryDirectory(prefix="nancy_testdir") as d: with tempfile.TemporaryDirectory(prefix="nancy_testdir") as d:
yield Path(d) yield Path(d)
@ -33,8 +33,8 @@ def test_record_untracked_dir(filled_dir: Path) -> None:
@pytest.fixture @pytest.fixture
def empty_store() -> Iterator[store.Store]: def empty_store(bare_dir: Path) -> Iterator[store.Store]:
s = store.Store.init(message="test init") s = store.Store.init(directory=bare_dir, message="test init")
yield s yield s