From 6cd14610479766977e1f01a74acbe5498cedfc37 Mon Sep 17 00:00:00 2001 From: Johan Hidding Date: Sun, 12 Oct 2025 14:50:45 +0200 Subject: [PATCH 1/6] clean up filedb code; clean up property parsers --- entangled/commands/__init__.py | 2 + entangled/commands/reset.py | 62 +++++++++++++ entangled/commands/sync.py | 4 +- entangled/commands/tangle.py | 14 +-- entangled/config/__init__.py | 10 ++ entangled/filedb.py | 162 +++++++++++++-------------------- entangled/main.py | 4 +- entangled/parsing.py | 109 ++++++++++------------ entangled/properties.py | 52 +++++------ entangled/status.py | 6 +- entangled/transaction.py | 20 ++-- test/test_daemon.py | 4 +- test/test_filedb.py | 6 +- test/test_transaction.py | 8 +- 14 files changed, 233 insertions(+), 230 deletions(-) create mode 100644 entangled/commands/reset.py diff --git a/entangled/commands/__init__.py b/entangled/commands/__init__.py index 0f5d090..c6f631c 100644 --- a/entangled/commands/__init__.py +++ b/entangled/commands/__init__.py @@ -3,12 +3,14 @@ from .stitch import stitch from .sync import sync from .tangle import tangle +from .reset import reset from .watch import watch from .brei import brei __all__ = [ "new", "brei", + "reset", "status", "stitch", "sync", diff --git a/entangled/commands/reset.py b/entangled/commands/reset.py new file mode 100644 index 0000000..a4e3f18 --- /dev/null +++ b/entangled/commands/reset.py @@ -0,0 +1,62 @@ +""" +The `reset` command resets the file database in `.entangled/filedb.json`. +This database gets updated every time you tangle or stitch, but sometimes +its contents may become invalid, for instance when switching branches. +This command will read the markdown sources, then pretend to be tangling +without actually writing out to source files. +""" + +from ..transaction import TransactionMode, transaction +from ..config import config, get_input_files +from ..hooks import get_hooks +from ..document import ReferenceMap +from ..errors.user import UserError + +import logging +from pathlib import Path + + +def reset(): + """ + Resets the database. This performs a tangle without actually writing + output to the files, but updating the database as if we were. + """ + config.read() + + # these imports depend on config being read + from ..markdown_reader import read_markdown_file + from ..tangle import tangle_ref + + input_file_list = get_input_files() + + refs = ReferenceMap() + hooks = get_hooks() + logging.debug("tangling with hooks: %s", [h.__module__ for h in hooks]) + mode = TransactionMode.RESETDB + annotation_method = config.get.annotation + + try: + with transaction(mode) as t: + for path in input_file_list: + logging.debug("reading `%s`", path) + t.update(path) + _, _ = read_markdown_file(path, refs=refs, hooks=hooks) + + for h in hooks: + h.pre_tangle(refs) + + for tgt in refs.targets: + result, deps = tangle_ref(refs, tgt, annotation_method) + mask = next(iter(refs.by_name(tgt))).mode + t.write(Path(tgt), result, list(map(Path, deps)), mask) + + for h in hooks: + h.on_tangle(t, refs) + + t.clear_orphans() + + for h in hooks: + h.post_tangle(refs) + + except UserError as e: + logging.error(str(e)) diff --git a/entangled/commands/sync.py b/entangled/commands/sync.py index e8d9e15..354902c 100644 --- a/entangled/commands/sync.py +++ b/entangled/commands/sync.py @@ -4,7 +4,7 @@ import logging -from ..filedb import file_db +from ..filedb import filedb from ..config import config from .stitch import stitch, get_input_files from .tangle import tangle @@ -18,7 +18,7 @@ def _stitch_then_tangle(): def sync_action() -> Optional[Callable[[], None]]: input_file_list = get_input_files() - with file_db(readonly=True) as db: + with filedb(readonly=True) as db: changed = set(db.changed()) if not all(f in db for f in input_file_list): diff --git a/entangled/commands/tangle.py b/entangled/commands/tangle.py index b316216..d8b13c0 100644 --- a/entangled/commands/tangle.py +++ b/entangled/commands/tangle.py @@ -1,25 +1,15 @@ -from itertools import chain from pathlib import Path import argh # type: ignore import logging from ..document import ReferenceMap -from ..config import config, AnnotationMethod +from ..config import config, AnnotationMethod, get_input_files from ..transaction import transaction, TransactionMode from ..hooks import get_hooks from ..errors.user import UserError -def get_input_files() -> list[Path]: - include_file_list = chain.from_iterable(map(Path(".").glob, config.get.watch_list)) - input_file_list = [ - path for path in include_file_list - if not any(path.match(pat) for pat in config.get.ignore_list) - ] - return input_file_list - - @argh.arg( "-a", "--annotate", @@ -62,7 +52,7 @@ def tangle(*, annotate: str | None = None, force: bool = False, show: bool = Fal for path in input_file_list: logging.debug("reading `%s`", path) t.update(path) - read_markdown_file(path, refs=refs, hooks=hooks) + _, _ = read_markdown_file(path, refs=refs, hooks=hooks) for h in hooks: h.pre_tangle(refs) diff --git a/entangled/config/__init__.py b/entangled/config/__init__.py index 857a3b6..ef2c957 100644 --- a/entangled/config/__init__.py +++ b/entangled/config/__init__.py @@ -11,6 +11,7 @@ from enum import StrEnum from pathlib import Path from typing import Any +from itertools import chain import msgspec from msgspec import Struct, field @@ -186,3 +187,12 @@ def get_language(self, lang_name: str) -> Language | None: config = ConfigWrapper() """The `config.config` variable is changed when the `config` module is loaded. Config is read from `entangled.toml` file.""" + + +def get_input_files() -> list[Path]: + include_file_list = chain.from_iterable(map(Path(".").glob, config.get.watch_list)) + input_file_list = [ + path for path in include_file_list + if not any(path.match(pat) for pat in config.get.ignore_list) + ] + return input_file_list diff --git a/entangled/filedb.py b/entangled/filedb.py index 29376af..bb8562a 100644 --- a/entangled/filedb.py +++ b/entangled/filedb.py @@ -1,12 +1,13 @@ from __future__ import annotations -from collections.abc import Iterable -from dataclasses import dataclass from datetime import datetime from contextlib import contextmanager from pathlib import Path +from typing import override + +import msgspec +from msgspec import Struct import hashlib -import json import os import time import logging @@ -26,10 +27,9 @@ def hexdigest(s: str) -> str: return hashlib.sha256(content).hexdigest() -@dataclass -class FileStat: - path: Path - deps: list[Path] | None +class FileStat(Struct): + path: str + deps: list[str] | None modified: datetime hexdigest: str size: int @@ -51,33 +51,18 @@ def from_path(path: Path, deps: list[Path] | None) -> FileStat: with open(path, "r") as f: digest = hexdigest(f.read()) - return FileStat(path, deps, datetime.fromtimestamp(stat.st_mtime), digest, size) + return FileStat( + path.as_posix(), + [d.as_posix() for d in deps] if deps else None, + datetime.fromtimestamp(stat.st_mtime), digest, size) def __lt__(self, other: FileStat) -> bool: return self.modified < other.modified + @override def __eq__(self, other: object) -> bool: return isinstance(other, FileStat) and self.hexdigest == other.hexdigest - @staticmethod - def from_json(data) -> FileStat: - return FileStat( - Path(data["path"]), - None if data["deps"] is None else [Path(d) for d in data["deps"]], - datetime.fromisoformat(data["modified"]), - data["hexdigest"], - data["size"], - ) - - def to_json(self): - return { - "path": str(self.path), - "deps": None if self.deps is None else [str(p) for p in self.deps], - "modified": self.modified.isoformat(), - "hexdigest": self.hexdigest, - "size": self.size, - } - def stat(path: Path, deps: list[Path] | None = None) -> FileStat: path = normal_relative(path) @@ -85,8 +70,7 @@ def stat(path: Path, deps: list[Path] | None = None) -> FileStat: return FileStat.from_path(path, deps) -@dataclass -class FileDB: +class FileDB(Struct): """Persistent storage for file stats of both Markdown and generated files. We can use this to detect conflicts. @@ -97,28 +81,15 @@ class FileDB: All files are stored in a single dictionary, the distinction between source and target files is made in two separate indices.""" - _files: dict[Path, FileStat] - _source: set[Path] - _target: set[Path] - - @staticmethod - def path(): - return Path(".") / ".entangled" / "filedb.json" - - @staticmethod - def read() -> FileDB: - logging.debug("Reading FileDB") - raw = json.load(open(FileDB.path())) - return FileDB( - {stat.path: stat for stat in (FileStat.from_json(r) for r in raw["files"])}, - set(map(Path, raw["source"])), - set(map(Path, raw["target"])), - ) + version: str + files: dict[str, FileStat] + source: set[str] + target: set[str] def clear(self): - self._files = {} - self._source = set() - self._target = set() + self.files = {} + self.source = set() + self.target = set() @property def managed(self) -> set[Path]: @@ -128,21 +99,11 @@ def managed(self) -> set[Path]: For example: markdown sources cannot be reconstructed, so are not listed here. However, generated code is first constructed from the markdown, so is considered to be managed.""" - return self._target - - def write(self): - logging.debug("Writing FileDB") - raw = { - "version": __version__, - "files": [stat.to_json() for stat in sorted(self._files.values(), key=lambda s: s.path)], - "source": list(sorted(map(str, self._source))), - "target": list(sorted(map(str, self._target))), - } - json.dump(raw, open(FileDB.path(), "w"), indent=2, sort_keys=True) + return {Path(p) for p in self.target} def changed(self) -> list[Path]: """List all target files that have changed w.r.t. the database.""" - return [p for p, s in self._files.items() if s != stat(p)] + return [Path(p) for p, s in self.files.items() if s != stat(Path(p))] def has_changed(self, path: Path) -> bool: return stat(path) != self[path] @@ -151,57 +112,60 @@ def update(self, path: Path, deps: list[Path] | None = None): """Update the given path to a new stat.""" path = normal_relative(path) if path in self.managed and deps is None: - deps = self[path].deps - self._files[path] = stat(path, deps) + known_deps = self[path].deps + if known_deps is not None: + deps = [Path(p) for p in known_deps] + self.files[path.as_posix()] = stat(path, deps) def __contains__(self, path: Path) -> bool: - return path in self._files + return path.as_posix() in self.files def __getitem__(self, path: Path) -> FileStat: - return self._files[path] + return self.files[path.as_posix()] def __delitem__(self, path: Path): - if path in self._target: - self._target.remove(path) - del self._files[path] + if path in self.target: + self.target.remove(str(path)) + del self.files[str(path)] - @property - def files(self) -> Iterable[Path]: - return self._files.keys() + def __iter__(self): + return (Path(p) for p in self.files) def check(self, path: Path, content: str) -> bool: - return hexdigest(content) == self._files[path].hexdigest + return hexdigest(content) == self.files[str(path)].hexdigest - @staticmethod - def initialize() -> FileDB: - if FileDB.path().exists(): - db = FileDB.read() - undead = list(filter(lambda p: not p.exists(), db.files)) - for path in undead: - if path in db.managed: - logging.warning( - "File `%s` in DB seems not to exist, but this file is managed.\n" + - "This may happen every now and then with certain editors that " + - "delete a file before writing.", path - ) - else: - logging.warning( - "File `%s` is in database but doesn't seem to exist.\n" + - "Run `entangled tangle -r` to recreate the database.", path - ) - return db - - FileDB.path().parent.mkdir(parents=True, exist_ok=True) - data = {"version": __version__, "files": [], "source": [], "target": []} - json.dump(data, open(FileDB.path(), "w")) - return FileDB.read() + +FILEDB_PATH = Path(".") / ".entangled" / "filedb.json" +FILEDB_LOCK_PATH = Path(".") / ".entangled" / "filedb.lock" + + +def read_filedb() -> FileDB: + if not FILEDB_PATH.exists(): + return FileDB(__version__, {}, set(), set()) + + logging.debug("Reading FileDB") + db = msgspec.json.decode(FILEDB_PATH.open("br").read(), type=FileDB) + if db.version != __version__: + logging.debug(f"FileDB was written with version {db.version}, running version {__version__}; updating.") + db.version = __version__ + + undead = list(filter(lambda p: not p.exists(), db)) + for path in undead: + logging.warning(f"undead file `{path}` (found in db but not on drive)") + + return db + + +def write_filedb(db: FileDB): + logging.debug("Writing FileDB") + _ = FILEDB_PATH.open("wb").write(msgspec.json.encode(db, order="sorted")) @contextmanager -def file_db(readonly: bool = False): - lock = FileLock(ensure_parent(Path.cwd() / ".entangled" / "filedb.lock")) +def filedb(readonly: bool = False): + lock = FileLock(ensure_parent(FILEDB_LOCK_PATH)) with lock: - db = FileDB.initialize() + db = read_filedb() yield db if not readonly: - db.write() + write_filedb(db) diff --git a/entangled/main.py b/entangled/main.py index 03a22c2..3a9a211 100644 --- a/entangled/main.py +++ b/entangled/main.py @@ -5,7 +5,7 @@ import traceback from rich_argparse import RichHelpFormatter -from .commands import new, status, stitch, sync, tangle, watch, brei +from .commands import new, status, stitch, sync, tangle, watch, brei, reset from .errors.internal import bug_contact from .errors.user import HelpfulUserError, UserError from .version import __version__ @@ -22,7 +22,7 @@ def cli(): _ = parser.add_argument( "-v", "--version", action="store_true", help="show version number" ) - _ = argh.add_commands(parser, [new, brei, status, stitch, sync, tangle, watch], + _ = argh.add_commands(parser, [new, brei, reset, status, stitch, sync, tangle, watch], func_kwargs={"formatter_class": RichHelpFormatter}) args = parser.parse_args() diff --git a/entangled/parsing.py b/entangled/parsing.py index 07fc9b4..f2ed02b 100644 --- a/entangled/parsing.py +++ b/entangled/parsing.py @@ -4,26 +4,16 @@ from __future__ import annotations +from abc import ABC, abstractmethod from dataclasses import dataclass from typing import ( - TypeVar, - TypeVarTuple, - Generic, + Never, Callable, - Any, - ParamSpec, override, ) import re -T = TypeVar("T") -Ts = TypeVarTuple("Ts") -U = TypeVar("U") -P = ParamSpec("P") -T_co = TypeVar("T_co", covariant=True) - - @dataclass class Failure(Exception): """Base class for parser failures.""" @@ -73,68 +63,61 @@ def expected(self): return " | ".join(str(f) for f in self.failures) -class Parser(Generic[T]): +class Parser[T](ABC): """Base class for parsers.""" - def read(self, _: str) -> tuple[T, str]: - """Read a string and return an object the remainder of the string.""" + @abstractmethod + def read(self, inp: str) -> tuple[T, str]: + """Read a string and return an object and the remainder of the string.""" raise NotImplementedError() - def __rshift__(self, f: Callable[[T], Parser[U]]) -> Parser[U]: + def __rshift__[U](self, f: Callable[[T], Parser[U]]) -> Parser[U]: return bind(self, f) - def then(self, p: Parser[U]) -> Parser[U]: + def then[U](self, p: Parser[U]) -> Parser[U]: return bind(self, lambda _: p) + def __or__[U](self, other: Parser[U]) -> Choice[T, U]: + return Choice(self, other) -def starmap(f: Callable[..., U]) -> Callable[[tuple], Parser[U]]: - return lambda args: pure(f(*args)) - - -class ParserMeta(Generic[T], Parser[T], type): - def read(cls, inp: str) -> tuple[T, str]: - return cls.__parser__().read(inp) # type: ignore - - -class Parsable(Generic[T], metaclass=ParserMeta): - """Base class for Parsable objects. Parsables need to define a - `__parser__()` method that should return a `Parser[Self]`. That - way a Parsable class is also a `Parser` object for itself. - This allows for nicely expressive grammars.""" - pass +def splat[*Args, U](f: Callable[[*Args], U]) -> Callable[[tuple[*Args]], Parser[U]]: + def wrapper(args: tuple[*Args]) -> Parser[U]: + return pure(f(*args)) + return wrapper @dataclass -class ParserWrapper(Generic[T], Parser[T]): +class ParserWrapper[T](Parser[T]): """Wrapper class for functional parser.""" f: Callable[[str], tuple[T, str]] + @override def read(self, inp: str) -> tuple[T, str]: return self.f(inp) -def fmap(f: Callable[[T], U]) -> Callable[[T], Parser[U]]: +def fmap[T, U](f: Callable[[T], U]) -> Callable[[T], Parser[U]]: """Map a parser action over a function.""" return lambda x: pure(f(x)) -def parser(f: Callable[[str], tuple[T, str]]) -> Parser[T]: +def parser[T](f: Callable[[str], tuple[T, str]]) -> Parser[T]: """Parser decorator.""" return ParserWrapper(f) -def pure(x: T) -> Parser[T]: +def pure[T](x: T) -> Parser[T]: """Parser that always succeeds and returns value `x`.""" return parser(lambda inp: (x, inp)) -def fail(msg: str) -> Parser[Any]: +def fail(msg: str) -> Parser[Never]: """Parser that always fails with a message `msg`.""" @parser - def _fail(_: str) -> tuple[Any, str]: + def _fail(_: str) -> tuple[Never, str]: raise Failure(msg) return _fail @@ -148,7 +131,7 @@ def item(inp: str) -> tuple[str, str]: return inp[0], inp[1:] -def bind(p: Parser[T], f: Callable[[T], Parser[U]]) -> Parser[U]: +def bind[T, U](p: Parser[T], f: Callable[[T], Parser[U]]) -> Parser[U]: """Fundamental monadic combinator. First parses `p`, then passes the value to `f`, giving a new parser that also knows the result of the first one.""" @@ -169,29 +152,37 @@ def bound(inp: str): # seq = Sequence(pure(())) +@dataclass +class Choice[T, U](Parser[T | U]): + first: Parser[T] + second: Parser[U] -def choice(*options: Parser[Any]) -> Parser[Any]: - @parser - def _choice(inp: str) -> tuple[Any, str]: - failures = [] - - for o in options: - try: - return o.read(inp) - except Failure as f: - failures.append(f) - continue + @override + def read(self, inp: str) -> tuple[T | U, str]: + failures: list[Failure] + + try: + return self.first.read(inp) + except ChoiceFailure as f: + failures = f.failures + except Failure as f: + failures = [f] + + try: + return self.second.read(inp) + except ChoiceFailure as f: + failures.extend(f.failures) + except Failure as f: + failures.append(f) raise ChoiceFailure("", inp, failures) - return _choice - -def optional[T, U](p: Parser[T], default: U | None = None) -> Parser[T | U]: - return choice(p, pure(default)) +def optional[T, U](p: Parser[T], default: U = None) -> Choice[T, U]: + return p | pure(default) -def many(p: Parser[T]) -> Parser[list[T]]: +def many[T](p: Parser[T]) -> Parser[list[T]]: @parser def _many(inp: str) -> tuple[list[T], str]: result: list[T] = [] @@ -206,11 +197,11 @@ def _many(inp: str) -> tuple[list[T], str]: return _many -def matching(regex: str) -> Parser[tuple[str | Any, ...]]: +def matching(regex: str) -> Parser[tuple[str, ...]]: pattern = re.compile(f"^{regex}") @parser - def _matching(inp: str) -> tuple[tuple[str | Any, ...], str]: + def _matching(inp: str) -> tuple[tuple[str, ...], str]: if m := pattern.match(inp): return m.groups(), inp[m.end() :] raise Expected(f"/^{regex}/", inp) @@ -230,8 +221,8 @@ def _fullmatch(inp: str): return _fullmatch -space = matching(r"\s+") +space: Parser[str] = fullmatch(r"\s+") -def tokenize(p: Parser[T]) -> Parser[T]: +def tokenize[T](p: Parser[T]) -> Parser[T]: return optional(space).then(p) diff --git a/entangled/properties.py b/entangled/properties.py index 9c3e9fe..2d160e0 100644 --- a/entangled/properties.py +++ b/entangled/properties.py @@ -3,65 +3,57 @@ from __future__ import annotations -from typing import Any, ClassVar +from typing import Any, cast, override from collections.abc import Iterable from dataclasses import dataclass -import re + from .parsing import ( Parser, many, - choice, tokenize, matching, - Parsable, - starmap, - Failure, + splat, ) @dataclass -class Id(Parsable): +class Id: value: str - _pattern: ClassVar[Parser] = matching(r"#([a-zA-Z]\S*)") + @override def __str__(self): return f"#{self.value}" - @staticmethod - def __parser__(): - return Id._pattern >> starmap(Id) + +id_p: Parser[Id] = cast(Parser[tuple[str]], matching(r"#([a-zA-Z]\S*)")) >> splat(Id) @dataclass -class Class(Parsable): +class Class: value: str - _pattern: ClassVar[Parser] = matching(r"\.?([a-zA-Z]\S*)") + @override def __str__(self): return f".{self.value}" - @staticmethod - def __parser__(): - return Class._pattern >> starmap(Class) + +class_p: Parser[Class] = cast(Parser[tuple[str]], matching(r"\.?([a-zA-Z]\S*)")) >> splat(Class) @dataclass -class Attribute(Parsable): +class Attribute: key: str - value: Any - - _pattern1: ClassVar[Parser] = matching( - r"([a-zA-Z]\S*)\s*=\s*\"([^\"\\]*(?:\\.[^\"\\]*)*)\"" - ) - _pattern2: ClassVar[Parser] = matching(r"([a-zA-Z]\S*)\s*=\s*(\S+)") + value: Any # pyright: ignore[reportExplicitAny] + @override def __str__(self): - return f'{self.key}="{self.value}"' + return f'{self.key}="{self.value}"' # pyright: ignore[reportAny] + - @staticmethod - def __parser__(): - return choice(Attribute._pattern1, Attribute._pattern2) >> starmap(Attribute) +attribute_p: Parser[Attribute] = cast(Parser[tuple[str, str]], + matching(r"([a-zA-Z]\S*)\s*=\s*\"([^\"\\]*(?:\\.[^\"\\]*)*)\"") | + matching(r"([a-zA-Z]\S*)\s*=\s*(\S+)")) >> splat(Attribute) Property = Attribute | Class | Id @@ -75,7 +67,7 @@ def read_properties(inp: str) -> list[Property]: """ # Explicit typing is needed to convince MyPy of correctness # parsers: list[Parser[Property]] = [Id, Class, Attribute] - result, _ = many(tokenize(choice(Id, Attribute, Class))).read(inp) + result, _ = many(tokenize(id_p | attribute_p | class_p)).read(inp) return result @@ -92,9 +84,9 @@ def get_classes(props: list[Property]) -> Iterable[str]: return (p.value for p in props if isinstance(p, Class)) -def get_attribute(props: list[Property], key: str) -> Any: +def get_attribute(props: list[Property], key: str) -> Any: # pyright: ignore[reportExplicitAny, reportAny] """Get the value of an Attribute in a property list.""" try: - return next(p.value for p in props if isinstance(p, Attribute) and p.key == key) + return next(p.value for p in props if isinstance(p, Attribute) and p.key == key) # pyright: ignore[reportAny] except StopIteration: return None diff --git a/entangled/status.py b/entangled/status.py index ff456e1..cb28d20 100644 --- a/entangled/status.py +++ b/entangled/status.py @@ -1,6 +1,6 @@ from collections.abc import Iterable from .config import config -from .filedb import file_db +from .filedb import filedb from itertools import chain from pathlib import Path @@ -21,7 +21,7 @@ def find_watch_dirs(): """List all directories that contain files that need watching.""" input_file_list = list_input_files() markdown_dirs = set(p.parent for p in input_file_list) - with file_db(readonly=True) as db: + with filedb(readonly=True) as db: code_dirs = set(p.parent for p in db.managed) return code_dirs.union(markdown_dirs) @@ -36,6 +36,6 @@ def list_input_files(): def list_dependent_files(): - with file_db(readonly=True) as db: + with filedb(readonly=True) as db: result = list(db.managed) return result diff --git a/entangled/transaction.py b/entangled/transaction.py index 15fe63c..4c9cdb9 100644 --- a/entangled/transaction.py +++ b/entangled/transaction.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from collections.abc import Iterable from dataclasses import dataclass, field from pathlib import Path from contextlib import contextmanager @@ -10,15 +9,8 @@ import logging from typing import override -try: - import rich - - WITH_RICH = True -except ImportError: - WITH_RICH = False - from .utility import cat_maybes -from .filedb import FileDB, stat, file_db, hexdigest +from .filedb import FileDB, stat, filedb, hexdigest from .errors.internal import InternalError @@ -91,11 +83,11 @@ def __str__(self): return f"create `{self.target}`" -def assure_final_newline(str) -> str: - if str[-1] != "\n": - return str + "\n" +def assure_final_newline(s: str) -> str: + if s[-1] != "\n": + return s + "\n" else: - return str + return s @dataclass @@ -237,7 +229,7 @@ class TransactionMode(Enum): @contextmanager def transaction(mode: TransactionMode = TransactionMode.FAIL): - with file_db() as db: + with filedb() as db: if mode == TransactionMode.RESETDB: db.clear() diff --git a/test/test_daemon.py b/test/test_daemon.py index bc208e6..8bef716 100644 --- a/test/test_daemon.py +++ b/test/test_daemon.py @@ -36,6 +36,7 @@ def wait_for_stat_diff(md_stat, filename, timeout=5): return False +@pytest.mark.skip @pytest.mark.skipif( sys.platform=="win32" and sys.version.startswith("3.13"), reason="threading.Event seems to be broken") @@ -63,9 +64,8 @@ def test_daemon(tmp_path: Path): goodbye = '(display "goodbye") (newline)' lines.insert(2, goodbye) Path("hello.scm").write_text("\n".join(lines)) - wait_for_stat_diff(md_stat1, "main.md") + assert wait_for_stat_diff(md_stat1, "main.md") md_stat2 = stat(Path("main.md")) - assert md_stat1 != md_stat2 assert md_stat1 < md_stat2 lines = Path("main.md").read_text().splitlines() diff --git a/test/test_filedb.py b/test/test_filedb.py index 091518b..fdcc616 100644 --- a/test/test_filedb.py +++ b/test/test_filedb.py @@ -1,4 +1,4 @@ -from entangled.filedb import file_db, stat +from entangled.filedb import filedb, stat from time import sleep from pathlib import Path import pytest @@ -33,14 +33,14 @@ def test_stat(example_files: Path): def test_filedb(example_files: Path): with chdir(example_files): - with file_db() as db: + with filedb() as db: for n in "abcd": db.update(Path(n)) with open(example_files / "d", "w") as f: f.write("mars") - with file_db() as db: + with filedb() as db: assert db.changed() == [Path("d")] db.update(Path("d")) assert db.changed() == [] diff --git a/test/test_transaction.py b/test/test_transaction.py index b49aaf9..6fa6b6f 100644 --- a/test/test_transaction.py +++ b/test/test_transaction.py @@ -2,12 +2,12 @@ from pathlib import Path from entangled.transaction import Transaction, Create, Write, Delete -from entangled.filedb import file_db +from entangled.filedb import filedb def test_transaction(tmp_path: Path): with chdir(tmp_path): - with file_db() as db: + with filedb() as db: t = Transaction(db) t.write(Path("a"), "hello", []) t.write(Path("b"), "goodbye", [Path("a")]) @@ -20,7 +20,7 @@ def test_transaction(tmp_path: Path): with open(Path("a"), "w") as f: f.write("ciao") - with file_db() as db: + with filedb() as db: assert Path("a") in db assert Path("b") in db assert list(db.changed()) == [Path("a")] @@ -33,7 +33,7 @@ def test_transaction(tmp_path: Path): assert isinstance(t.actions[0], Write) assert not t.all_ok() - with file_db() as db: + with filedb() as db: t = Transaction(db) t.write(Path("a"), "goodbye", []) assert isinstance(t.actions[0], Write) From 8f4992c201a4b84e570a3929986fd4e9a27293a2 Mon Sep 17 00:00:00 2001 From: Johan Hidding Date: Sun, 12 Oct 2025 22:11:27 +0200 Subject: [PATCH 2/6] tests are passing again --- entangled/filedb.py | 5 +++++ entangled/transaction.py | 38 +++++++++++++++++++++----------------- test/test_transaction.py | 2 +- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/entangled/filedb.py b/entangled/filedb.py index bb8562a..bfb396d 100644 --- a/entangled/filedb.py +++ b/entangled/filedb.py @@ -108,10 +108,15 @@ def changed(self) -> list[Path]: def has_changed(self, path: Path) -> bool: return stat(path) != self[path] + def update_target(self, path: Path, deps: list[Path]): + self.update(path, deps) + self.target.add(path.as_posix()) + def update(self, path: Path, deps: list[Path] | None = None): """Update the given path to a new stat.""" path = normal_relative(path) if path in self.managed and deps is None: + logging.warning(f"updating managed file {path} without deps given.") known_deps = self[path].deps if known_deps is not None: deps = [Path(p) for p in known_deps] diff --git a/entangled/transaction.py b/entangled/transaction.py index 4c9cdb9..8af15d4 100644 --- a/entangled/transaction.py +++ b/entangled/transaction.py @@ -14,12 +14,18 @@ from .errors.internal import InternalError +@dataclass +class Conflict: + target: Path + description: str + + @dataclass class Action(ABC): target: Path @abstractmethod - def conflict(self, db: FileDB) -> str | None: + def conflict(self, db: FileDB) -> Conflict | None: """Indicate wether the action might have conflicts. This could be inconsistency in the modification times of files, or overwriting a file that is not managed by Entangled.""" @@ -44,23 +50,23 @@ class Create(Action): mode: int | None @override - def conflict(self, db: FileDB) -> str | None: + def conflict(self, db: FileDB) -> Conflict | None: if self.target.exists(): # Check if file contents are the same as what we want to write or is empty # then it is safe to take ownership. md_stat = stat(self.target) - fileHexdigest = md_stat.hexdigest - contentHexdigest = hexdigest(self.content) - if (contentHexdigest == fileHexdigest) or (md_stat.size == 0): + file_digest = md_stat.hexdigest + content_digest = hexdigest(self.content) + if (content_digest == file_digest) or (md_stat.size == 0): return None - return f"{self.target} is not managed by Entangled" + return Conflict(self.target, "not managed by Entangled") return None @override def add_to_db(self, db: FileDB): - db.update(self.target, self.sources) - if self.sources != []: - db.managed.add(self.target) + db.update_target(self.target, self.sources) + if not self.sources: + logging.warning(f"Creating file `{self.target}` but no sources listed.") @override def run(self, db: FileDB): @@ -97,14 +103,14 @@ class Write(Action): mode: int | None @override - def conflict(self, db: FileDB) -> str | None: + def conflict(self, db: FileDB) -> Conflict | None: st = stat(self.target) if st != db[self.target]: - return f"`{self.target}` seems to have changed outside the control of Entangled" + return Conflict(self.target, "changed outside the control of Entangled") if self.sources: newest_src = max(stat(s) for s in self.sources) if st > newest_src: - return f"`{self.target}` seems to be newer than `{newest_src.path}`" + return Conflict(self.target, f"newer than `{newest_src.path}`") return None @override @@ -134,12 +140,10 @@ def __str__(self): @dataclass class Delete(Action): @override - def conflict(self, db: FileDB) -> str | None: + def conflict(self, db: FileDB) -> Conflict | None: st = stat(self.target) if st != db[self.target]: - return ( - f"{self.target} seems to have changed outside the control of Entangled" - ) + return Conflict(self.target, "changed outside the control of Entangled") return None @override @@ -192,7 +196,7 @@ def clear_orphans(self): for p in orphans: self.actions.append(Delete(p)) - def check_conflicts(self) -> list[str]: + def check_conflicts(self) -> list[Conflict]: return list(cat_maybes(a.conflict(self.db) for a in self.actions)) def all_ok(self) -> bool: diff --git a/test/test_transaction.py b/test/test_transaction.py index 6fa6b6f..c1a8d79 100644 --- a/test/test_transaction.py +++ b/test/test_transaction.py @@ -18,7 +18,7 @@ def test_transaction(tmp_path: Path): assert Path("b").exists() with open(Path("a"), "w") as f: - f.write("ciao") + _ = f.write("ciao") with filedb() as db: assert Path("a") in db From 9b62e65b8632c0978f3131cd26c89b5ed3fbd181 Mon Sep 17 00:00:00 2001 From: Johan Hidding Date: Tue, 14 Oct 2025 10:16:25 +0200 Subject: [PATCH 3/6] move io related code to separate folder; introduce FileCache class --- entangled/filedb.py | 176 ------------------------------ entangled/io/__init__.py | 0 entangled/io/filedb.py | 108 ++++++++++++++++++ entangled/io/stat.py | 70 ++++++++++++ entangled/{ => io}/transaction.py | 134 +++++++++++------------ entangled/io/virtual.py | 25 +++++ 6 files changed, 269 insertions(+), 244 deletions(-) delete mode 100644 entangled/filedb.py create mode 100644 entangled/io/__init__.py create mode 100644 entangled/io/filedb.py create mode 100644 entangled/io/stat.py rename entangled/{ => io}/transaction.py (79%) create mode 100644 entangled/io/virtual.py diff --git a/entangled/filedb.py b/entangled/filedb.py deleted file mode 100644 index bfb396d..0000000 --- a/entangled/filedb.py +++ /dev/null @@ -1,176 +0,0 @@ -from __future__ import annotations -from datetime import datetime -from contextlib import contextmanager -from pathlib import Path -from typing import override - -import msgspec -from msgspec import Struct - -import hashlib -import os -import time -import logging - -from filelock import FileLock - -from .version import __version__ -from .utility import normal_relative, ensure_parent -from .errors.user import FileError - - -def hexdigest(s: str) -> str: - """Creates a MD5 hash digest from a string. Before hashing, the string has - linefeed `\\r` characters and trailing newlines removed, and the string - is encoded as UTF-8.""" - content = s.replace("\r", "").rstrip().encode() - return hashlib.sha256(content).hexdigest() - - -class FileStat(Struct): - path: str - deps: list[str] | None - modified: datetime - hexdigest: str - size: int - - @staticmethod - def from_path(path: Path, deps: list[Path] | None) -> FileStat: - stat: os.stat_result | None = None - for _ in range(5): - try: - stat = os.stat(path) - except FileNotFoundError: - logging.warning("File `%s` not found.", path) - time.sleep(0.1) - - if stat is None: - raise FileError(path) - - size = stat.st_size - with open(path, "r") as f: - digest = hexdigest(f.read()) - - return FileStat( - path.as_posix(), - [d.as_posix() for d in deps] if deps else None, - datetime.fromtimestamp(stat.st_mtime), digest, size) - - def __lt__(self, other: FileStat) -> bool: - return self.modified < other.modified - - @override - def __eq__(self, other: object) -> bool: - return isinstance(other, FileStat) and self.hexdigest == other.hexdigest - - -def stat(path: Path, deps: list[Path] | None = None) -> FileStat: - path = normal_relative(path) - deps = None if deps is None else [normal_relative(d) for d in deps] - return FileStat.from_path(path, deps) - - -class FileDB(Struct): - """Persistent storage for file stats of both Markdown and generated - files. We can use this to detect conflicts. - - This data is stored in `.entangled/files.json`. It is recommended to - keep this file under version control. That way entangled shouldn't get - too confused when switching branches. - - All files are stored in a single dictionary, the distinction between - source and target files is made in two separate indices.""" - - version: str - files: dict[str, FileStat] - source: set[str] - target: set[str] - - def clear(self): - self.files = {} - self.source = set() - self.target = set() - - @property - def managed(self) -> set[Path]: - """List all managed files. These are files that can be reconstructed - from the sources, at least when things are in a consistent state. - - For example: markdown sources cannot be reconstructed, so are not - listed here. However, generated code is first constructed from - the markdown, so is considered to be managed.""" - return {Path(p) for p in self.target} - - def changed(self) -> list[Path]: - """List all target files that have changed w.r.t. the database.""" - return [Path(p) for p, s in self.files.items() if s != stat(Path(p))] - - def has_changed(self, path: Path) -> bool: - return stat(path) != self[path] - - def update_target(self, path: Path, deps: list[Path]): - self.update(path, deps) - self.target.add(path.as_posix()) - - def update(self, path: Path, deps: list[Path] | None = None): - """Update the given path to a new stat.""" - path = normal_relative(path) - if path in self.managed and deps is None: - logging.warning(f"updating managed file {path} without deps given.") - known_deps = self[path].deps - if known_deps is not None: - deps = [Path(p) for p in known_deps] - self.files[path.as_posix()] = stat(path, deps) - - def __contains__(self, path: Path) -> bool: - return path.as_posix() in self.files - - def __getitem__(self, path: Path) -> FileStat: - return self.files[path.as_posix()] - - def __delitem__(self, path: Path): - if path in self.target: - self.target.remove(str(path)) - del self.files[str(path)] - - def __iter__(self): - return (Path(p) for p in self.files) - - def check(self, path: Path, content: str) -> bool: - return hexdigest(content) == self.files[str(path)].hexdigest - - -FILEDB_PATH = Path(".") / ".entangled" / "filedb.json" -FILEDB_LOCK_PATH = Path(".") / ".entangled" / "filedb.lock" - - -def read_filedb() -> FileDB: - if not FILEDB_PATH.exists(): - return FileDB(__version__, {}, set(), set()) - - logging.debug("Reading FileDB") - db = msgspec.json.decode(FILEDB_PATH.open("br").read(), type=FileDB) - if db.version != __version__: - logging.debug(f"FileDB was written with version {db.version}, running version {__version__}; updating.") - db.version = __version__ - - undead = list(filter(lambda p: not p.exists(), db)) - for path in undead: - logging.warning(f"undead file `{path}` (found in db but not on drive)") - - return db - - -def write_filedb(db: FileDB): - logging.debug("Writing FileDB") - _ = FILEDB_PATH.open("wb").write(msgspec.json.encode(db, order="sorted")) - - -@contextmanager -def filedb(readonly: bool = False): - lock = FileLock(ensure_parent(FILEDB_LOCK_PATH)) - with lock: - db = read_filedb() - yield db - if not readonly: - write_filedb(db) diff --git a/entangled/io/__init__.py b/entangled/io/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/entangled/io/filedb.py b/entangled/io/filedb.py new file mode 100644 index 0000000..fcb38c6 --- /dev/null +++ b/entangled/io/filedb.py @@ -0,0 +1,108 @@ +from __future__ import annotations +from contextlib import contextmanager +from pathlib import Path + +import msgspec +from msgspec import Struct + +import logging + +from filelock import FileLock + +from ..version import __version__ +from ..utility import normal_relative, ensure_parent +from .virtual import FileCache +from .stat import Stat + + +class FileDB(Struct): + """Persistent storage for file stats of both Markdown and generated + files. We can use this to detect conflicts. + + This data is stored in `.entangled/files.json`. It is recommended to + keep this file under version control. That way entangled shouldn't get + too confused when switching branches. + + All files are stored in a single dictionary, the distinction between + source and target files is made in two separate indices.""" + + version: str + files: dict[str, Stat] + targets: set[str] + + def clear(self): + self.files = {} + self.targets = set() + + @property + def managed_files(self) -> set[Path]: + """List all managed files. These are files that can be reconstructed + from the sources, at least when things are in a consistent state. + + For example: markdown sources cannot be reconstructed, so are not + listed here. However, generated code is first constructed from + the markdown, so is considered to be managed.""" + return {Path(p) for p in self.targets} + + def create_target(self, fs: FileCache, path: Path): + path = normal_relative(path) + self.update(fs, path) + self.targets.add(path.as_posix()) + + def update(self, fs: FileCache, path: Path): + path = normal_relative(path) + self.files[path.as_posix()] = fs[path].stat + + def __contains__(self, path: Path) -> bool: + return path.as_posix() in self.files + + def __getitem__(self, path: Path) -> Stat: + return self.files[path.as_posix()] + + def __delitem__(self, path: Path): + path_str = path.as_posix() + if path_str in self.targets: + self.targets.remove(path_str) + del self.files[path_str] + + def __iter__(self): + return (Path(p) for p in self.files) + + def check(self, path: Path, content: str) -> bool: + return hexdigest(content) == self.files[str(path)].hexdigest + + +FILEDB_PATH = Path(".") / ".entangled" / "filedb.json" +FILEDB_LOCK_PATH = Path(".") / ".entangled" / "filedb.lock" + + +def read_filedb() -> FileDB: + if not FILEDB_PATH.exists(): + return FileDB(__version__, {}, set(), set()) + + logging.debug("Reading FileDB") + db = msgspec.json.decode(FILEDB_PATH.open("br").read(), type=FileDB) + if db.version != __version__: + logging.debug(f"FileDB was written with version {db.version}, running version {__version__}; updating.") + db.version = __version__ + + undead = list(filter(lambda p: not p.exists(), db)) + for path in undead: + logging.warning(f"undead file `{path}` (found in db but not on drive)") + + return db + + +def write_filedb(db: FileDB): + logging.debug("Writing FileDB") + _ = FILEDB_PATH.open("wb").write(msgspec.json.encode(db, order="sorted")) + + +@contextmanager +def filedb(readonly: bool = False): + lock = FileLock(ensure_parent(FILEDB_LOCK_PATH)) + with lock: + db = read_filedb() + yield db + if not readonly: + write_filedb(db) diff --git a/entangled/io/stat.py b/entangled/io/stat.py new file mode 100644 index 0000000..1b5e9cf --- /dev/null +++ b/entangled/io/stat.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import override +from datetime import datetime +from pathlib import Path + +import hashlib +import os +import logging +import time + +from ..utility import normal_relative + + +def hexdigest(s: str) -> str: + """Creates a MD5 hash digest from a string. Before hashing, the string has + linefeed `\\r` characters and trailing newlines removed, and the string + is encoded as UTF-8.""" + content = s.replace("\r", "").rstrip().encode() + return hashlib.sha256(content).hexdigest() + + +@dataclass +class Stat: + modified: datetime + hexdigest: str + + def __lt__(self, other: Stat) -> bool: + return self.modified < other.modified + + @override + def __eq__(self, other: object) -> bool: + if not isinstance(other, Stat): + return False + return self.hexdigest == other.hexdigest + + +@dataclass +class FileData: + path: Path + content: str + stat: Stat + + @staticmethod + def from_path(path: Path) -> FileData | None: + stat: os.stat_result | None = None + for _ in range(5): + try: + stat = os.stat(path) + except FileNotFoundError: + logging.warning("File `%s` not found.", path) + time.sleep(0.1) + + if stat is None: + return None + + with open(path, "r") as f: + content = f.read() + digest = hexdigest(content) + + return FileData( + path, + content, + Stat(datetime.fromtimestamp(stat.st_mtime), digest)) + + +def stat(path: Path) -> FileData | None: + path = normal_relative(path) + return FileData.from_path(path) diff --git a/entangled/transaction.py b/entangled/io/transaction.py similarity index 79% rename from entangled/transaction.py rename to entangled/io/transaction.py index 8af15d4..50168b4 100644 --- a/entangled/transaction.py +++ b/entangled/io/transaction.py @@ -1,5 +1,6 @@ -from abc import ABC, abstractmethod +from abc import ABC, ABCMeta, abstractmethod from dataclasses import dataclass, field +from functools import cached_property from pathlib import Path from contextlib import contextmanager from enum import Enum @@ -10,18 +11,46 @@ from typing import override from .utility import cat_maybes -from .filedb import FileDB, stat, filedb, hexdigest +from .filedb import FileDB, FileStat, stat, filedb, hexdigest from .errors.internal import InternalError -@dataclass +def assure_final_newline(s: str) -> str: + if s[-1] != "\n": + return s + "\n" + else: + return s + + +def atomic_write(target: Path, content: str, mode: int | None): + """ + Writes a file by first writing to a temporary location and then moving + the file to the target path. + """ + tmp_dir = Path() / ".entangled" / "tmp" + tmp_dir.mkdir(exist_ok=True, parents=True) + with tempfile.NamedTemporaryFile(mode="w", delete=False, dir=tmp_dir) as f: + _ = f.write(assure_final_newline(content)) + # Flush and sync contents to disk + f.flush() + if mode is not None: + os.chmod(f.name, mode) + os.fsync(f.fileno()) + os.replace(f.name, target) + + +@dataclass(frozen=True) class Conflict: target: Path description: str + @override + def __str__(self): + return f"`{self.target}` {self.description}" -@dataclass -class Action(ABC): + +@dataclass(frozen=True) +class Action(metaclass=ABCMeta): target: Path @abstractmethod @@ -42,15 +71,40 @@ def run(self, db: FileDB): asked in case of a conflict.""" ... + @cached_property + def target_stat(self) -> FileStat | None: + if not self.target.exists(): + return None + return stat(self.target) -@dataclass -class Create(Action): + +@dataclass(frozen=True) +class WriterBase(Action, metaclass=ABCMeta): content: str sources: list[Path] mode: int | None + @cached_property + def content_digest(self) -> str: + return hexdigest(self.content) + + @override + def add_to_db(self, db: FileDB): + db.update_target(self.target, self.sources) + + @override + def run(self, db: FileDB): + self.target.parent.mkdir(parents=True, exist_ok=True) + atomic_write(self.target, self.content, self.mode) + self.add_to_db(db) + + +class Create(WriterBase): @override def conflict(self, db: FileDB) -> Conflict | None: + if not self.sources: + logging.warning(f"Creating file `{self.target}` but no sources listed.") + if self.target.exists(): # Check if file contents are the same as what we want to write or is empty # then it is safe to take ownership. @@ -62,87 +116,31 @@ def conflict(self, db: FileDB) -> Conflict | None: return Conflict(self.target, "not managed by Entangled") return None - @override - def add_to_db(self, db: FileDB): - db.update_target(self.target, self.sources) - if not self.sources: - logging.warning(f"Creating file `{self.target}` but no sources listed.") - - @override - def run(self, db: FileDB): - self.target.parent.mkdir(parents=True, exist_ok=True) - # Write to tmp file then replace with file name - tmp_dir = Path() / ".entangled" / "tmp" - tmp_dir.mkdir(exist_ok=True, parents=True) - with tempfile.NamedTemporaryFile(mode="w", delete=False, dir=tmp_dir) as f: - _ = f.write(self.content) - # Flush and sync contents to disk - f.flush() - if self.mode is not None: - os.chmod(f.name, self.mode) - os.fsync(f.fileno()) - os.replace(f.name, self.target) - self.add_to_db(db) - @override def __str__(self): return f"create `{self.target}`" -def assure_final_newline(s: str) -> str: - if s[-1] != "\n": - return s + "\n" - else: - return s - - -@dataclass -class Write(Action): - content: str - sources: list[Path] - mode: int | None - +class Write(WriterBase): @override def conflict(self, db: FileDB) -> Conflict | None: - st = stat(self.target) - if st != db[self.target]: + if self.target_stat != db[self.target]: return Conflict(self.target, "changed outside the control of Entangled") - if self.sources: + if self.sources and self.target_stat: newest_src = max(stat(s) for s in self.sources) - if st > newest_src: + if self.target_stat > newest_src: return Conflict(self.target, f"newer than `{newest_src.path}`") return None - @override - def add_to_db(self, db: FileDB): - db.update(self.target, self.sources) - - @override - def run(self, db: FileDB): - # Write to tmp file then replace with file name - tmp_dir = Path() / ".entangled" / "tmp" - tmp_dir.mkdir(exist_ok=True, parents=True) - with tempfile.NamedTemporaryFile(mode="w", delete=False, dir=tmp_dir) as f: - _ = f.write(assure_final_newline(self.content)) - # Flush and sync contents to disk - f.flush() - if self.mode is not None: - os.chmod(f.name, self.mode) - os.fsync(f.fileno()) - os.replace(f.name, self.target) - self.add_to_db(db) - @override def __str__(self): return f"write `{self.target}`" -@dataclass class Delete(Action): @override def conflict(self, db: FileDB) -> Conflict | None: - st = stat(self.target) - if st != db[self.target]: + if self.target_stat != db[self.target]: return Conflict(self.target, "changed outside the control of Entangled") return None diff --git a/entangled/io/virtual.py b/entangled/io/virtual.py new file mode 100644 index 0000000..af8ffee --- /dev/null +++ b/entangled/io/virtual.py @@ -0,0 +1,25 @@ +""" +A virtual file system layer to cache file reads and stats. +""" + +from dataclasses import dataclass, field +from pathlib import Path +from .stat import stat, FileData + + +@dataclass +class FileCache: + _data: dict[Path, FileData] = field(default_factory=dict) + + def __getitem__(self, key: Path) -> FileData: + if key not in self._data: + if (s := stat(key)) is None: + raise KeyError() + self._data[key] = s + return self._data[key] + + def __contains__(self, key: Path) -> bool: + return key.exists() + + def reset(self): + self._data = {} From 0b7d5f87c8fd820caf06712a06528ef2263bcb16 Mon Sep 17 00:00:00 2001 From: Johan Hidding Date: Tue, 14 Oct 2025 15:10:50 +0200 Subject: [PATCH 4/6] finalize clean up --- entangled/code_reader.py | 2 +- entangled/commands/brei.py | 12 +- entangled/commands/new.py | 13 +-- entangled/commands/reset.py | 2 +- entangled/commands/status.py | 9 +- entangled/commands/stitch.py | 6 +- entangled/commands/sync.py | 13 ++- entangled/commands/tangle.py | 2 +- entangled/config/__init__.py | 10 +- entangled/document.py | 6 +- entangled/hooks/base.py | 10 +- entangled/hooks/build.py | 2 +- entangled/hooks/repl.py | 2 +- entangled/hooks/task.py | 2 +- entangled/io/__init__.py | 11 ++ entangled/io/filedb.py | 11 +- entangled/io/transaction.py | 144 ++++++++++------------- entangled/io/virtual.py | 76 +++++++++++- entangled/logging.py | 8 +- entangled/status.py | 6 +- entangled/tangle.py | 12 +- test/test_config.py | 2 +- test/test_daemon.py | 14 ++- test/test_filedb.py | 26 +++-- test/test_modes.py | 25 ++-- test/test_transaction.py | 10 +- uv.lock | 217 +++++++++++++++++++---------------- 27 files changed, 385 insertions(+), 268 deletions(-) diff --git a/entangled/code_reader.py b/entangled/code_reader.py index affb35a..5da2116 100644 --- a/entangled/code_reader.py +++ b/entangled/code_reader.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from pathlib import Path, PurePath +from pathlib import PurePath import mawk import re diff --git a/entangled/commands/brei.py b/entangled/commands/brei.py index 330cf6f..fcd8107 100644 --- a/entangled/commands/brei.py +++ b/entangled/commands/brei.py @@ -1,5 +1,6 @@ from pathlib import Path -from typing import Awaitable, Optional +from collections.abc import Awaitable +from typing import Any import argh # type: ignore import asyncio import textwrap @@ -11,15 +12,15 @@ log = logger() -async def main(target_strs: list[str], force_run: bool, throttle: Optional[int]): +async def main(target_strs: list[str], force_run: bool, throttle: int | None): if not Path(".entangled").exists(): Path(".entangled").mkdir() - db = await resolve_tasks(config.brei, Path(".entangled/brei_history")) + db = await resolve_tasks(config.get.brei, Path(".entangled/brei_history")) if throttle: db.throttle = asyncio.Semaphore(throttle) db.force_run = force_run - jobs: list[Awaitable] = [db.run(Phony(t), db=db) for t in target_strs] + jobs: list[Awaitable[Any]] = [db.run(Phony(t), db=db) for t in target_strs] with db.persistent_history(): results = await asyncio.gather(*jobs) @@ -35,8 +36,7 @@ async def main(target_strs: list[str], force_run: bool, throttle: Optional[int]) @argh.arg("targets", nargs="+", help="name of target to run") @argh.arg("-B", "--force-run", help="rebuild all dependencies") @argh.arg("-j", "--throttle", help="limit number of concurrent jobs") -def brei(targets: list[str], *, force_run: bool = False, throttle: Optional[int] = None): +def brei(targets: list[str], *, force_run: bool = False, throttle: int | None = None): """Build one of the configured targets.""" config.read() asyncio.run(main(targets, force_run, throttle)) - diff --git a/entangled/commands/new.py b/entangled/commands/new.py index 52c235b..860eaf5 100644 --- a/entangled/commands/new.py +++ b/entangled/commands/new.py @@ -1,6 +1,5 @@ -from typing import Optional - import argh # type: ignore +from argh.utils import get_subparsers import argparse from pathlib import Path @@ -45,7 +44,7 @@ def print_help() -> None: """ parser = argparse.ArgumentParser(formatter_class=RichHelpFormatter) argh.add_commands(parser, [new], func_kwargs={"formatter_class": RichHelpFormatter}) - argh.utils.get_subparsers(parser).choices["new"].print_help() + get_subparsers(parser).choices["new"].print_help() @argh.arg( @@ -96,9 +95,9 @@ def print_help() -> None: help="Initialize a new project at this path", ) def new( - template: Optional[str], - project_path: Optional[Path], *, - answers_file: Optional[str] = None, + template: str | None, + project_path: Path | None, *, + answers_file: str | None = None, data: str = "", defaults: bool = False, pretend: bool = False, @@ -135,7 +134,7 @@ def new( copy_this_template = template_option.url break - data_dict: dict = {} + data_dict: dict[str, str] = {} if data: try: for d in data.split(";"): diff --git a/entangled/commands/reset.py b/entangled/commands/reset.py index a4e3f18..b0646fa 100644 --- a/entangled/commands/reset.py +++ b/entangled/commands/reset.py @@ -6,7 +6,7 @@ without actually writing out to source files. """ -from ..transaction import TransactionMode, transaction +from ..io import TransactionMode, transaction from ..config import config, get_input_files from ..hooks import get_hooks from ..document import ReferenceMap diff --git a/entangled/commands/status.py b/entangled/commands/status.py index 87d01f7..b9a5f58 100644 --- a/entangled/commands/status.py +++ b/entangled/commands/status.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterable -from ..status import find_watch_dirs, list_input_files, list_dependent_files +from collections.abc import Iterable +from ..status import list_input_files, list_dependent_files from ..config import config from pathlib import Path @@ -10,6 +10,7 @@ from rich.panel import Panel from rich.tree import Tree + def tree_from_files(files: Iterable[Path]): tree = Tree(label=".") dirs = {Path("."): tree} @@ -17,13 +18,15 @@ def tree_from_files(files: Iterable[Path]): for p in reversed(f.parents): if p not in dirs: dirs[p] = dirs[p.parent].add(p.name, style="repr.path") - dirs[f.parent].add(f.name, style="repr.filename") + _ = dirs[f.parent].add(f.name, style="repr.filename") return tree + def files_panel(file_list: Iterable[Path], title: str) -> Panel: tree = tree_from_files(file_list) return Panel(tree, title=title, border_style="dark_cyan") + def rich_status(): config_table = Table() config_table.add_column("name") diff --git a/entangled/commands/stitch.py b/entangled/commands/stitch.py index d0adb5f..c6fbe93 100644 --- a/entangled/commands/stitch.py +++ b/entangled/commands/stitch.py @@ -6,9 +6,9 @@ from ..config import config from ..document import ReferenceMap, Content, PlainText, ReferenceId -from ..transaction import transaction, TransactionMode +from ..io import transaction, TransactionMode from ..errors.user import UserError -from .tangle import get_input_files +from ..config import get_input_files def stitch_markdown(reference_map: ReferenceMap, content: list[Content]) -> str: @@ -52,7 +52,7 @@ def stitch(*, force: bool = False, show: bool = False): content[path] = c with transaction(mode) as t: - for path in t.db.managed: + for path in t.db.managed_files: logging.debug("reading `%s`", path) t.update(path) with open(path, "r") as f: diff --git a/entangled/commands/sync.py b/entangled/commands/sync.py index 354902c..df1a86d 100644 --- a/entangled/commands/sync.py +++ b/entangled/commands/sync.py @@ -4,7 +4,7 @@ import logging -from ..filedb import filedb +from ..io import filedb, FileCache from ..config import config from .stitch import stitch, get_input_files from .tangle import tangle @@ -15,11 +15,12 @@ def _stitch_then_tangle(): tangle() -def sync_action() -> Optional[Callable[[], None]]: +def sync_action() -> Callable[[], None] | None: input_file_list = get_input_files() + fs = FileCache() with filedb(readonly=True) as db: - changed = set(db.changed()) + changed = set(db.changed_files(fs)) if not all(f in db for f in input_file_list): return tangle @@ -27,17 +28,17 @@ def sync_action() -> Optional[Callable[[], None]]: if not changed: return None - if changed.isdisjoint(db.managed): + if changed.isdisjoint(db.managed_files): logging.info("Tangling") return tangle - if changed.issubset(db.managed): + if changed.issubset(db.managed_files): logging.info("Stitching") return _stitch_then_tangle logging.error("changed: %s", [str(p) for p in changed]) logging.error( - "Both markdown and code seem to have changed. " "Don't know what to do now." + "Both markdown and code seem to have changed, don't know what to do now." ) return None diff --git a/entangled/commands/tangle.py b/entangled/commands/tangle.py index d8b13c0..4151345 100644 --- a/entangled/commands/tangle.py +++ b/entangled/commands/tangle.py @@ -5,7 +5,7 @@ from ..document import ReferenceMap from ..config import config, AnnotationMethod, get_input_files -from ..transaction import transaction, TransactionMode +from ..io import transaction, TransactionMode from ..hooks import get_hooks from ..errors.user import UserError diff --git a/entangled/config/__init__.py b/entangled/config/__init__.py index ef2c957..cf6bc01 100644 --- a/entangled/config/__init__.py +++ b/entangled/config/__init__.py @@ -87,7 +87,7 @@ class Config(Struct, dict=True): annotation: AnnotationMethod = AnnotationMethod.STANDARD use_line_directives: bool = False hooks: list[str] = field(default_factory=lambda: ["shebang"]) - hook: dict[str, Any] = field(default_factory=dict) + hook: dict[str, Any] = field(default_factory=dict) # pyright: ignore[reportExplicitAny] brei: Program = field(default_factory=Program) language_index: dict[str, Language] = field(default_factory=dict) @@ -127,10 +127,10 @@ def read_config_from_toml( return None try: with open(path, "rb") as f: - json: Any = tomllib.load(f) + json: Any = tomllib.load(f) # pyright: ignore[reportExplicitAny] if section is not None: for s in section.split("."): - json = json[s] + json = json[s] # pyright: ignore[reportAny] return msgspec.convert(json, type=Config, dec_hook=from_str.dec_hook) except ValueError as e: @@ -163,7 +163,7 @@ def read(self, force: bool = False): @property def get(self) -> Config: if self.config is None: - raise ValueError(f"No config loaded.") + raise ValueError("No config loaded.") return self.config @contextmanager @@ -180,7 +180,7 @@ def __call__(self, **kwargs): def get_language(self, lang_name: str) -> Language | None: if self.config is None: - raise ValueError(f"No config loaded.") + raise ValueError("No config loaded.") return self.config.language_index.get(lang_name, None) diff --git a/entangled/document.py b/entangled/document.py index 827ce60..af60674 100644 --- a/entangled/document.py +++ b/entangled/document.py @@ -125,8 +125,10 @@ def content_to_text(r: ReferenceMap, c: Content) -> str: A string, usually not terminated by a newline. """ match c: - case PlainText(s): return s - case ReferenceId(): return r.get_codeblock(c).indented_text + case PlainText(s): + return s + case ReferenceId(): + return r.get_codeblock(c).indented_text def document_to_text(r: ReferenceMap, cs: Iterable[Content]) -> str: diff --git a/entangled/hooks/base.py b/entangled/hooks/base.py index 897ae1d..975ff27 100644 --- a/entangled/hooks/base.py +++ b/entangled/hooks/base.py @@ -3,7 +3,7 @@ from msgspec import Struct from ..document import ReferenceMap, CodeBlock -from ..transaction import Transaction +from ..io import Transaction @dataclass @@ -26,23 +26,23 @@ def check_prerequisites(self): """When prerequisites aren't met, raise PrerequisitesFailed.""" pass - def on_read(self, code: CodeBlock): + def on_read(self, code: CodeBlock): # pyright: ignore[reportUnusedParameter] """Called when the Markdown is being read, before the assembling of the reference map.""" pass - def pre_tangle(self, refs: ReferenceMap): + def pre_tangle(self, refs: ReferenceMap): # pyright: ignore[reportUnusedParameter] """Executed after reading Markdown, but before actually tangling files. This allows the opportunity to add more targets to the reference map. """ pass - def on_tangle(self, t: Transaction, refs: ReferenceMap): + def on_tangle(self, t: Transaction, refs: ReferenceMap): # pyright: ignore[reportUnusedParameter] """Executed after other targets were tangled, but during the same transaction. If you want to write a file, consider doing so as part of the transaction.""" pass - def post_tangle(self, refs: ReferenceMap): + def post_tangle(self, refs: ReferenceMap): # pyright: ignore[reportUnusedParameter] """Called after the tangle transaction is finished.""" pass diff --git a/entangled/hooks/build.py b/entangled/hooks/build.py index b9dfd65..925a2e2 100644 --- a/entangled/hooks/build.py +++ b/entangled/hooks/build.py @@ -97,7 +97,7 @@ def pre_tangle(self, refs: ReferenceMap): refs.index[script_file_name].append(ref) refs.targets.add(script_file_name) - deps = (get_attribute(cb.properties, "deps") or "").split() + deps = [str(s) for s in (get_attribute(cb.properties, "deps") or "").split()] self.recipes.append(Hook.Recipe(target, deps, cb.language, script_file_name)) @override diff --git a/entangled/hooks/repl.py b/entangled/hooks/repl.py index fa6557f..2333a36 100644 --- a/entangled/hooks/repl.py +++ b/entangled/hooks/repl.py @@ -10,7 +10,7 @@ from pathlib import Path from ..logging import logger -from ..transaction import Transaction +from ..io import Transaction from ..document import CodeBlock, ReferenceMap from ..properties import Class, get_attribute, get_id diff --git a/entangled/hooks/task.py b/entangled/hooks/task.py index 10d71d5..b2708e2 100644 --- a/entangled/hooks/task.py +++ b/entangled/hooks/task.py @@ -6,7 +6,7 @@ from typing import Any, final, override from ..config import AnnotationMethod -from ..transaction import Transaction +from ..io import Transaction from ..document import CodeBlock, ReferenceId, ReferenceMap from ..properties import Class, Property, get_attribute, get_classes diff --git a/entangled/io/__init__.py b/entangled/io/__init__.py index e69de29..46dd297 100644 --- a/entangled/io/__init__.py +++ b/entangled/io/__init__.py @@ -0,0 +1,11 @@ +""" +In Entangled all file IO should pass through a transaction. +""" + + +from .transaction import transaction, Transaction, TransactionMode +from .filedb import filedb +from .virtual import FileCache + + +__all__ = ["FileCache", "filedb", "Transaction", "TransactionMode", "transaction"] diff --git a/entangled/io/filedb.py b/entangled/io/filedb.py index fcb38c6..355ca07 100644 --- a/entangled/io/filedb.py +++ b/entangled/io/filedb.py @@ -1,4 +1,5 @@ from __future__ import annotations +from collections.abc import Generator from contextlib import contextmanager from pathlib import Path @@ -12,7 +13,7 @@ from ..version import __version__ from ..utility import normal_relative, ensure_parent from .virtual import FileCache -from .stat import Stat +from .stat import Stat, hexdigest class FileDB(Struct): @@ -44,6 +45,10 @@ def managed_files(self) -> set[Path]: the markdown, so is considered to be managed.""" return {Path(p) for p in self.targets} + def changed_files(self, fs: FileCache) -> Generator[Path]: + return (Path(p) for p, known_stat in self.files.items() + if fs[Path(p)].stat != known_stat) + def create_target(self, fs: FileCache, path: Path): path = normal_relative(path) self.update(fs, path) @@ -69,7 +74,7 @@ def __iter__(self): return (Path(p) for p in self.files) def check(self, path: Path, content: str) -> bool: - return hexdigest(content) == self.files[str(path)].hexdigest + return hexdigest(content) == self.files[path.as_posix()].hexdigest FILEDB_PATH = Path(".") / ".entangled" / "filedb.json" @@ -78,7 +83,7 @@ def check(self, path: Path, content: str) -> bool: def read_filedb() -> FileDB: if not FILEDB_PATH.exists(): - return FileDB(__version__, {}, set(), set()) + return FileDB(__version__, {}, set()) logging.debug("Reading FileDB") db = msgspec.json.decode(FILEDB_PATH.open("br").read(), type=FileDB) diff --git a/entangled/io/transaction.py b/entangled/io/transaction.py index 50168b4..38dfc57 100644 --- a/entangled/io/transaction.py +++ b/entangled/io/transaction.py @@ -1,42 +1,19 @@ -from abc import ABC, ABCMeta, abstractmethod +from abc import ABCMeta, abstractmethod from dataclasses import dataclass, field from functools import cached_property from pathlib import Path from contextlib import contextmanager from enum import Enum -import os -import tempfile import logging from typing import override -from .utility import cat_maybes -from .filedb import FileDB, FileStat, stat, filedb, hexdigest -from .errors.internal import InternalError +from ..utility import cat_maybes +from ..errors.internal import InternalError - -def assure_final_newline(s: str) -> str: - if s[-1] != "\n": - return s + "\n" - else: - return s - - -def atomic_write(target: Path, content: str, mode: int | None): - """ - Writes a file by first writing to a temporary location and then moving - the file to the target path. - """ - tmp_dir = Path() / ".entangled" / "tmp" - tmp_dir.mkdir(exist_ok=True, parents=True) - with tempfile.NamedTemporaryFile(mode="w", delete=False, dir=tmp_dir) as f: - _ = f.write(assure_final_newline(content)) - # Flush and sync contents to disk - f.flush() - if mode is not None: - os.chmod(f.name, mode) - os.fsync(f.fileno()) - os.replace(f.name, target) +from .stat import Stat, hexdigest +from .virtual import FileCache +from .filedb import FileDB, filedb @dataclass(frozen=True) @@ -54,68 +31,57 @@ class Action(metaclass=ABCMeta): target: Path @abstractmethod - def conflict(self, db: FileDB) -> Conflict | None: + def conflict(self, fs: FileCache, db: FileDB) -> Conflict | None: """Indicate wether the action might have conflicts. This could be inconsistency in the modification times of files, or overwriting a file that is not managed by Entangled.""" ... @abstractmethod - def add_to_db(self, db: FileDB): + def add_to_db(self, fs: FileCache, db: FileDB): """Only perform the corresponding database action.""" ... @abstractmethod - def run(self, db: FileDB): + def run(self, fs: FileCache): """Run the action, if `interact` is `True` then confirmation is asked in case of a conflict.""" ... - @cached_property - def target_stat(self) -> FileStat | None: - if not self.target.exists(): + def target_stat(self, fs: FileCache) -> Stat | None: + if self.target not in fs: return None - return stat(self.target) + return fs[self.target].stat @dataclass(frozen=True) class WriterBase(Action, metaclass=ABCMeta): content: str - sources: list[Path] mode: int | None + sources: list[Path] @cached_property def content_digest(self) -> str: return hexdigest(self.content) @override - def add_to_db(self, db: FileDB): - db.update_target(self.target, self.sources) - - @override - def run(self, db: FileDB): - self.target.parent.mkdir(parents=True, exist_ok=True) - atomic_write(self.target, self.content, self.mode) - self.add_to_db(db) + def run(self, fs: FileCache): + fs.write(self.target, self.content, self.mode) class Create(WriterBase): @override - def conflict(self, db: FileDB) -> Conflict | None: - if not self.sources: - logging.warning(f"Creating file `{self.target}` but no sources listed.") - - if self.target.exists(): - # Check if file contents are the same as what we want to write or is empty - # then it is safe to take ownership. - md_stat = stat(self.target) - file_digest = md_stat.hexdigest - content_digest = hexdigest(self.content) - if (content_digest == file_digest) or (md_stat.size == 0): + def conflict(self, fs: FileCache, db: FileDB) -> Conflict | None: + if self.target in fs: + if (self.content_digest == fs[self.target].stat.hexdigest): return None return Conflict(self.target, "not managed by Entangled") return None + @override + def add_to_db(self, fs: FileCache, db: FileDB): + return db.create_target(fs, self.target) + @override def __str__(self): return f"create `{self.target}`" @@ -123,15 +89,20 @@ def __str__(self): class Write(WriterBase): @override - def conflict(self, db: FileDB) -> Conflict | None: - if self.target_stat != db[self.target]: + def conflict(self, fs: FileCache, db: FileDB) -> Conflict | None: + if self.target not in fs: + return None + if fs[self.target].stat != db[self.target]: return Conflict(self.target, "changed outside the control of Entangled") - if self.sources and self.target_stat: - newest_src = max(stat(s) for s in self.sources) - if self.target_stat > newest_src: - return Conflict(self.target, f"newer than `{newest_src.path}`") + if self.sources: + if all(fs[s].stat < fs[self.target].stat for s in self.sources): + return Conflict(self.target, "newer than sources: " + ", ".join(f"`{s}`" for s in self.sources)) return None + @override + def add_to_db(self, fs: FileCache, db: FileDB): + db.update(fs, self.target) + @override def __str__(self): return f"write `{self.target}`" @@ -139,23 +110,18 @@ def __str__(self): class Delete(Action): @override - def conflict(self, db: FileDB) -> Conflict | None: - if self.target_stat != db[self.target]: + def conflict(self, fs: FileCache, db: FileDB) -> Conflict | None: + if fs[self.target].stat != db[self.target]: return Conflict(self.target, "changed outside the control of Entangled") return None @override - def add_to_db(self, db: FileDB): + def add_to_db(self, fs: FileCache, db: FileDB): del db[self.target] @override - def run(self, db: FileDB): - self.target.unlink() - parent = self.target.parent - while list(parent.iterdir()) == []: - parent.rmdir() - parent = parent.parent - self.add_to_db(db) + def run(self, fs: FileCache): + del fs[self.target] @override def __str__(self): @@ -164,7 +130,13 @@ def __str__(self): @dataclass class Transaction: + """ + Collects a set of file mutations, checking for consistency. All file IO outside of + the `entangled.io` module should pass through this class, used with the context + manager function `transaction`. + """ db: FileDB + fs: FileCache = field(default_factory=FileCache) updates: list[Path] = field(default_factory=list) actions: list[Action] = field(default_factory=list) passed: set[Path] = field(default_factory=set) @@ -178,15 +150,15 @@ def write(self, path: Path, content: str, sources: list[Path], mode: int | None self.passed.add(path) if path not in self.db: logging.debug("creating target `%s`", path) - self.actions.append(Create(path, content, sources, mode)) + self.actions.append(Create(path, content, mode, sources)) elif not self.db.check(path, content): logging.debug("target `%s` changed", path) - self.actions.append(Write(path, content, sources, mode)) + self.actions.append(Write(path, content, mode, sources)) else: logging.debug("target `%s` unchanged", path) def clear_orphans(self): - orphans = self.db.managed - self.passed + orphans = self.db.managed_files - self.passed if not orphans: return @@ -195,10 +167,10 @@ def clear_orphans(self): self.actions.append(Delete(p)) def check_conflicts(self) -> list[Conflict]: - return list(cat_maybes(a.conflict(self.db) for a in self.actions)) + return list(cat_maybes(a.conflict(self.fs, self.db) for a in self.actions)) def all_ok(self) -> bool: - return all(a.conflict(self.db) is None for a in self.actions) + return all(a.conflict(self.fs, self.db) is None for a in self.actions) def print_plan(self): if not self.actions: @@ -210,18 +182,28 @@ def print_plan(self): def run(self): for a in self.actions: - a.run(self.db) + a.run(self.fs) + a.add_to_db(self.fs, self.db) for f in self.updates: - self.db.update(f) + self.db.update(self.fs, f) def updatedb(self): for a in self.actions: - a.add_to_db(self.db) + a.add_to_db(self.fs, self.db) for f in self.updates: - self.db.update(f) + self.db.update(self.fs, f) class TransactionMode(Enum): + """ + Selects the mode of transaction: + + - `SHOW` only show what would be done + - `FAIL` fail with an error message if any conflicts are found + - `CONFIRM` in the evennt of conflicts, ask the user for confirmation + - `FORCE` print a warning on conflicts but execute anyway + - `RESETDB` recreate the filedb in case it got corrupted + """ SHOW = 1 FAIL = 2 CONFIRM = 3 diff --git a/entangled/io/virtual.py b/entangled/io/virtual.py index af8ffee..7a3f452 100644 --- a/entangled/io/virtual.py +++ b/entangled/io/virtual.py @@ -4,14 +4,53 @@ from dataclasses import dataclass, field from pathlib import Path -from .stat import stat, FileData + +import os +import tempfile + +from .stat import hexdigest, stat, FileData + + +def assure_final_newline(s: str) -> str: + if s[-1] != "\n": + return s + "\n" + else: + return s + + +def atomic_write(target: Path, content: str, mode: int | None): + """ + Writes a file by first writing to a temporary location and then moving + the file to the target path. + """ + tmp_dir = Path() / ".entangled" / "tmp" + tmp_dir.mkdir(exist_ok=True, parents=True) + with tempfile.NamedTemporaryFile(mode="w", delete=False, dir=tmp_dir) as f: + _ = f.write(assure_final_newline(content)) + # Flush and sync contents to disk + f.flush() + if mode is not None: + os.chmod(f.name, mode) + os.fsync(f.fileno()) + os.replace(f.name, target) @dataclass class FileCache: + """ + A virtualization layer between the file system and the rest of Entangled. This is to + give file IO a cleaner semantics, and also to cache reading file contents and computing + digests. + + This acts as a mapping from `Path` to `FileData`. Removing items actually deletes files. + """ _data: dict[Path, FileData] = field(default_factory=dict) def __getitem__(self, key: Path) -> FileData: + """ + Get `FileData` belonging to given `Path`. The data is cached inbetween calls. + If you expect data to have changed, you should first `reset` the cache. + """ if key not in self._data: if (s := stat(key)) is None: raise KeyError() @@ -19,7 +58,42 @@ def __getitem__(self, key: Path) -> FileData: return self._data[key] def __contains__(self, key: Path) -> bool: + """ + Check that a file exists. + """ return key.exists() + def __delitem__(self, key: Path): + """ + Remove a file. If its parent directories are empty, these are also + removed. + """ + key.unlink() + parent = key.parent + while list(parent.iterdir()) == []: + parent.rmdir() + parent = parent.parent + if key in self._data: + del self._data[key] + + def write(self, key: Path, new_content: str, mode: int | None = None): + """ + Write contents to a file. If `new_content` has the same digest as the known + contents, nothing is done. The entry is removed from the cache afterward. + + Nothing is done to prevent overwriting an existing file. + """ + if key in self: + new_digest = hexdigest(new_content) + if new_digest == self[key].stat.hexdigest: + return + del self._data[key] + + key.parent.mkdir(parents=True, exist_ok=True) + atomic_write(key, new_content, mode) + def reset(self): + """ + Reset the cache. Doesn't perform any IO. + """ self._data = {} diff --git a/entangled/logging.py b/entangled/logging.py index 2d0e347..9f43c5b 100644 --- a/entangled/logging.py +++ b/entangled/logging.py @@ -6,7 +6,7 @@ from .version import __version__ -LOGGING_SETUP = False +logging_setup = False class BackTickHighlighter(RegexHighlighter): @@ -27,8 +27,8 @@ def logger(): def configure(debug: bool = False): - global LOGGING_SETUP - if LOGGING_SETUP: + global logging_setup + if logging_setup: return if debug: @@ -49,4 +49,4 @@ def configure(debug: bool = False): # log.propagate = False log.debug(f"Entangled {__version__} (https://entangled.github.io/)") - LOGGING_SETUP = True + logging_setup = True diff --git a/entangled/status.py b/entangled/status.py index cb28d20..279b4cb 100644 --- a/entangled/status.py +++ b/entangled/status.py @@ -1,6 +1,6 @@ from collections.abc import Iterable from .config import config -from .filedb import filedb +from .io import filedb from itertools import chain from pathlib import Path @@ -22,7 +22,7 @@ def find_watch_dirs(): input_file_list = list_input_files() markdown_dirs = set(p.parent for p in input_file_list) with filedb(readonly=True) as db: - code_dirs = set(p.parent for p in db.managed) + code_dirs = set(p.parent for p in db.managed_files) return code_dirs.union(markdown_dirs) @@ -37,5 +37,5 @@ def list_input_files(): def list_dependent_files(): with filedb(readonly=True) as db: - result = list(db.managed) + result = list(db.managed_files) return result diff --git a/entangled/tangle.py b/entangled/tangle.py index 0f954da..9c04c37 100644 --- a/entangled/tangle.py +++ b/entangled/tangle.py @@ -3,6 +3,7 @@ from textwrap import indent from contextlib import contextmanager from copy import copy +from pathlib import PurePath import re import mawk @@ -43,14 +44,14 @@ class Tangler(mawk.RuleSet): ref: ReferenceId init: bool visited: Visitor[str] - deps: set[str] = field(init=False) + deps: set[PurePath] = field(init=False) cb: CodeBlock = field(init=False) location: TextLocation = field(init=False) def __post_init__(self): - self.cb = self.refs[self.ref] + self.cb = self.refs.get_codeblock(self.ref) self.location = copy(self.cb.origin) - self.deps = set((self.cb.origin.filename,)) + self.deps = { self.cb.origin.filename } @mawk.always def lineno(self, _): @@ -105,6 +106,7 @@ def on_begin(self) -> list[str]: @override def on_eof(self): + assert self.cb.language return [f"{self.cb.language.comment.open} ~/~ end{self.close_comment}"] @@ -120,7 +122,7 @@ def tangle_ref( ref_name: str, annotation: type[Tangler] | AnnotationMethod | None = None, _visited: Visitor[str] | None = None, -) -> tuple[str, set[str]]: +) -> tuple[str, set[PurePath]]: if annotation is None: annotation = config.get.annotation @@ -141,7 +143,7 @@ def tangle_ref( with v.visit(ref_name): init = True result: list[str] = [] - deps: set[str] = set() + deps: set[PurePath] = set() for ref in refs.index[ref_name]: t = tangler(refs, ref, init, v) result.append(t.tangle()) diff --git a/test/test_config.py b/test/test_config.py index d1d2264..369172a 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -52,7 +52,7 @@ def test_new_language(tmp_path): tangle() sleep(0.1) assert Path("test.fish").exists() - assert Path("test.fish").read_text() == "echo hello world" + assert Path("test.fish").read_text() == "echo hello world\n" config_with_more = """ diff --git a/test/test_daemon.py b/test/test_daemon.py index 8bef716..3ad81f5 100644 --- a/test/test_daemon.py +++ b/test/test_daemon.py @@ -6,7 +6,7 @@ import sys from entangled.config import config -from entangled.filedb import stat +from entangled.io.stat import stat from entangled.commands.watch import _watch from entangled.logging import configure @@ -44,11 +44,11 @@ def wait_for_stat_diff(md_stat, filename, timeout=5): def test_daemon(tmp_path: Path): config.read(force=True) with chdir(tmp_path): + configure(debug=True) + stop = threading.Event() + start = threading.Event() + t = threading.Thread(target=_watch, args=(stop, start)) try: - configure(debug=True) - stop = threading.Event() - start = threading.Event() - t = threading.Thread(target=_watch, args=(stop, start)) t.start() # Wait for watch to boot up start.wait() @@ -57,6 +57,7 @@ def test_daemon(tmp_path: Path): ) wait_for_file("main.md") md_stat1 = stat(Path("main.md")) + assert md_stat1 wait_for_file("hello.scm") assert Path("hello.scm").exists() @@ -66,7 +67,8 @@ def test_daemon(tmp_path: Path): Path("hello.scm").write_text("\n".join(lines)) assert wait_for_stat_diff(md_stat1, "main.md") md_stat2 = stat(Path("main.md")) - assert md_stat1 < md_stat2 + assert md_stat2 + assert md_stat1.stat < md_stat2.stat lines = Path("main.md").read_text().splitlines() print(lines) diff --git a/test/test_filedb.py b/test/test_filedb.py index fdcc616..bfb46ba 100644 --- a/test/test_filedb.py +++ b/test/test_filedb.py @@ -1,9 +1,12 @@ -from entangled.filedb import filedb, stat +from entangled.io.stat import stat +from entangled.io.filedb import filedb from time import sleep from pathlib import Path import pytest from contextlib import chdir +from entangled.io.virtual import FileCache + @pytest.fixture(scope="session") def example_files(tmp_path_factory: pytest.TempPathFactory): @@ -24,23 +27,26 @@ def example_files(tmp_path_factory: pytest.TempPathFactory): def test_stat(example_files: Path): with chdir(example_files): stat_a = stat(example_files / "a") + assert stat_a stat_b = stat(example_files / "b") + assert stat_b stat_c = stat(example_files / "c") - assert stat_a == stat_b - assert stat_c != stat_b - assert stat_a < stat_b + assert stat_c + assert stat_a.stat == stat_b.stat + assert stat_c.stat != stat_b.stat + assert stat_a.stat < stat_b.stat def test_filedb(example_files: Path): with chdir(example_files): + fs = FileCache() with filedb() as db: for n in "abcd": - db.update(Path(n)) + db.update(fs, Path(n)) - with open(example_files / "d", "w") as f: - f.write("mars") + fs.write(Path("d"), "mars") with filedb() as db: - assert db.changed() == [Path("d")] - db.update(Path("d")) - assert db.changed() == [] + assert list(db.changed_files(fs)) == [Path("d")] + db.update(fs, Path("d")) + assert list(db.changed_files(fs)) == [] diff --git a/test/test_modes.py b/test/test_modes.py index 9f8ce2b..eb9ffc0 100644 --- a/test/test_modes.py +++ b/test/test_modes.py @@ -1,7 +1,7 @@ from contextlib import chdir from entangled.commands import tangle, stitch from entangled.config import config -from entangled.filedb import stat +from entangled.io.stat import stat from entangled.errors.user import UserError import pytest @@ -24,41 +24,48 @@ def test_modes(tmp_path: Path): target = tmp_path / "src" / "hello.py" assert target.exists() hello_stat1 = stat(target) + assert hello_stat1 hello_src = target.read_text().splitlines() assert hello_src[1] == 'print("hello")' md.write_text("``` {.python file=src/hello.py}\n" 'print("goodbye")\n' "```\n") sleep(0.1) md_stat1 = stat(md) + assert md_stat1 tangle(show=True) sleep(0.1) hello_stat2 = stat(target) - assert hello_stat2 == hello_stat1 - assert not (hello_stat2 > hello_stat1) + assert hello_stat2 + assert hello_stat2.stat == hello_stat1.stat + assert not (hello_stat2.stat > hello_stat1.stat) hello_src[1] = 'print("bonjour")' (tmp_path / "src" / "hello.py").write_text("\n".join(hello_src)) sleep(0.1) hello_stat1 = stat(target) + assert hello_stat1 # with pytest.raises(UserError): tangle() sleep(0.1) hello_stat2 = stat(target) - assert hello_stat2 == hello_stat1 - assert not (hello_stat2 > hello_stat1) + assert hello_stat2 + assert hello_stat2.stat == hello_stat1.stat + assert not (hello_stat2.stat > hello_stat1.stat) # with pytest.raises(UserError): stitch() sleep(0.1) md_stat2 = stat(md) + assert md_stat2 print(md.read_text()) - assert md_stat1 == md_stat2 - assert not (md_stat2 > md_stat1) + assert md_stat1.stat == md_stat2.stat + assert not (md_stat2.stat > md_stat1.stat) stitch(force=True) sleep(0.1) md_stat2 = stat(md) - assert md_stat1 != md_stat2 - assert md_stat2 > md_stat1 + assert md_stat2 + assert md_stat1.stat != md_stat2.stat + assert md_stat2.stat > md_stat1.stat diff --git a/test/test_transaction.py b/test/test_transaction.py index c1a8d79..816847d 100644 --- a/test/test_transaction.py +++ b/test/test_transaction.py @@ -1,12 +1,15 @@ from contextlib import chdir from pathlib import Path -from entangled.transaction import Transaction, Create, Write, Delete -from entangled.filedb import filedb +from entangled.io.transaction import Transaction, Create, Write, Delete +from entangled.io.filedb import filedb +from entangled.io.virtual import FileCache def test_transaction(tmp_path: Path): with chdir(tmp_path): + fs = FileCache() + with filedb() as db: t = Transaction(db) t.write(Path("a"), "hello", []) @@ -20,10 +23,11 @@ def test_transaction(tmp_path: Path): with open(Path("a"), "w") as f: _ = f.write("ciao") + fs.reset() with filedb() as db: assert Path("a") in db assert Path("b") in db - assert list(db.changed()) == [Path("a")] + assert list(db.changed_files(fs)) == [Path("a")] t = Transaction(db) t.write(Path("b"), "goodbye", []) diff --git a/uv.lock b/uv.lock index f1a9b28..cdf73fc 100644 --- a/uv.lock +++ b/uv.lock @@ -88,44 +88,59 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, - { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, - { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, - { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, - { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, - { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, - { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, - { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, - { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, - { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] @@ -394,11 +409,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] @@ -862,7 +877,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -870,72 +885,76 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/da/b8a7ee04378a53f6fefefc0c5e05570a3ebfdfa0523a878bcd3b475683ee/pydantic-2.12.0.tar.gz", hash = "sha256:c1a077e6270dbfb37bfd8b498b3981e2bb18f68103720e51fa6c306a5a9af563", size = 814760, upload-time = "2025-10-07T15:58:03.467Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/a7/d0d7b3c128948ece6676a6a21b9036e3ca53765d35052dbcc8c303886a44/pydantic-2.12.1.tar.gz", hash = "sha256:0af849d00e1879199babd468ec9db13b956f6608e9250500c1a9d69b6a62824e", size = 815997, upload-time = "2025-10-13T21:00:41.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/9d/d5c855424e2e5b6b626fbc6ec514d8e655a600377ce283008b115abb7445/pydantic-2.12.0-py3-none-any.whl", hash = "sha256:f6a1da352d42790537e95e83a8bdfb91c7efbae63ffd0b86fa823899e807116f", size = 459730, upload-time = "2025-10-07T15:58:01.576Z" }, + { url = "https://files.pythonhosted.org/packages/f5/69/ce4e60e5e67aa0c339a5dc3391a02b4036545efb6308c54dc4aa9425386f/pydantic-2.12.1-py3-none-any.whl", hash = "sha256:665931f5b4ab40c411439e66f99060d631d1acc58c3d481957b9123343d674d1", size = 460511, upload-time = "2025-10-13T21:00:38.935Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.1" +version = "2.41.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/14/12b4a0d2b0b10d8e1d9a24ad94e7bbb43335eaf29c0c4e57860e8a30734a/pydantic_core-2.41.1.tar.gz", hash = "sha256:1ad375859a6d8c356b7704ec0f547a58e82ee80bb41baa811ad710e124bc8f2f", size = 454870, upload-time = "2025-10-07T10:50:45.974Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/bc/5f520319ee1c9e25010412fac4154a72e0a40d0a19eb00281b1f200c0947/pydantic_core-2.41.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:db2f82c0ccbce8f021ad304ce35cbe02aa2f95f215cac388eed542b03b4d5eb4", size = 2099300, upload-time = "2025-10-06T21:10:30.463Z" }, - { url = "https://files.pythonhosted.org/packages/31/14/010cd64c5c3814fb6064786837ec12604be0dd46df3327cf8474e38abbbd/pydantic_core-2.41.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47694a31c710ced9205d5f1e7e8af3ca57cbb8a503d98cb9e33e27c97a501601", size = 1910179, upload-time = "2025-10-06T21:10:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/8e/2e/23fc2a8a93efad52df302fdade0a60f471ecc0c7aac889801ac24b4c07d6/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e9decce94daf47baf9e9d392f5f2557e783085f7c5e522011545d9d6858e00", size = 1957225, upload-time = "2025-10-06T21:10:33.11Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b6/6db08b2725b2432b9390844852e11d320281e5cea8a859c52c68001975fa/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab0adafdf2b89c8b84f847780a119437a0931eca469f7b44d356f2b426dd9741", size = 2053315, upload-time = "2025-10-06T21:10:34.87Z" }, - { url = "https://files.pythonhosted.org/packages/61/d9/4de44600f2d4514b44f3f3aeeda2e14931214b6b5bf52479339e801ce748/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5da98cc81873f39fd56882e1569c4677940fbc12bce6213fad1ead784192d7c8", size = 2224298, upload-time = "2025-10-06T21:10:36.233Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ae/dbe51187a7f35fc21b283c5250571a94e36373eb557c1cba9f29a9806dcf/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:209910e88afb01fd0fd403947b809ba8dba0e08a095e1f703294fda0a8fdca51", size = 2351797, upload-time = "2025-10-06T21:10:37.601Z" }, - { url = "https://files.pythonhosted.org/packages/b5/a7/975585147457c2e9fb951c7c8dab56deeb6aa313f3aa72c2fc0df3f74a49/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365109d1165d78d98e33c5bfd815a9b5d7d070f578caefaabcc5771825b4ecb5", size = 2074921, upload-time = "2025-10-06T21:10:38.927Z" }, - { url = "https://files.pythonhosted.org/packages/62/37/ea94d1d0c01dec1b7d236c7cec9103baab0021f42500975de3d42522104b/pydantic_core-2.41.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:706abf21e60a2857acdb09502bc853ee5bce732955e7b723b10311114f033115", size = 2187767, upload-time = "2025-10-06T21:10:40.651Z" }, - { url = "https://files.pythonhosted.org/packages/d3/fe/694cf9fdd3a777a618c3afd210dba7b414cb8a72b1bd29b199c2e5765fee/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bf0bd5417acf7f6a7ec3b53f2109f587be176cb35f9cf016da87e6017437a72d", size = 2136062, upload-time = "2025-10-06T21:10:42.09Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/174aeabd89916fbd2988cc37b81a59e1186e952afd2a7ed92018c22f31ca/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2e71b1c6ceb9c78424ae9f63a07292fb769fb890a4e7efca5554c47f33a60ea5", size = 2317819, upload-time = "2025-10-06T21:10:43.974Z" }, - { url = "https://files.pythonhosted.org/packages/65/e8/e9aecafaebf53fc456314f72886068725d6fba66f11b013532dc21259343/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:80745b9770b4a38c25015b517451c817799bfb9d6499b0d13d8227ec941cb513", size = 2312267, upload-time = "2025-10-06T21:10:45.34Z" }, - { url = "https://files.pythonhosted.org/packages/35/2f/1c2e71d2a052f9bb2f2df5a6a05464a0eb800f9e8d9dd800202fe31219e1/pydantic_core-2.41.1-cp312-cp312-win32.whl", hash = "sha256:83b64d70520e7890453f1aa21d66fda44e7b35f1cfea95adf7b4289a51e2b479", size = 1990927, upload-time = "2025-10-06T21:10:46.738Z" }, - { url = "https://files.pythonhosted.org/packages/b1/78/562998301ff2588b9c6dcc5cb21f52fa919d6e1decc75a35055feb973594/pydantic_core-2.41.1-cp312-cp312-win_amd64.whl", hash = "sha256:377defd66ee2003748ee93c52bcef2d14fde48fe28a0b156f88c3dbf9bc49a50", size = 2034703, upload-time = "2025-10-06T21:10:48.524Z" }, - { url = "https://files.pythonhosted.org/packages/b2/53/d95699ce5a5cdb44bb470bd818b848b9beadf51459fd4ea06667e8ede862/pydantic_core-2.41.1-cp312-cp312-win_arm64.whl", hash = "sha256:c95caff279d49c1d6cdfe2996e6c2ad712571d3b9caaa209a404426c326c4bde", size = 1972719, upload-time = "2025-10-06T21:10:50.256Z" }, - { url = "https://files.pythonhosted.org/packages/27/8a/6d54198536a90a37807d31a156642aae7a8e1263ed9fe6fc6245defe9332/pydantic_core-2.41.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70e790fce5f05204ef4403159857bfcd587779da78627b0babb3654f75361ebf", size = 2105825, upload-time = "2025-10-06T21:10:51.719Z" }, - { url = "https://files.pythonhosted.org/packages/4f/2e/4784fd7b22ac9c8439db25bf98ffed6853d01e7e560a346e8af821776ccc/pydantic_core-2.41.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9cebf1ca35f10930612d60bd0f78adfacee824c30a880e3534ba02c207cceceb", size = 1910126, upload-time = "2025-10-06T21:10:53.145Z" }, - { url = "https://files.pythonhosted.org/packages/f3/92/31eb0748059ba5bd0aa708fb4bab9fcb211461ddcf9e90702a6542f22d0d/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:170406a37a5bc82c22c3274616bf6f17cc7df9c4a0a0a50449e559cb755db669", size = 1961472, upload-time = "2025-10-06T21:10:55.754Z" }, - { url = "https://files.pythonhosted.org/packages/ab/91/946527792275b5c4c7dde4cfa3e81241bf6900e9fee74fb1ba43e0c0f1ab/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12d4257fc9187a0ccd41b8b327d6a4e57281ab75e11dda66a9148ef2e1fb712f", size = 2063230, upload-time = "2025-10-06T21:10:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/31/5d/a35c5d7b414e5c0749f1d9f0d159ee2ef4bab313f499692896b918014ee3/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a75a33b4db105dd1c8d57839e17ee12db8d5ad18209e792fa325dbb4baeb00f4", size = 2229469, upload-time = "2025-10-06T21:10:59.409Z" }, - { url = "https://files.pythonhosted.org/packages/21/4d/8713737c689afa57ecfefe38db78259d4484c97aa494979e6a9d19662584/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08a589f850803a74e0fcb16a72081cafb0d72a3cdda500106942b07e76b7bf62", size = 2347986, upload-time = "2025-10-06T21:11:00.847Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ec/929f9a3a5ed5cda767081494bacd32f783e707a690ce6eeb5e0730ec4986/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a97939d6ea44763c456bd8a617ceada2c9b96bb5b8ab3dfa0d0827df7619014", size = 2072216, upload-time = "2025-10-06T21:11:02.43Z" }, - { url = "https://files.pythonhosted.org/packages/26/55/a33f459d4f9cc8786d9db42795dbecc84fa724b290d7d71ddc3d7155d46a/pydantic_core-2.41.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2ae423c65c556f09569524b80ffd11babff61f33055ef9773d7c9fabc11ed8d", size = 2193047, upload-time = "2025-10-06T21:11:03.787Z" }, - { url = "https://files.pythonhosted.org/packages/77/af/d5c6959f8b089f2185760a2779079e3c2c411bfc70ea6111f58367851629/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:4dc703015fbf8764d6a8001c327a87f1823b7328d40b47ce6000c65918ad2b4f", size = 2140613, upload-time = "2025-10-06T21:11:05.607Z" }, - { url = "https://files.pythonhosted.org/packages/58/e5/2c19bd2a14bffe7fabcf00efbfbd3ac430aaec5271b504a938ff019ac7be/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:968e4ffdfd35698a5fe659e5e44c508b53664870a8e61c8f9d24d3d145d30257", size = 2327641, upload-time = "2025-10-06T21:11:07.143Z" }, - { url = "https://files.pythonhosted.org/packages/93/ef/e0870ccda798c54e6b100aff3c4d49df5458fd64217e860cb9c3b0a403f4/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:fff2b76c8e172d34771cd4d4f0ade08072385310f214f823b5a6ad4006890d32", size = 2318229, upload-time = "2025-10-06T21:11:08.73Z" }, - { url = "https://files.pythonhosted.org/packages/b1/4b/c3b991d95f5deb24d0bd52e47bcf716098fa1afe0ce2d4bd3125b38566ba/pydantic_core-2.41.1-cp313-cp313-win32.whl", hash = "sha256:a38a5263185407ceb599f2f035faf4589d57e73c7146d64f10577f6449e8171d", size = 1997911, upload-time = "2025-10-06T21:11:10.329Z" }, - { url = "https://files.pythonhosted.org/packages/a7/ce/5c316fd62e01f8d6be1b7ee6b54273214e871772997dc2c95e204997a055/pydantic_core-2.41.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42ae7fd6760782c975897e1fdc810f483b021b32245b0105d40f6e7a3803e4b", size = 2034301, upload-time = "2025-10-06T21:11:12.113Z" }, - { url = "https://files.pythonhosted.org/packages/29/41/902640cfd6a6523194123e2c3373c60f19006447f2fb06f76de4e8466c5b/pydantic_core-2.41.1-cp313-cp313-win_arm64.whl", hash = "sha256:ad4111acc63b7384e205c27a2f15e23ac0ee21a9d77ad6f2e9cb516ec90965fb", size = 1977238, upload-time = "2025-10-06T21:11:14.1Z" }, - { url = "https://files.pythonhosted.org/packages/04/04/28b040e88c1b89d851278478842f0bdf39c7a05da9e850333c6c8cbe7dfa/pydantic_core-2.41.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:440d0df7415b50084a4ba9d870480c16c5f67c0d1d4d5119e3f70925533a0edc", size = 1875626, upload-time = "2025-10-06T21:11:15.69Z" }, - { url = "https://files.pythonhosted.org/packages/d6/58/b41dd3087505220bb58bc81be8c3e8cbc037f5710cd3c838f44f90bdd704/pydantic_core-2.41.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71eaa38d342099405dae6484216dcf1e8e4b0bebd9b44a4e08c9b43db6a2ab67", size = 2045708, upload-time = "2025-10-06T21:11:17.258Z" }, - { url = "https://files.pythonhosted.org/packages/d7/b8/760f23754e40bf6c65b94a69b22c394c24058a0ef7e2aa471d2e39219c1a/pydantic_core-2.41.1-cp313-cp313t-win_amd64.whl", hash = "sha256:555ecf7e50f1161d3f693bc49f23c82cf6cdeafc71fa37a06120772a09a38795", size = 1997171, upload-time = "2025-10-06T21:11:18.822Z" }, - { url = "https://files.pythonhosted.org/packages/41/12/cec246429ddfa2778d2d6301eca5362194dc8749ecb19e621f2f65b5090f/pydantic_core-2.41.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:05226894a26f6f27e1deb735d7308f74ef5fa3a6de3e0135bb66cdcaee88f64b", size = 2107836, upload-time = "2025-10-06T21:11:20.432Z" }, - { url = "https://files.pythonhosted.org/packages/20/39/baba47f8d8b87081302498e610aefc37142ce6a1cc98b2ab6b931a162562/pydantic_core-2.41.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:85ff7911c6c3e2fd8d3779c50925f6406d770ea58ea6dde9c230d35b52b16b4a", size = 1904449, upload-time = "2025-10-06T21:11:22.185Z" }, - { url = "https://files.pythonhosted.org/packages/50/32/9a3d87cae2c75a5178334b10358d631bd094b916a00a5993382222dbfd92/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47f1f642a205687d59b52dc1a9a607f45e588f5a2e9eeae05edd80c7a8c47674", size = 1961750, upload-time = "2025-10-06T21:11:24.348Z" }, - { url = "https://files.pythonhosted.org/packages/27/42/a96c9d793a04cf2a9773bff98003bb154087b94f5530a2ce6063ecfec583/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df11c24e138876ace5ec6043e5cae925e34cf38af1a1b3d63589e8f7b5f5cdc4", size = 2063305, upload-time = "2025-10-06T21:11:26.556Z" }, - { url = "https://files.pythonhosted.org/packages/3e/8d/028c4b7d157a005b1f52c086e2d4b0067886b213c86220c1153398dbdf8f/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f0bf7f5c8f7bf345c527e8a0d72d6b26eda99c1227b0c34e7e59e181260de31", size = 2228959, upload-time = "2025-10-06T21:11:28.426Z" }, - { url = "https://files.pythonhosted.org/packages/08/f7/ee64cda8fcc9ca3f4716e6357144f9ee71166775df582a1b6b738bf6da57/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82b887a711d341c2c47352375d73b029418f55b20bd7815446d175a70effa706", size = 2345421, upload-time = "2025-10-06T21:11:30.226Z" }, - { url = "https://files.pythonhosted.org/packages/13/c0/e8ec05f0f5ee7a3656973ad9cd3bc73204af99f6512c1a4562f6fb4b3f7d/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5f1d5d6bbba484bdf220c72d8ecd0be460f4bd4c5e534a541bb2cd57589fb8b", size = 2065288, upload-time = "2025-10-06T21:11:32.019Z" }, - { url = "https://files.pythonhosted.org/packages/0a/25/d77a73ff24e2e4fcea64472f5e39b0402d836da9b08b5361a734d0153023/pydantic_core-2.41.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bf1917385ebe0f968dc5c6ab1375886d56992b93ddfe6bf52bff575d03662be", size = 2189759, upload-time = "2025-10-06T21:11:33.753Z" }, - { url = "https://files.pythonhosted.org/packages/66/45/4a4ebaaae12a740552278d06fe71418c0f2869537a369a89c0e6723b341d/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f94f3ab188f44b9a73f7295663f3ecb8f2e2dd03a69c8f2ead50d37785ecb04", size = 2140747, upload-time = "2025-10-06T21:11:35.781Z" }, - { url = "https://files.pythonhosted.org/packages/da/6d/b727ce1022f143194a36593243ff244ed5a1eb3c9122296bf7e716aa37ba/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:3925446673641d37c30bd84a9d597e49f72eacee8b43322c8999fa17d5ae5bc4", size = 2327416, upload-time = "2025-10-06T21:11:37.75Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8c/02df9d8506c427787059f87c6c7253435c6895e12472a652d9616ee0fc95/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:49bd51cc27adb980c7b97357ae036ce9b3c4d0bb406e84fbe16fb2d368b602a8", size = 2318138, upload-time = "2025-10-06T21:11:39.463Z" }, - { url = "https://files.pythonhosted.org/packages/98/67/0cf429a7d6802536941f430e6e3243f6d4b68f41eeea4b242372f1901794/pydantic_core-2.41.1-cp314-cp314-win32.whl", hash = "sha256:a31ca0cd0e4d12ea0df0077df2d487fc3eb9d7f96bbb13c3c5b88dcc21d05159", size = 1998429, upload-time = "2025-10-06T21:11:41.989Z" }, - { url = "https://files.pythonhosted.org/packages/38/60/742fef93de5d085022d2302a6317a2b34dbfe15258e9396a535c8a100ae7/pydantic_core-2.41.1-cp314-cp314-win_amd64.whl", hash = "sha256:1b5c4374a152e10a22175d7790e644fbd8ff58418890e07e2073ff9d4414efae", size = 2028870, upload-time = "2025-10-06T21:11:43.66Z" }, - { url = "https://files.pythonhosted.org/packages/31/38/cdd8ccb8555ef7720bd7715899bd6cfbe3c29198332710e1b61b8f5dd8b8/pydantic_core-2.41.1-cp314-cp314-win_arm64.whl", hash = "sha256:4fee76d757639b493eb600fba668f1e17475af34c17dd61db7a47e824d464ca9", size = 1974275, upload-time = "2025-10-06T21:11:45.476Z" }, - { url = "https://files.pythonhosted.org/packages/e7/7e/8ac10ccb047dc0221aa2530ec3c7c05ab4656d4d4bd984ee85da7f3d5525/pydantic_core-2.41.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f9b9c968cfe5cd576fdd7361f47f27adeb120517e637d1b189eea1c3ece573f4", size = 1875124, upload-time = "2025-10-06T21:11:47.591Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e4/7d9791efeb9c7d97e7268f8d20e0da24d03438a7fa7163ab58f1073ba968/pydantic_core-2.41.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1ebc7ab67b856384aba09ed74e3e977dded40e693de18a4f197c67d0d4e6d8e", size = 2043075, upload-time = "2025-10-06T21:11:49.542Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c3/3f6e6b2342ac11ac8cd5cb56e24c7b14afa27c010e82a765ffa5f771884a/pydantic_core-2.41.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8ae0dc57b62a762985bc7fbf636be3412394acc0ddb4ade07fe104230f1b9762", size = 1995341, upload-time = "2025-10-06T21:11:51.497Z" }, - { url = "https://files.pythonhosted.org/packages/2b/3e/a51c5f5d37b9288ba30683d6e96f10fa8f1defad1623ff09f1020973b577/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b04fa9ed049461a7398138c604b00550bc89e3e1151d84b81ad6dc93e39c4c06", size = 2115344, upload-time = "2025-10-07T10:50:02.466Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bd/389504c9e0600ef4502cd5238396b527afe6ef8981a6a15cd1814fc7b434/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b3b7d9cfbfdc43c80a16638c6dc2768e3956e73031fca64e8e1a3ae744d1faeb", size = 1927994, upload-time = "2025-10-07T10:50:04.379Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9c/5111c6b128861cb792a4c082677e90dac4f2e090bb2e2fe06aa5b2d39027/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eec83fc6abef04c7f9bec616e2d76ee9a6a4ae2a359b10c21d0f680e24a247ca", size = 1959394, upload-time = "2025-10-07T10:50:06.335Z" }, - { url = "https://files.pythonhosted.org/packages/14/3f/cfec8b9a0c48ce5d64409ec5e1903cb0b7363da38f14b41de2fcb3712700/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6771a2d9f83c4038dfad5970a3eef215940682b2175e32bcc817bdc639019b28", size = 2147365, upload-time = "2025-10-07T10:50:07.978Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/00/e9/3916abb671bffb00845408c604ff03480dc8dc273310d8268547a37be0fb/pydantic_core-2.41.3.tar.gz", hash = "sha256:cdebb34b36ad05e8d77b4e797ad38a2a775c2a07a8fa386d4f6943b7778dcd39", size = 457489, upload-time = "2025-10-13T19:34:51.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/11/3149cae2a61ddd11c206cde9dab7598a53cfabe8e69850507876988d2047/pydantic_core-2.41.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7bdc8b70bc4b68e4d891b46d018012cac7bbfe3b981a7c874716dde09ff09fd5", size = 2098919, upload-time = "2025-10-13T19:31:28.727Z" }, + { url = "https://files.pythonhosted.org/packages/53/64/1717c7c5b092c64e5022b0d02b11703c2c94c31d897366b6c8d160b7d1de/pydantic_core-2.41.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446361e93f4ffe509edae5862fb89a0d24cbc8f2935f05c6584c2f2ca6e7b6df", size = 1910372, upload-time = "2025-10-13T19:31:30.351Z" }, + { url = "https://files.pythonhosted.org/packages/99/ba/0231b5dde6c1c436e0d58aed7d63f927694d92c51aff739bf692142ce6e6/pydantic_core-2.41.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9af9a9ae24b866ce58462a7de61c33ff035e052b7a9c05c29cf496bd6a16a63f", size = 1952392, upload-time = "2025-10-13T19:31:32.345Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5d/1adbfa682a56544d70b42931f19de44a4e58a4fc2152da343a2fdfd4cad5/pydantic_core-2.41.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc836eb8561f04fede7b73747463bd08715be0f55c427e0f0198aa2f1d92f913", size = 2041093, upload-time = "2025-10-13T19:31:34.534Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d3/9d14041f0b125a5d6388957cace43f9dfb80d862e56a0685dde431a20b6a/pydantic_core-2.41.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16f80f366472eb6a3744149289c263e5ef182c8b18422192166b67625fef3c50", size = 2214331, upload-time = "2025-10-13T19:31:36.575Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cd/384988d065596fafecf9baeab0c66ef31610013b26eec3b305a80ab5f669/pydantic_core-2.41.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d699904cd13d0f509bdbb17f0784abb332d4aa42df4b0a8b65932096fcd4b21", size = 2344450, upload-time = "2025-10-13T19:31:38.905Z" }, + { url = "https://files.pythonhosted.org/packages/a3/13/1b0dd34fce51a746823a347d7f9e02c6ea09078ec91c5f656594c23d2047/pydantic_core-2.41.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485398dacc5dddb2be280fd3998367531eccae8631f4985d048c2406a5ee5ecc", size = 2070507, upload-time = "2025-10-13T19:31:41.093Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/0f8d6d67d917318d842fe8dba2489b0c5989ce01fc1ed58bf204f80663df/pydantic_core-2.41.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6dfe0898272bf675941cd1ea701677341357b77acadacabbd43d71e09763dceb", size = 2185401, upload-time = "2025-10-13T19:31:42.785Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/b8a82253736f2efd3b79338dfe53866b341b68868fbce7111ff6b040b680/pydantic_core-2.41.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:86ffbf5291c367a56b5718590dc3452890f2c1ac7b76d8f4a1e66df90bd717f6", size = 2131929, upload-time = "2025-10-13T19:31:46.226Z" }, + { url = "https://files.pythonhosted.org/packages/7c/16/efe252cbf852ebfcb4978820e7681d83ae45c526cbfc0cf847f70de49850/pydantic_core-2.41.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:c58c5acda77802eedde3aaf22be09e37cfec060696da64bf6e6ffb2480fdabd0", size = 2307223, upload-time = "2025-10-13T19:31:48.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ea/7d8eba2c37769d8768871575be449390beb2452a2289b0090ea7fa63f920/pydantic_core-2.41.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:40db5705aec66371ca5792415c3e869137ae2bab48c48608db3f84986ccaf016", size = 2312962, upload-time = "2025-10-13T19:31:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/02/c4/b617e33c3b6f4a99c7d252cc42df958d14627a09a1a935141fb9abe44189/pydantic_core-2.41.3-cp312-cp312-win32.whl", hash = "sha256:668fcb317a0b3c84781796891128111c32f83458d436b022014ed0ea07f66e1b", size = 1988735, upload-time = "2025-10-13T19:31:51.778Z" }, + { url = "https://files.pythonhosted.org/packages/24/fc/05bb0249782893b52baa7732393c0bac9422d6aab46770253f57176cddba/pydantic_core-2.41.3-cp312-cp312-win_amd64.whl", hash = "sha256:248a5d1dac5382454927edf32660d0791d2df997b23b06a8cac6e3375bc79cee", size = 2032239, upload-time = "2025-10-13T19:31:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/75/1d/7637f6aaafdbc27205296bde9843096bd449192986b5523869444f844b82/pydantic_core-2.41.3-cp312-cp312-win_arm64.whl", hash = "sha256:347a23094c98b7ea2ba6fff93b52bd2931a48c9c1790722d9e841f30e4b7afcd", size = 1969072, upload-time = "2025-10-13T19:31:55.7Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a6/7533cba20b8b66e209d8d2acbb9ccc0bc1b883b0654776d676e02696ef5d/pydantic_core-2.41.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a8596700fdd3ee12b0d9c1f2395f4c32557e7ebfbfacdc08055b0bcbe7d2827e", size = 2105686, upload-time = "2025-10-13T19:31:57.675Z" }, + { url = "https://files.pythonhosted.org/packages/84/d7/2d15cb9dfb9f94422fb4a8820cbfeb397e3823087c2361ef46df5c172000/pydantic_core-2.41.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:624503f918e472c0eed6935020c01b6a6b4bcdb7955a848da5c8805d40f15c0f", size = 1910554, upload-time = "2025-10-13T19:32:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fc/cbd1caa19e88fd64df716a37b49e5864c1ac27dbb9eb870b8977a584fa42/pydantic_core-2.41.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36388958d0c614df9f5de1a5f88f4b79359016b9ecdfc352037788a628616aa2", size = 1957559, upload-time = "2025-10-13T19:32:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/3b/fe/da942ae51f602173556c627304dc24b9fa8bd04423bce189bf397ba0419e/pydantic_core-2.41.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c50eba144add9104cf43ef9a3d81c37ebf48bfd0924b584b78ec2e03ec91daf", size = 2051084, upload-time = "2025-10-13T19:32:05.056Z" }, + { url = "https://files.pythonhosted.org/packages/c8/62/0abd59a7107d1ef502b9cfab68145c6bb87115c2d9e883afbf18b98fe6db/pydantic_core-2.41.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6ea2102958eb5ad560d570c49996e215a6939d9bffd0e9fd3b9e808a55008cc", size = 2218098, upload-time = "2025-10-13T19:32:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/93a36aa119b70126f3f0d06b6f9a81ca864115962669d8a85deb39c82ecc/pydantic_core-2.41.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd0d26f1e4335d5f84abfc880da0afa080c8222410482f9ee12043bb05f55ec8", size = 2341954, upload-time = "2025-10-13T19:32:08.583Z" }, + { url = "https://files.pythonhosted.org/packages/0f/be/7c2563b53b71ff3e41950b0ffa9eeba3d702091c6d59036fff8a39050528/pydantic_core-2.41.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41c38700094045b12c0cff35c8585954de66cf6dd63909fed1c2e6b8f38e1e1e", size = 2069474, upload-time = "2025-10-13T19:32:10.808Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ac/2394004db9f6e03712c1e52f40f0979750fa87721f6baf5f76ad92b8be46/pydantic_core-2.41.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4061cc82d7177417fdb90e23e67b27425ecde2652cfd2053b5b4661a489ddc19", size = 2190633, upload-time = "2025-10-13T19:32:12.731Z" }, + { url = "https://files.pythonhosted.org/packages/7d/31/7b70c2d1fe41f450f8022f5523edaaea19c17a2d321fab03efd03aea1fe8/pydantic_core-2.41.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:b1d9699a4dae10a7719951cca1e30b591ef1dd9cdda9fec39282a283576c0241", size = 2137097, upload-time = "2025-10-13T19:32:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ae/f872198cffc8564f52c4ef83bcd3e324e5ac914e168c6b812f5ce3f80aab/pydantic_core-2.41.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:d5099f1b97e79f0e45cb6a236a5bd1a20078ed50b1b28f3d17f6c83ff3585baa", size = 2316771, upload-time = "2025-10-13T19:32:16.586Z" }, + { url = "https://files.pythonhosted.org/packages/23/50/f0fce3a9a7554ced178d943e1eada58b15fca896e9eb75d50244fc12007c/pydantic_core-2.41.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:b5ff0467a8c1b6abb0ab9c9ea80e2e3a9788592e44c726c2db33fdaf1b5e7d0b", size = 2319449, upload-time = "2025-10-13T19:32:18.503Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/86a6948408e8388604c02ffde651a2e39b711bd1ab6eeaff376094553a10/pydantic_core-2.41.3-cp313-cp313-win32.whl", hash = "sha256:edfe9b4cee4a91da7247c25732f24504071f3e101c050694d18194b7d2d320bf", size = 1995352, upload-time = "2025-10-13T19:32:20.5Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4b/6dac37c3f62684dc459a31623d8ae97ee433fd68bb827e5c64dd831a5087/pydantic_core-2.41.3-cp313-cp313-win_amd64.whl", hash = "sha256:44af3276c0c2c14efde6590523e4d7e04bcd0e46e0134f0dbef1be0b64b2d3e3", size = 2031894, upload-time = "2025-10-13T19:32:23.11Z" }, + { url = "https://files.pythonhosted.org/packages/fd/75/3d9ba041a3fcb147279fbb37d2468efe62606809fec97b8de78174335ef4/pydantic_core-2.41.3-cp313-cp313-win_arm64.whl", hash = "sha256:59aeed341f92440d51fdcc82c8e930cfb234f1843ed1d4ae1074f5fb9789a64b", size = 1974036, upload-time = "2025-10-13T19:32:25.219Z" }, + { url = "https://files.pythonhosted.org/packages/50/68/45842628ccdb384df029f884ef915306d195c4f08b66ca4d99867edc6338/pydantic_core-2.41.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef37228238b3a280170ac43a010835c4a7005742bc8831c2c1a9560de4595dbe", size = 1876856, upload-time = "2025-10-13T19:32:27.504Z" }, + { url = "https://files.pythonhosted.org/packages/99/73/336a82910c6a482a0ba9a255c08dcc456ebca9735df96d7a82dffe17626a/pydantic_core-2.41.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cb19f36253152c509abe76c1d1b185436e0c75f392a82934fe37f4a1264449", size = 1884665, upload-time = "2025-10-13T19:32:29.567Z" }, + { url = "https://files.pythonhosted.org/packages/34/87/ec610a7849561e0ef7c25b74ef934d154454c3aac8fb595b899557f3c6ab/pydantic_core-2.41.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91be4756e05367ce19a70e1db3b77f01f9e40ca70d26fb4cdfa993e53a08964a", size = 2043067, upload-time = "2025-10-13T19:32:31.506Z" }, + { url = "https://files.pythonhosted.org/packages/db/b4/5f2b0cf78752f9111177423bd5f2bc0815129e587c13401636b8900a417e/pydantic_core-2.41.3-cp313-cp313t-win_amd64.whl", hash = "sha256:ce7d8f4353f82259b55055bd162bbaf599f6c40cd0c098e989eeb95f9fdc022f", size = 1996799, upload-time = "2025-10-13T19:32:33.612Z" }, + { url = "https://files.pythonhosted.org/packages/49/7f/07e7f19a6a44a52abd48846e348e11fa1b3de5ed7c0231d53f055ffb365f/pydantic_core-2.41.3-cp313-cp313t-win_arm64.whl", hash = "sha256:f06a9e81da60e5a0ef584f6f4790f925c203880ae391bf363d97126fd1790b21", size = 1969574, upload-time = "2025-10-13T19:32:35.533Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/db32fbced75853c1d8e7ada8cb2b837ade99b2f281de569908de3e29f0bf/pydantic_core-2.41.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0c77e8e72344e34052ea26905fa7551ecb75fc12795ca1a8e44f816918f4c718", size = 2103383, upload-time = "2025-10-13T19:32:37.522Z" }, + { url = "https://files.pythonhosted.org/packages/de/28/5bcb3327b3777994633f4cb459c5dc34a9cbe6cf0ac449d3e8f1e74bdaaa/pydantic_core-2.41.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32be442a017e82a6c496a52ef5db5f5ac9abf31c3064f5240ee15a1d27cc599e", size = 1904974, upload-time = "2025-10-13T19:32:39.513Z" }, + { url = "https://files.pythonhosted.org/packages/71/8d/c9d8cad7c02d63869079fb6fb61b8ab27adbeeda0bf130c684fe43daa126/pydantic_core-2.41.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af10c78f0e9086d2d883ddd5a6482a613ad435eb5739cf1467b1f86169e63d91", size = 1956879, upload-time = "2025-10-13T19:32:41.849Z" }, + { url = "https://files.pythonhosted.org/packages/15/b1/8a84b55631a45375a467df288d8f905bec0abadb1e75bce3b32402b49733/pydantic_core-2.41.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6212874118704e27d177acee5b90b83556b14b2eb88aae01bae51cd9efe27019", size = 2051787, upload-time = "2025-10-13T19:32:43.86Z" }, + { url = "https://files.pythonhosted.org/packages/c3/97/a84ea9cb7ba4dbfd43865e5dd536b22c78ee763d82d501c6f6a553403c00/pydantic_core-2.41.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6a24c82674a3a8e7f7306e57e98219e5c1cdfc0f57bc70986930dda136230b2", size = 2217830, upload-time = "2025-10-13T19:32:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/64233c77410e314dbb7f2e8112be7f56de57cf64198a32d8ab3f7b74adf4/pydantic_core-2.41.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e0c81dc047c18059410c959a437540abcefea6a882d6e43b9bf45c291eaacd9", size = 2341131, upload-time = "2025-10-13T19:32:48.402Z" }, + { url = "https://files.pythonhosted.org/packages/23/3d/915b90eb0de93bd522b293fd1a986289f5d576c72e640f3bb426b496d095/pydantic_core-2.41.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0d7e1a9f80f00a8180b9194ecef66958eb03f3c3ae2d77195c9d665ac0a61e", size = 2063797, upload-time = "2025-10-13T19:32:50.458Z" }, + { url = "https://files.pythonhosted.org/packages/4d/25/a65665caa86e496e19feef48e6bd9263c1a46f222e8f9b0818f67bd98dc3/pydantic_core-2.41.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2868fabfc35ec0738539ce0d79aab37aeffdcb9682b9b91f0ac4b0ba31abb1eb", size = 2193041, upload-time = "2025-10-13T19:32:52.686Z" }, + { url = "https://files.pythonhosted.org/packages/cd/46/a7f7e17f99ee691a7d93a53aa41bf7d1b1d425945b6e9bc8020498a413e1/pydantic_core-2.41.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:cb4f40c93307e1c50996e4edcddf338e1f3f1fb86fb69b654111c6050ae3b081", size = 2136119, upload-time = "2025-10-13T19:32:54.737Z" }, + { url = "https://files.pythonhosted.org/packages/5f/92/c27c1f3edd06e04af71358aa8f4d244c8bc6726e3fb47e00157d3dffe66f/pydantic_core-2.41.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:287cbcd3407a875eaf0b1efa2e5288493d5b79bfd3629459cf0b329ad8a9071a", size = 2317223, upload-time = "2025-10-13T19:32:56.927Z" }, + { url = "https://files.pythonhosted.org/packages/51/6c/20aabe3c32888fb13d4726e405716fed14b1d4d1d4292d585862c1458b7b/pydantic_core-2.41.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5253835aa145049205a67056884555a936f9b3fea7c3ce860bff62be6a1ae4d1", size = 2320425, upload-time = "2025-10-13T19:32:59.454Z" }, + { url = "https://files.pythonhosted.org/packages/67/d2/476d4bc6b3070e151ae920167f27f26415e12f8fcc6cf5a47a613aba7267/pydantic_core-2.41.3-cp314-cp314-win32.whl", hash = "sha256:69297795efe5349156d18eebea818b75d29a1d3d1d5f26a250f22ab4220aacd6", size = 1994216, upload-time = "2025-10-13T19:33:01.484Z" }, + { url = "https://files.pythonhosted.org/packages/16/ca/2cd8515584b3d665ca3c4d946364c2a9932d0d5648694c2a10d273cde81c/pydantic_core-2.41.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1c133e3447c2f6d95e47ede58fff0053370758112a1d39117d0af8c93584049", size = 2026522, upload-time = "2025-10-13T19:33:03.546Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/c9f2791d7188594f0abdc1b7fe8ec3efc123ee2d9c553fd3b6da2d9fd53d/pydantic_core-2.41.3-cp314-cp314-win_arm64.whl", hash = "sha256:54534eecbb7a331521f832e15fc307296f491ee1918dacfd4d5b900da6ee3332", size = 1969070, upload-time = "2025-10-13T19:33:05.604Z" }, + { url = "https://files.pythonhosted.org/packages/b5/eb/45f9a91f8c09f4cfb62f78dce909b20b6047ce4fd8d89310fcac5ad62e54/pydantic_core-2.41.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6b4be10152098b43c093a4b5e9e9da1ac7a1c954c1934d4438d07ba7b7bcf293", size = 1876593, upload-time = "2025-10-13T19:33:07.814Z" }, + { url = "https://files.pythonhosted.org/packages/99/f8/5c9d0959e0e1f260eea297a5ecc1dc29a14e03ee6a533e805407e8403c1a/pydantic_core-2.41.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe4ebd676c158a7994253161151b476dbbef2acbd2f547cfcfdf332cf67cc29", size = 1882977, upload-time = "2025-10-13T19:33:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f4/7ab918e35f55e7beee471ba8c67dfc4c9c19a8904e4867bfda7f9c76a72e/pydantic_core-2.41.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:984ca0113b39dda1d7c358d6db03dd6539ef244d0558351806c1327239e035bf", size = 2041033, upload-time = "2025-10-13T19:33:12.216Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c8/5b12e5a36410ebcd0082ae5b0258150d72762e306f298cc3fe731b5574ec/pydantic_core-2.41.3-cp314-cp314t-win_amd64.whl", hash = "sha256:2a7dd8a6f5a9a2f8c7f36e4fc0982a985dbc4ac7176ee3df9f63179b7295b626", size = 1994462, upload-time = "2025-10-13T19:33:14.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f6/c6f3b7244a2a0524f4a04052e3d590d3be0ba82eb1a2f0fe5d068237701e/pydantic_core-2.41.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b387f08b378924fa82bd86e03c9d61d6daca1a73ffb3947bdcfe12ea14c41f68", size = 1973551, upload-time = "2025-10-13T19:33:16.87Z" }, + { url = "https://files.pythonhosted.org/packages/68/e6/a41dec3d50cfbd7445334459e847f97a62c5658d2c6da268886928ffd357/pydantic_core-2.41.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:a6ded5abbb7391c0db9e002aaa5f0e3a49a024b0a22e2ed09ab69087fd5ab8a8", size = 2112077, upload-time = "2025-10-13T19:34:00.77Z" }, + { url = "https://files.pythonhosted.org/packages/44/38/e136a52ae85265a07999439cd8dcd24ba4e83e23d61e40000cd74b426f19/pydantic_core-2.41.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:43abc869cce9104ff35cb4eff3028e9a87346c95fe44e0173036bf4d782bdc3d", size = 1920464, upload-time = "2025-10-13T19:34:03.454Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/a3f509f682818ded836bd006adce08d731d81c77694a26a0a1a448f3e351/pydantic_core-2.41.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb3c63f4014a603caee687cd5c3c63298d2c8951b7acb2ccd0befbf2e1c0b8ad", size = 1951926, upload-time = "2025-10-13T19:34:05.983Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/cb30ad2a0147cc7763c0c805ee1c534f6ed5d5db7bc8cf8ebaf34b4c9dab/pydantic_core-2.41.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88461e25f62e58db4d8b180e2612684f31b5844db0a8f8c1c421498c97bc197b", size = 2139233, upload-time = "2025-10-13T19:34:08.396Z" }, ] [[package]] From c9821e75b65d2767c76c60db40c571cf1d32dc75 Mon Sep 17 00:00:00 2001 From: Johan Hidding Date: Tue, 14 Oct 2025 15:47:07 +0200 Subject: [PATCH 5/6] fix faulty import inn command new --- entangled/commands/new.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entangled/commands/new.py b/entangled/commands/new.py index 860eaf5..80e7e97 100644 --- a/entangled/commands/new.py +++ b/entangled/commands/new.py @@ -3,7 +3,7 @@ import argparse from pathlib import Path -from brei.cli import RichHelpFormatter +from rich_argparse import RichHelpFormatter from rich.console import Console from rich.table import Table From deceee1a2344763a5336e51cdb1266d898106407 Mon Sep 17 00:00:00 2001 From: Johan Hidding Date: Tue, 14 Oct 2025 15:49:41 +0200 Subject: [PATCH 6/6] fix implicit optional inn parser --- entangled/parsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entangled/parsing.py b/entangled/parsing.py index f2ed02b..bbf0b59 100644 --- a/entangled/parsing.py +++ b/entangled/parsing.py @@ -178,7 +178,7 @@ def read(self, inp: str) -> tuple[T | U, str]: raise ChoiceFailure("", inp, failures) -def optional[T, U](p: Parser[T], default: U = None) -> Choice[T, U]: +def optional[T, U](p: Parser[T], default: U | None = None) -> Choice[T, U | None]: return p | pure(default)